Policy decisions
policyDecide every sensitive call in process, explain which rule decided, and later load policies from git and roll changes out in shadow first.
policyFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
policy is the one layer that decides who may do what and what data, hosts and models an app may reach. You ask a question and get allow or deny, the reasons and an id.
You ask it in your own code when a decision is sensitive, such as exporting data or acting across a border. Every decision runs in process and is written to the audit log.
What runs today is the built-in engine that access also uses: roles, owner rules, named rules, relations and the three modes. policy.decide() and policy.explain() are thin calls over it, and you configure it under access: in main.yaml. Policies as code in policies/, the Cedar and OPA engines, layers, policy.test(), policy.rollout() and policy.decideRemote() are planned for 0.2 (spec policy) and are marked below. Do not write them yet.
At a glance
policy:
engine: cedar
placement: server
dir: policies/
mode: observe # the default for new policiesimport { policy } from "mainsdk";
const d = policy.decide({
subject: { type: "user", id: user.id },
action: { name: "export.data" },
resource: { type: "report", id: report.id },
context: { country: "IN" },
});
if (d.decision === "deny") console.log(policy.explain(d.id));lib/export.test.ts
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { policy } from "mainsdk";
createTestApp({ yaml: "schema: 1\nproject: prj_test\naccess:\n roles:\n analyst: [reports.read]\n" });
const d = policy.decide({
subject: { id: "u_1", roles: ["analyst"] },
action: "export.data",
resource: { type: "report", id: "r_1" },
});
assert.equal(d.allow, false);
assert.equal(policy.explain(d.id)?.decision.reasons[0], "no role or owner rule grants export.data");Drivers
2 drivers. Select one to see its line in main.yaml.
policy:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-POLICY-0004 | Checkmainsdk check, before deploy | A repo policy weakens an org policy; loading fails, naming both files |
MAIN-POLICY-0001 | RuntimeError responses and logs | See the spec |
MAIN-POLICY-0002 | RuntimeError responses and logs | See the spec |
MAIN-POLICY-0003 | RuntimeError responses and logs | See the spec |
MAIN-POLICY-0005 | RuntimeError responses and logs | See the spec |
MAIN-POLICY-0006 | RuntimeError responses and logs | See the spec |
MAIN-POLICY-0009 | RuntimeError responses and logs | A new policy set failed to load; the previous version stays in force |
MAIN-POLICY-0011 | RuntimeError responses and logs | A Cedar policy uses a construct the built-in TypeScript engine doesn't support yet; it names the construct and line and never ignores it |
MAIN-POLICY-0012 | RuntimeError responses and logs | See the spec |
MAIN-PLAY-0001 | RuntimeError responses and logs | The tests file is not a list of tests with a name, an input and allow or deny as the expectation (400). Fix: write each test as { "name": "…", "input": { … }, "expect": "allow" } |
MAIN-PLAY-0002 | RuntimeError responses and logs | The replay holds more recorded decisions than the playground replays at once (413). Fix: replay a smaller sample of at most 100000 decisions |
MAIN-PLAY-0003 | RuntimeError responses and logs | The link does not hold a playground state this version can read (400). Fix: ask for the link again, or paste the policy and request in by hand |
MAIN-PLAY-0004 | RuntimeError responses and logs | The state is too large to share, or getqr.id did not make the link (502 or 413). Fix: share without the replay, or share again when the connection is back |
MAIN-PLAY-0005 | RuntimeError responses and logs | mainsdk policy play could not read a policy, request, entities, tests or replay file (400). Fix: pass a folder with policies and a request file that holds JSON |
MAIN-POLICY-0007 | RuntimeError responses and logs | mainsdk test policy --recorded was given a folder that does not exist |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Deny-by-default decisions | Phase 1 | 0.2 | policy#decide |
| Policies folder with a content-hashed version | Phase 1 | 0.2 | policy#policies-folder |
| Observe, warn and enforce modes | Phase 1 | 0.2 | policy#modes |
| Org, repo, overlay and Fleet layers | Phase 1 | 0.2 | policy#layering |
| Explain a decision | Phase 1 | 0.2 | policy#explain |
| Policy tests and recorded replay | Phase 1 | 0.2 | policy#test |
| Staged rollout with automatic rollback | Phase 1 | 0.2 | policy#rollout |
| OPA driver for Rego | Phase 1 | 0.2 | policy#opa-driver |
| Last good policy set | Phase 1 | 0.2 | policy#fail-static |
| Server, edge or browser placement | Phase 1 | 0.2 | policy#placement |
| Policy playground | Phase 2 | 0.7 | policy-playground |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Teams | Policy shared across the organisation and rolled out to the team's projects |
| Fleet | Central policy pushed to every app in the estate, signed with keys the tenant holds |
Quick start
access:
mode: enforce # enforce | warn | observe
roles:
admin: ["*"]
analyst: [reports.read]import { auth, policy } from "mainsdk";
declare function startExport(reportId: string): Promise<Response>; // your own export job
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth.require(req);
const d = policy.decide({ // synchronous, in process
subject: { type: "user", id: session.user, roles: ["analyst"] },
action: { name: "export.data" },
resource: { type: "report", id },
context: { purpose: "audit" },
});
if (!d.allow) {
return Response.json({ reasons: d.reasons, decisionId: d.id }, { status: 403 });
}
return startExport(id);
}With no matching rule the decision is deny, with the reason no role or owner rule grants export.data. A decision is { id, decision, allow, mode, policyVersion, reasons }. In a real app the roles come from the tenant membership when you leave roles out.
Check d.allow, not d.decision. In observe and warn mode decision can be "deny" while allow is true, so the request goes ahead and the would-be denial is logged.
Configuration
Today the keys live under access:. See permissions for all of them.
access:
roles:
admin: ["*"] # every action
analyst: [reports.read]
owner: [read, update] # on a resource whose owner is the subject
when:
export.data: [audit-only] # named rules that must also hold
mode: enforce # enforce | warn | observe| Key | What it does |
|---|---|
roles | Role name to the actions it grants. "*" grants every action, "reports.*" every reports action |
owner | Actions a subject may take on a resource whose owner is their id |
when | Action to named rules that must also hold. A rule that is not registered fails closed |
mode | enforce blocks, warn and observe allow and record the would-be decision. Default enforce |
The policy version is a short hash of this section, and every decision carries it.
Planned for 0.2, not in the code yet: a policy: section with engine (cedar or opa), placement (server, edge or browser), dir: policies/ for policies kept in git, and layers where org, repo, market and Fleet policies apply in order and a lower layer only tightens.
What you get
- An allow or deny for each question, with the reasons, the mode and the policy version.
- A decision id that
policy.explain()turns back into the rule that decided. - In-process, deterministic decisions with no network round trip, each written to the audit log.
- Deny by default when no rule permits, and a denial across tenants before any rule runs.
Examples
Explain a denial
Every decision has an id. policy.explain(id) returns the decision and the request that produced it, or null for an id it does not hold. It keeps the last 10,000 decisions of the process.
import { auth, policy } from "mainsdk";
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await auth.require(req, { role: "admin" });
const found = policy.explain(id);
return found ? Response.json(found) : new Response(null, { status: 404 });
}The reasons name the rule: role admin grants *, owner of report may read, no role or owner rule grants export.data, cross-tenant, or the API key does not have this scope.
Shadow a new rule, then enforce it
Set access.mode: observe. The request is allowed and the would-be decision is logged with mode: "observe". Move to warn, then enforce, once the log looks right.
import { auth, policy } from "mainsdk";
declare function createTransfer(body: { accountId: string; amount: number }): Promise<unknown>; // your own transfer code
export async function POST(req: Request) {
const session = await auth.require(req);
const body = (await req.json()) as { accountId: string; amount: number };
const d = policy.decide({
subject: { type: "user", id: session.user, roles: ["member"] },
action: { name: "transfers.create" },
resource: { type: "account", id: body.accountId, amount: body.amount },
});
if (!d.allow) return Response.json({ reasons: d.reasons }, { status: 403 }); // only enforce blocks
return Response.json(await createTransfer(body));
}Planned
These are in the spec and the catalog, and none of them is in the code yet.
- Policies as code. Cedar files in
policies/, a built-in TypeScript Cedar evaluator and an OPA driver for compiled Rego, with a content-hashed policy version and the last good set kept when a new one fails to load. - Layers. Org, repo, market and Fleet policies, where a lower layer only tightens. A repo policy that weakens an org one fails to load with
MAIN-POLICY-0004. policy.test()andmainsdk test policyto unit-test policies and replay recorded decisions.policy.rollout()to stage a change (shadow, then 5%, 25%, 100%) with automatic rollback.policy.decideRemote()for decisions in the browser or at the edge.
Questions the spec still lists as open: how rollout state is kept across instances, how DecisionInput maps to Cedar entities beyond context, and what counts as weakening beyond deny-to-allow.
Errors and checks
A denied decision is a value, not an error: policy.decide() does not throw. Code that turns a denial into an error uses access.require(), which throws a MainError with status 403.
The policy-loading codes are planned with the policy files: MAIN-POLICY-0004 (a lower layer weakens a higher one) and MAIN-POLICY-0009 (a new set fails to load and the last good set stays in force; see fail static).
Swapping drivers
Today there is one engine, the built-in one. The engine choice (cedar or opa) arrives with the policy: section, and the policy.decide() and policy.explain() calls will stay the same.
Testing
createTestApp builds the app from a main.yaml string, so a test states its own rules.
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { policy } from "mainsdk";
createTestApp({ yaml: "schema: 1\nproject: prj_test\naccess:\n roles:\n analyst: [reports.read]\n" });
const d = policy.decide({
subject: { id: "u_1", roles: ["analyst"] },
action: "export.data",
resource: { type: "report", id: "r_1" },
});
assert.equal(d.allow, false);
assert.equal(policy.explain(d.id)?.decision.reasons[0], "no role or owner rule grants export.data");policyTest and mainsdk test policy arrive with policy files.
Security and privacy
- Deny when no rule permits. A named rule that is not registered denies.
- Decisions run in process and are deterministic, with no network round trip.
- Every decision is written to the audit log with the decision id, policy version and mode.
- A decision across tenants is denied before any rule runs, and an API key never widens what the rules allow.
Everything on this page runs in your app and is free; see free and Cloud for what the plans add.
FAQ
Does a decision ever wait on the network? No. The built-in engine evaluates in process. MainSDK Cloud is never in the request path.
How is this different from access? access is the permissions interface for your app's records: access.check(), access.require(), relations and rules. policy is the same engine seen as the layer for every decision MainSDK makes, and it is where Cedar, layers and rollout will land.
Can my repo loosen an org rule for one case? Layers are planned. Exceptions with an owner and an expiry will live in main.yaml; see main.yaml.
Next
- Permissions: roles, rules and relations.
- Network permissions: rules for the hosts an app may reach.
- Fail static: why the last good policy stays in force.
- Try a policy in the playground:
mainsdk policy playand the browser page; codesMAIN-PLAY-0001to0005are in check codes.
Was this page helpful?