Permissions
accessDecide who can do what to which record with roles, owners, rules and relations declared in main.yaml, with every decision audited and explainable.
accessFree 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.
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 }import { access } from "mainsdk";
const decision = await access.check({
subject: user,
action: "orders.update",
resource: order,
});
if (!decision.allow) return new Response("Forbidden", { status: 403 });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);
});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)
Drivers
8 drivers. Select one to see its line in main.yaml.
access:
driver: cedarChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-ACCESS-0004 | Checkmainsdk check, before deploy | Relations need a ReBAC driver (openfga, spicedb, permit) or Cedar entities |
MAIN-ACCESS-0011 | Checkmainsdk check, before deploy | OpenFGA 1.8.0 to 1.13.1 pinned (CVE-2026-34972, wrongly permissive BatchCheck) |
MAIN-ACCESS-0007 | RuntimeError responses and logs | Remote engine unreachable and no cached decision; denied (fail closed) |
MAIN-ACCESS-0030 | RuntimeError responses and logs | See the spec |
MAIN-ACCESS-0031 | RuntimeError responses and logs | See the spec |
MAIN-ACCESS-0001 | RuntimeError responses and logs | Access denied (403) |
MAIN-ACCESS-0020 | RuntimeError responses and logs | An 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Token gates through external attributes | Phase 2 | 0.7 | web3-ready |
| Tenant in every subject and resource | Phase 1 | 0.3 | tenancy#access-tenant |
| access@1 contract in AuthZEN shape | Phase 1 | 0.3 | access#contract |
| In-process Cedar with RBAC and ABAC | Phase 1 | 0.3 | access#cedar |
| Lists filtered in the database | Phase 1 | 0.3 | access#listing |
| OpenFGA driver | Phase 1 | 0.3 | access#openfga |
| SpiceDB driver | Phase 1 | 0.3 | access#spicedb |
| OPA and Cerbos drivers | Phase 1 | 0.3 | access#opa-cerbos |
| Fail closed with a short cache | Phase 1 | 0.3 | access#fail-closed |
| Capability gap and engine version checks | Phase 1 | 0.3 | access#gap-check |
| Decision audit | Phase 1 | 0.3 | access#audit |
| mainsdk test access | Phase 1 | 0.3 | access#test-cmd |
Quick start
access:
roles:
admin: ["*"]
editor: [orders.read, orders.update]
owner: [read, update]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:.
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| Key | Type | What it does |
|---|---|---|
roles | map | A role name to the actions it grants. "*" grants every action, "orders.*" every action that starts with orders. |
owner | list | Actions 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 |
when | map | An action to the named rules that must also hold. A rule that is not registered denies (fail closed) |
relations | map | A relation name to the actions it grants on the resource it is written for. editor and viewer exist by default |
mode | string | enforce (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:
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.
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 nullFor 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.
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.
import { access } from "mainsdk";
declare function teamIdsOf(userId: string): Promise<string[]>;
access.setGroups(async (userId) => teamIdsOf(userId)); // your own lookupInvite a teammate to an organisation
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
| Code | Meaning |
|---|---|
MAIN-ACCESS-0001 | The action was denied (403). Check the role or relation it needs. |
MAIN-ACCESS-0004 | A relation needs a relationship driver or Cedar entities (check). |
MAIN-ACCESS-0007 | A remote engine was unreachable with no cached decision, so the request is denied. |
MAIN-ACCESS-0020 | An attribute source timed out or is down, so the request is denied. |
MAIN-ACCESS-0030 | access.relate got a relation with no name, or a resource with no id (400). |
MAIN-ACCESS-0031 | access.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.
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.
| Driver | Models | Lists | Consistency | Batch |
|---|---|---|---|---|
| built-in | rbac, abac, rebac | yes | n/a (in process) | yes |
openfga | abac (conditions), rebac | yes | yes | yes |
spicedb | rebac | no | yes (ZedTokens) | no |
opa | rbac, abac | no | no | no |
cerbos | rbac, abac | no | no | yes |
- Fail closed. When the engine cannot be reached, a decision that is not cached is denied with
MAIN-ACCESS-0007. Withcache: 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 (OpenFGAHIGHER_CONSISTENCY, a SpiceDB ZedToken), soaccess.can()right after sees the relation. - Lists at the database.
access.list(subject, "read", "order")returns a filter, not rows. Passfilter.wheretodb.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.whereisnullandfilter.clausesholds the clauses to query one by one. A named rule on the action cannot be pushed into a query and fails withMAIN-ACCESS-0031instead of widening. - Conformance.
accessConformanceandrunAccessConformanceinmainsdk/testingrun one set of cases against any engine. A case the profile excludes is reported asskipped (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.
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
- Policy decisions: policies in git, explain and staged rollout.
- Data and records: owner-scoped queries.
- Sign-in and sessions:
auth.require()with a role. - Protect a route with permissions
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.
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 modeWas this page helpful?