MainSDK
Search⌘K
Interfaces

Identity and access

Permissions

access

Decide who can do what to which record with roles, owners, rules and relations declared in main.yaml, with every decision audited and explainable.

Building for 0.3ships as a stable APIPhase 1 · Core, public betaSpec accessFree 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

access answers one question on every action: may this subject do this action to this resource? Roles and rules from main.yaml decide it in process. The default is deny, and every decision is audited.

At a glance

access:
  driver: cedar            # built in, in process
  models: [rbac, abac]
  fresh-after-write: true
  unreachable: { deny: true, cache: 30s }

Calls

  • access.check({ subject, action, resource, context })
  • access.decide({ subject, action, resource, context })
  • access.require(subject, action, resource)
  • access.explain(decisionId)
  • access.can()
  • access.role().can()
  • access.list()
  • access.relate(team, "editor", folder)
  • access.rule(name, fn)
  • access.who(action, resource)
  • access.attribute(name, { subject, asset })
  • access.attributes.register(name, source)
  • access.checkWith(request, needs)
  • access.setGroups(fn)

Guarantees

Whatever the driver.

  • Deny by default, with no bypass path
  • Denies when a remote engine is unreachable
  • Lists filter at the database, not after fetching rows
  • Every decision written to the audit log with the policy version

Drivers

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

main.yamlCedar (built in) (default)
access:
  driver: cedar

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

Codes

CodeWhere it appearsMeaning
MAIN-ACCESS-0004Checkmainsdk check, before deployRelations need a ReBAC driver (openfga, spicedb, permit) or Cedar entities
MAIN-ACCESS-0011Checkmainsdk check, before deployOpenFGA 1.8.0 to 1.13.1 pinned (CVE-2026-34972, wrongly permissive BatchCheck)
MAIN-ACCESS-0007RuntimeError responses and logsRemote engine unreachable and no cached decision; denied (fail closed)
MAIN-ACCESS-0030RuntimeError responses and logsSee the spec
MAIN-ACCESS-0031RuntimeError responses and logsSee the spec
MAIN-ACCESS-0001RuntimeError responses and logsAccess denied (403)
MAIN-ACCESS-0020RuntimeError responses and logsAn attribute source timed out or is down, so access denied the request

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Token gates through external attributesPhase 20.7web3-ready
Tenant in every subject and resourcePhase 10.3tenancy#access-tenant
access@1 contract in AuthZEN shapePhase 10.3access#contract
In-process Cedar with RBAC and ABACPhase 10.3access#cedar
Lists filtered in the databasePhase 10.3access#listing
OpenFGA driverPhase 10.3access#openfga
SpiceDB driverPhase 10.3access#spicedb
OPA and Cerbos driversPhase 10.3access#opa-cerbos
Fail closed with a short cachePhase 10.3access#fail-closed
Capability gap and engine version checksPhase 10.3access#gap-check
Decision auditPhase 10.3access#audit
mainsdk test accessPhase 10.3access#test-cmd

Quick start

main.yaml
access:
  roles:
    admin: ["*"]
    editor: [orders.read, orders.update]
  owner: [read, update]
app/api/orders/[id]/route.ts
import { access, auth, MainError } from "mainsdk";

// your own order store
declare function loadOrder(id: string): Promise<{ type: string; id: string; owner: string }>;
declare function saveOrder(order: { id: string }, patch: unknown): Promise<unknown>;

export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const session = await auth.require(req);
  const order = await loadOrder(id);   // { type: "order", id, owner: "u_1", ... }

  const decision = await access.check({
    subject: { id: session.user },
    action: "orders.update",
    resource: order,
  });
  if (!decision.allow) throw new MainError("MAIN-ACCESS-0001");   // 403 problem details
  return Response.json(await saveOrder(order, await req.json()));
}

A subject with the editor role gets allow: true. The owner of the order gets allow: true through the owner rule. Anyone else, and any action no rule names, gets allow: false. The decision carries id, decision ("allow" or "deny"), allow, mode, policyVersion and reasons.

When the app has a tenancy section, a subject that names no roles gets the roles it holds in the current tenant. Without one, pass roles: ["editor"] on the subject.

Configuration

Every key lives under access:.

main.yaml
access:
  roles:
    admin: ["*"]                       # every action
    support: [orders.read, orders.refund]
    editor: ["orders.*"]               # every orders action
  owner: [read, update]                # on a resource whose owner is the subject
  when:
    orders.refund: [limit]             # named rules that must also hold
  relations:
    editor: [read, write, update]      # what a relation grants (these are the defaults)
    viewer: [read]
  mode: enforce                        # enforce | warn | observe
KeyTypeWhat it does
rolesmapA role name to the actions it grants. "*" grants every action, "orders.*" every action that starts with orders.
ownerlistActions a subject may take on a resource whose owner equals the subject's id. An entry such as read covers orders.read and files.read
whenmapAn action to the named rules that must also hold. A rule that is not registered denies (fail closed)
relationsmapA relation name to the actions it grants on the resource it is written for. editor and viewer exist by default
modestringenforce (default) denies. warn and observe still record the deny, but allow comes back true so you can roll a policy out

