MainSDK
Search⌘K
Interfaces

Identity and access

Policy decisions

policy

Decide every sensitive call in process, explain which rule decided, and later load policies from git and roll changes out in shadow first.

Building for 0.2ships as a stable APIPhase 1 · Core, public betaSpec policyFree in your app, Apache-2.0

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.
Stability levels
Updated 2026-10-28

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 policies

Calls

  • policy.decide()
  • policy.explain()
  • policy.test()
  • policy.rollout()
  • policy.decideRemote(input)

Guarantees

Whatever the driver.

  • Deny when no policy permits
  • In process, no network round trip per decision
  • Lower layers only tighten, never loosen
  • Keeps the last good policy set when a new one fails to load

Drivers

2 drivers. Select one to see its line in main.yaml.

main.yamlCedar (built in (default)
policy:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-POLICY-0004Checkmainsdk check, before deployA repo policy weakens an org policy; loading fails, naming both files
MAIN-POLICY-0001RuntimeError responses and logsSee the spec
MAIN-POLICY-0002RuntimeError responses and logsSee the spec
MAIN-POLICY-0003RuntimeError responses and logsSee the spec
MAIN-POLICY-0005RuntimeError responses and logsSee the spec
MAIN-POLICY-0006RuntimeError responses and logsSee the spec
MAIN-POLICY-0009RuntimeError responses and logsA new policy set failed to load; the previous version stays in force
MAIN-POLICY-0011RuntimeError responses and logsA 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-0012RuntimeError responses and logsSee the spec
MAIN-PLAY-0001RuntimeError responses and logsThe 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-0002RuntimeError responses and logsThe replay holds more recorded decisions than the playground replays at once (413). Fix: replay a smaller sample of at most 100000 decisions
MAIN-PLAY-0003RuntimeError responses and logsThe 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-0004RuntimeError responses and logsThe 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-0005RuntimeError responses and logsmainsdk 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-0007RuntimeError responses and logsmainsdk 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.

FeaturePhaseMilestoneSpec ticket
Deny-by-default decisionsPhase 10.2policy#decide
Policies folder with a content-hashed versionPhase 10.2policy#policies-folder
Observe, warn and enforce modesPhase 10.2policy#modes
Org, repo, overlay and Fleet layersPhase 10.2policy#layering
Explain a decisionPhase 10.2policy#explain
Policy tests and recorded replayPhase 10.2policy#test
Staged rollout with automatic rollbackPhase 10.2policy#rollout
OPA driver for RegoPhase 10.2policy#opa-driver
Last good policy setPhase 10.2policy#fail-static
Server, edge or browser placementPhase 10.2policy#placement
Policy playgroundPhase 20.7policy-playground

MainSDK Cloud

Everything above is free in your app. Cloud adds these on top, by plan:

PlanWhat it adds
TeamsPolicy shared across the organisation and rolled out to the team's projects
FleetCentral policy pushed to every app in the estate, signed with keys the tenant holds

Quick start

main.yaml
access:
  mode: enforce        # enforce | warn | observe
  roles:
    admin: ["*"]
    analyst: [reports.read]
app/api/reports/[id]/export/route.ts
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.

main.yaml
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
KeyWhat it does
rolesRole name to the actions it grants. "*" grants every action, "reports.*" every reports action
ownerActions a subject may take on a resource whose owner is their id
whenAction to named rules that must also hold. A rule that is not registered fails closed
modeenforce 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.

app/api/decisions/[id]/route.ts
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.

server/transfers.ts
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() and mainsdk test policy to 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.

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");

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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet