Policy as code
One policy engine decides who can do what and which data may go where, in the app, in CI and later from Fleet. Cedar by default, OPA as a driver.
Status 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.
One policy layer runs everything MainSDK decides: who can do what, which data may go where, which hosts and models an app may reach. It is the same engine and the same files in your app at runtime and in CI as gates. Nothing is decided twice in two languages.
Policy as code
Policies live in policies/ next to main.yaml, in Cedar by default or in Rego through the OPA driver. You review them in pull requests and version them in git. The policy version is a content hash of the loaded set, so every decision names exactly what decided it.
permit (
principal,
action == Action::"orders.update",
resource
) when { principal.role == "editor" };
permit (
principal,
action == Action::"orders.read",
resource
) when { resource.owner == principal };policy:
engine: cedar # cedar (built in) | opa
placement: server # server (default) | edge | browser
dir: policies/
mode: enforce # new policies default to observeWith no matching permit, the decision is deny.
import { auth, db, policy } from "mainsdk";
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth.require(req);
const decision = policy.decide({
subject: { type: "User", id: session.user.id, role: session.user.role },
action: { name: "orders.update" },
resource: { type: "Order", id },
});
if (decision.decision === "deny") {
return Response.json({ reasons: decision.reasons }, { status: 403 });
}
const order = await db.orders.for(session.user).update(id, await req.json());
return Response.json(order);
}Most of the time you don't call policy.decide yourself: every interface calls it for you. For roles and permissions in app code, use access; full calls are on the policy interface page.
Code as policy
The rules you write in main.yaml and the guarantees inside every interface compile to the same policies. network.allow is a policy. So is the owner scope on db. mainsdk explain shows, for any decision, the policy, the file and line, and the main.yaml line it was compiled from.
$ npx mainsdk explain dec_01JC9A…
decision deny mode enforce policy 9f2c1e… layer repo
reason no matching permit
source main.yaml:21 network.allowTested like code
$ npx mainsdk test policy --recorded .mainsdk/decisions
policies/orders.cedar 12 tests passed
recorded decisions 500 replayed · 3 flip allow → deny
orders.update User u_8f3 (viewer) Order 8812 allow → deny
…mainsdk test policy runs your policy unit tests and replays recorded real decisions, so you see what a change would allow or deny before it ships. In code, policy.test("policies/") does the same; for writing the tests see Test your app.
Observe, warn, enforce, then roll out
Every policy, and every runtime rule compiled from main.yaml such as network.allow, runs in one of three modes. New policies default to observe, so adopting a rule never breaks production.
| Mode | What happens |
|---|---|
observe | The decision is logged as a would-be deny. Nothing is blocked. |
warn | Allowed, with a warning in the check output and the log. |
enforce | Blocked. |
network:
allow: ["*.pinecart.dev"]
default: deny
mode: observe # observe, then warn, then enforceFor bigger changes, roll out in stages with automatic rollback:
import { policy } from "mainsdk";
policy.rollout("egress-v4", {
stages: ["shadow", "5%", "25%", "100%"],
rollbackIf: { denials: "+20%", errors: "+1%" },
});Canary traffic is assigned by a stable hash of the subject, so a user stays in one bucket. Every stage change is audited.
Still open: with no Cloud in the request path, each instance counts its own denials and errors. Whether each instance rolls back on its own, or the stage lives in git and changes only through sync, is not decided.
Layers only tighten
flowchart LR O[Org<br/>main.org.yaml] --> R[Repo<br/>policies/] --> V[Market and segment overlays] --> F[Fleet]
A lower layer can't turn a higher layer's deny into an allow. A repo policy that tries fails to load with both files named: MAIN-POLICY-0004 repo layer weakens org: data.export.
One decision log
Every decision, in the app or in CI, goes to the audit log with the decision ID, policy version, layer and mode, in OCSF so a SIEM reads it as is.
Fail static
If a new policy set fails to parse, validate or verify, the engine keeps the last good set and logs MAIN-POLICY-0009. It never falls back to no policy. Why that matters is on Never in the request path.
Where decisions run
placement | Where |
|---|---|
server (default) | On your server |
edge | In your edge runtime; mainsdk sync writes the edge entry |
browser | The browser asks your server, or, with policy.browser.local: true, loads the TypeScript Cedar engine lazily as a separate chunk, outside the 150 KB browser budget |
The browser is never the only place a deny is enforced for data or actions your server owns.
Bring your own engine
Existing OPA bundles run in process as compiled Rego, never as a remote call per decision, and pass the same decision tests as Cedar. Relationship engines such as OpenFGA and SpiceDB plug in through access.
Next
Was this page helpful?