Declare a rule once in code, then name it under when:

lib/permissions.ts
import { access } from "mainsdk";

// the order amount must be within the subject's limit
access.rule("limit", (subject, resource) => Number(resource.amount) <= Number(subject.limit ?? 0));

Pass anything a rule needs in context on the check: time, IP, device. Rules receive (subject, resource, context).

An API key acts only inside its scopes, and a policy can narrow a key but never widen it. See API keys for your customers. See main.yaml for overlays and inheritance from main.org.yaml.

What you get

access answers one question on every action: may this subject do this action to this resource, in this context? You declare roles, owner rules and conditions in main.yaml, and the built-in engine decides in process with no network call. The request shape follows the OpenID AuthZEN Authorization API, so subject, action, resource and context map onto other engines later. The default is deny, forbid wins, and every decision lands in the audit log with the policy version and an id you can explain.

Engines you already run (Cedar policies, OpenFGA, SpiceDB, OPA, Cerbos) are planned for 0.3 (spec access). Today the built-in engine is the one that runs, and your call sites will not change when a driver arrives.

Examples

Check, ask, require and explain

check is the call most code needs. The others give you the same decision in other shapes.

lib/orders.ts
import { access } from "mainsdk";

const user = { id: "u_1", roles: ["editor"] };
const order = { type: "order", id: "o_1", owner: "u_1" };

const d = access.decide({ subject: user, action: "orders.update", resource: order });   // sync, in process
const ok = await access.can(user, "orders.update", order);                              // true or false
access.require(user, "orders.update", order);                                           // throws MAIN-ACCESS-0001 (403)
const why = access.explain(d.id);                                                       // { decision, request } or null

For the refund rule above, a support subject with limit: 10 on a 20 order is denied with these reasons: role support grants orders.refund and rule limit does not hold.

Relationships

A relation says "this team is an editor of this folder". It holds only inside the tenant it was written in. A subject reaches a team relation through its teams list.

app/api/folders/[id]/editors/route.ts
import { access, auth } from "mainsdk";

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth.require(req);
  const folder = { type: "folder", id: (await params).id };
  const { teamId } = (await req.json()) as { teamId: string };

  access.require({ id: session.user }, "folders.share", folder);
  access.relate({ id: teamId }, "editor", folder);

  const canEdit = await access.can({ id: "u_5", teams: [teamId] }, "folders.update", folder);
  return Response.json({ shared: true, canEdit });
}

access.relate(subject, relation, resource) takes a team by default, or { id, type: "user" }. The resource needs an id, or the call fails with a 400 problem.

access.who(action, resource) returns the subjects that may take an action on a resource through ownership or a relation, as type:id names such as user:u_5 or team:t_1. Use it to show who can see a record.

access.role(name).can(action) grants an action to a role at run time, on top of the roles in main.yaml, and chains: access.role("editor").can("orders.update").can("orders.read").

To have the engine find a user's teams for you, register a lookup once with access.setGroups(fn). When a decision names a user subject with no teams list, the engine calls fn(userId) and uses the team IDs it returns. A subject that passes its own teams list is not looked up. Pass undefined to remove the lookup.

lib/access.ts
import { access } from "mainsdk";

declare function teamIdsOf(userId: string): Promise<string[]>;

access.setGroups(async (userId) => teamIdsOf(userId));   // your own lookup

Invite a teammate to an organisation

app/api/orgs/invites/route.ts
import { auth } from "mainsdk";

export async function POST(req: Request) {
  await auth.require(req, { role: "owner" });
  const { email } = (await req.json()) as { email: string };
  // acts on the current tenant; the invite link is a getqr.id link
  return Response.json(await auth.invites.create({ email, roles: ["member"] }));
}

Errors and checks

CodeMeaning
MAIN-ACCESS-0001The action was denied (403). Check the role or relation it needs.
MAIN-ACCESS-0004A relation needs a relationship driver or Cedar entities (check).
MAIN-ACCESS-0007A remote engine was unreachable with no cached decision, so the request is denied.
MAIN-ACCESS-0020An attribute source timed out or is down, so the request is denied.
MAIN-ACCESS-0030access.relate got a relation with no name, or a resource with no id (400).
MAIN-ACCESS-0031access.list was asked for an action that has a named rule; it is refused, never widened (400).

Run npx mainsdk check to validate the access: section. npx mainsdk explain <code> shows the fix.

Swapping drivers

The built-in engine runs in process. For an engine you already run, RemoteAccess puts the same guarantees around a driver for OpenFGA, SpiceDB, OPA or Cerbos (openfga, spicedb, opa, cerbos from mainsdk). Each driver talks to its engine through the fetch the network layer gives it, so the engine's host must be in network.allow.

