---
title: "Permissions"
description: "Decide who can do what to which record with roles, owners, rules and relations declared in main.yaml, with every decision audited and explainable."
section: "interfaces"
status: "Building for 0.3 · ships as a stable API"
spec: "access"
canonical: "https://mainsdk.dev/docs/interfaces/access"
updated: "2026-10-28"
---

# Permissions

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

Status: Building for 0.3 · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/access · 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

- Status: Building for 0.3 · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/access.md
- Category: Identity and access

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)`

In main.yaml:

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

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

Pick one in main.yaml with `access.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Cedar (built in) | `cedar` | yes |  |
| Open Policy Agent | `opa` |  |  |
| OpenFGA | `openfga` |  |  |
| SpiceDB | `spicedb` |  |  |
| Cerbos | `cerbos` |  |  |
| Permit.io | `permit` |  |  |
| Oso | `oso` |  |  |
| AWS Verified Permissions | `avp` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-ACCESS-0004` | check | Relations need a ReBAC driver (openfga, spicedb, permit) or Cedar entities |
| `MAIN-ACCESS-0011` | check | OpenFGA 1.8.0 to 1.13.1 pinned (CVE-2026-34972, wrongly permissive BatchCheck) |
| `MAIN-ACCESS-0007` | runtime | Remote engine unreachable and no cached decision; denied (fail closed) |
| `MAIN-ACCESS-0030` | runtime |  |
| `MAIN-ACCESS-0031` | runtime |  |
| `MAIN-ACCESS-0001` | runtime | Access denied (403) |
| `MAIN-ACCESS-0020` | runtime | An attribute source timed out or is down, so `access` denied the request |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Token gates through external attributes | 2 | 0.7 | `web3-ready` |
| Tenant in every subject and resource | 1 | 0.3 | `tenancy#access-tenant` |
| access@1 contract in AuthZEN shape | 1 | 0.3 | `access#contract` |
| In-process Cedar with RBAC and ABAC | 1 | 0.3 | `access#cedar` |
| Lists filtered in the database | 1 | 0.3 | `access#listing` |
| OpenFGA driver | 1 | 0.3 | `access#openfga` |
| SpiceDB driver | 1 | 0.3 | `access#spicedb` |
| OPA and Cerbos drivers | 1 | 0.3 | `access#opa-cerbos` |
| Fail closed with a short cache | 1 | 0.3 | `access#fail-closed` |
| Capability gap and engine version checks | 1 | 0.3 | `access#gap-check` |
| Decision audit | 1 | 0.3 | `access#audit` |
| mainsdk test access | 1 | 0.3 | `access#test-cmd` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
access:
  roles:
    admin: ["*"]
    editor: [orders.read, orders.update]
  owner: [read, update]
```

```ts title="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:`.

```yaml title="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
```

| 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`:

```ts title="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](https://mainsdk.dev/docs/interfaces/apikeys). See [main.yaml](https://mainsdk.dev/docs/concepts/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.

```ts title="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.

```ts title="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.

```ts title="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

```ts title="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

| 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`.

```ts title="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.

| 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`. 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](https://mainsdk.dev/docs/guides/testing).

```ts title="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`](https://mainsdk.dev/docs/interfaces/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](https://mainsdk.dev/docs/interfaces/policy): policies in git, explain and staged rollout.
- [Data and records](https://mainsdk.dev/docs/interfaces/db): owner-scoped queries.
- [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth): `auth.require()` with a role.
- [Protect a route with permissions](https://mainsdk.dev/docs/guides/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.

```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
```