lib/access.ts
import { RemoteAccess, openfga } from "mainsdk";

export const access = new RemoteAccess({
  engine: openfga({ url: "https://fga.internal", fetch, store: "01H..." }),
  unreachable: { deny: true, cache: 30_000 },   // access.unreachable: { deny: true, cache: 30s }
});

Each driver declares a capability profile: the models it expresses (rbac, abac, rebac), listing, conditions, consistency tokens, batch checks, decision logs, and whether it is local or remote.

DriverModelsListsConsistencyBatch
built-inrbac, abac, rebacyesn/a (in process)yes
openfgaabac (conditions), rebacyesyesyes
spicedbrebacnoyes (ZedTokens)no
oparbac, abacnonono
cerbosrbac, abacnonoyes
  • Fail closed. When the engine cannot be reached, a decision that is not cached is denied with MAIN-ACCESS-0007. With cache: 30s, a decision the engine gave in the last 30 seconds is served, and no older.
  • Fresh after a write. access.relate() on a remote engine writes through the driver, and for five seconds after it the checks ask for higher consistency (OpenFGA HIGHER_CONSISTENCY, a SpiceDB ZedToken), so access.can() right after sees the relation.
  • Lists at the database. access.list(subject, "read", "order") returns a filter, not rows. Pass filter.where to db.orders.public().list({ where }) and the database does the filtering. A role grant is every record; an owner grant is { owner }; relations are { id: { in: [...] } }. When more than one clause applies, filter.where is null and filter.clauses holds the clauses to query one by one. A named rule on the action cannot be pushed into a query and fails with MAIN-ACCESS-0031 instead of widening.
  • Conformance. accessConformance and runAccessConformance in mainsdk/testing run one set of cases against any engine. A case the profile excludes is reported as skipped (profile: models:rebac=false).

Not built yet: Cedar policy files and the Permit.io, Oso and Verified Permissions drivers, access.checkMany on the built-in engine, access.raw, and mainsdk test access.

Testing

createTestApp() reads access from your main.yaml and runs the same engine, so route tests need no engine running. See the testing guide.

test/orders.test.ts
import { it } from "node:test";
import assert from "node:assert/strict";
import { access } from "mainsdk";
import { createTestApp } from "@mainsdk/testing";

it("access AC1 denies an action no rule grants", async () => {
  createTestApp({ yaml: "schema: 1\nproject: prj_test\naccess:\n  roles:\n    editor: [orders.update]\n" });
  const order = { type: "order", id: "o_1" };
  const viewer = await access.check({ subject: { id: "u_1", roles: ["viewer"] }, action: "orders.update", resource: order });
  const editor = await access.check({ subject: { id: "u_2", roles: ["editor"] }, action: "orders.update", resource: order });
  assert.equal(viewer.allow, false);
  assert.equal(editor.allow, true);
});

mainsdk test access, for policy files, is planned with the Cedar driver.

Security and privacy

  • Deny by default, with no bypass path. Every action is checked.
  • A subject and a resource from different tenants are denied before any rule runs, with the reason cross-tenant.
  • Every decision is written to the audit log with decisionId, subject, action, resource, decision and the policy version.
  • An unregistered rule denies. A rule that throws is not swallowed.
  • Decisions run in process, with no network call.

FAQ

Do I have to move off OpenFGA or SpiceDB? No. Those drivers are planned to sit in front of your model and tuples, not replace them.

What is the difference between access and policy? access is the app-facing permissions interface. policy is the layer that decides everything MainSDK decides, including data, network and model rules.

Can I fail open? No. mode: warn and mode: observe exist only to roll out a rule: they still record the deny.

Are access reviews and break-glass access included? Not yet. They come in phase 2.

Next

External attributes

access.attribute(name, { subject, asset }) reads a fact only an outside source knows, such as token.balance. The source is a driver you register with access.attributes.register(name, source). It declares a timeoutMs and a freshnessMs: a value younger than the freshness is reused, and a source that is slow, failing or missing is denied with MAIN-ACCESS-0020. The audit event says the source was down. Core holds no chain code; the driver does.

ts
import { access, type AttributeSource } from "mainsdk";

const user = { id: "u_1" };
const room = { type: "room", id: "vip" };
const evmBalance: AttributeSource = {
  id: "evm-balance",
  timeoutMs: 800,
  freshnessMs: 30_000,
  read: async (_name, query) => (query.asset === "PASS" ? "1" : "0"),   // your own chain read
};

access.attributes.register("token.balance", evmBalance);
access.rule("holder", (_s, _r, ctx) => Number((ctx.attributes as Record<string, unknown>)["token.balance"]) > 0);
const decision = await access.checkWith(
  { subject: user, action: "vip.read", resource: room },
  [{ name: "token.balance", asset: "PASS" }],
);   // allow: false with MAIN-ACCESS-0020 in the reasons when the source is down, in every mode

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet