Skip to content

Auth and permissions

Permissions in Kumiko are layered. The framework asks three different questions on every call, and the answers come from three different places:

QuestionAnswered byWhere it lives
Can this user invoke this handler?Handler-level accessaccess: { roles: [...] } on the handler
Can this user see / write this field?Field-level accessaccess: { read, write } on the field definition
Can this user see this row?Row-level access (ownership)access: { read } on the entity definition

All three default to deny. A handler with no access declaration cannot be called. A field with no access declaration is visible to whoever can invoke the surrounding handler. An entity with no row-level rule lets anyone with handler access see all rows in their tenant. There is no hidden “admin can do everything” — the framework treats unspecified as restricted.

A user’s role list is the union of two sets, merged at login:

  • users.roles — global roles stored on the user record. Sysadmin lives here. These apply across every tenant the user touches.
  • tenant_memberships.roles — per-tenant roles. Admin, Editor, Viewer live here. They apply only inside the tenant they belong to.

When a user logs in, the auth feature reads both, merges them, and signs the combined list into the JWT. Inside any handler, ctx.user.roles is the single union — the handler does not know whether Admin came from the global record or the membership row, and does not need to. When a user switches tenants (a “change current tenant” action), the JWT is reissued with the new membership’s roles merged in.

Two consequences fall out of this:

  • Sysadmin is the only “across all tenants” role you typically need. Everything else is tenant-scoped, and the framework enforces that automatically through the tenant-DB invariant.
  • A user can hold different roles in different tenants. The same person can be Admin in their own organisation and Viewer in a partner’s, and the JWT carries the right set for whichever tenant they are currently operating in.

The simplest gate. Every write or query handler declares which roles can call it:

defineWriteHandler({
name: "create",
schema,
access: { roles: ["Admin", "OnCall"] },
handler: …,
});

There are three useful shapes for access:

  • { roles: [...] } — only listed roles can call.
  • { openToAll: true } — any authenticated user can call. Note this does not include anonymous; it means “no role check, but you must be logged in”.
  • { roles: ["anonymous"] } — explicitly public. Anonymous callers can reach this handler. Tenant scoping still applies — the anonymous resolver has to provide a tenant ID — but no JWT is required.

The deliberate split between openToAll and ["anonymous"] exists to prevent regressions. A migration that turns auth off should not be one character away from a publicly-callable internal endpoint. Anonymous access is its own keyword precisely so that a code review can grep for it.

Some fields are sensitive without the whole row being sensitive. A user’s phone number, an admin-only internal note, a financial figure visible to accounting only — all of these live as field-level rules on the field definition itself:

createTextField({
access: {
read: { roles: ["Admin", "Sysadmin"] },
write: { roles: ["Admin", "User"] },
},
});

The pipeline applies this in two places. On reads, the framework strips fields the caller cannot see — they simply do not appear in the response. On writes, the framework rejects payloads that try to set fields the caller cannot write — the response is field_access_denied with the offending path.

Field-level access reads and writes can differ. The phone-number example above lets any user write their own phone number (self-service settings) but only admins read it (privacy). Read and write are two independent declarations on one field, both of them values that other tools can inspect.

The first two layers limit who can call and what columns they see; row ownership limits which rows of the table they see. It lives on the entity definition:

r.entity("order", {
fields: { … },
access: {
read: {
Driver: { where: { assignedUserId: "$user.id" } },
Manager: { where: { teamId: "$user.teamId" } },
Admin: "all",
},
},
});

A driver listing orders sees only their own. A manager sees their team’s. An admin sees everything. The framework rewrites every list query, every detail read, every search, and every export to apply the rule that matches the caller’s role. A handler that says “list orders” does not know which subset of orders the caller will get back — the framework knows.

The $user.* placeholders bind to fields on the pipeline user: $user.id, $user.tenantId, $user.teamId, $user.roles. Any other binding fails boot validation, because the pipeline user has a fixed shape and a typo would otherwise become a silent allow-all.

A role with handler access but no ownership rule and no "all" marker is a boot error: Role "X" has handler access to "order:list" but no ownership rule — add explicit "all" or a where clause. Silent fall-through is not allowed; you have to commit to one or the other.

Some operations need to act regardless of any user. Migrations, scheduled jobs, hooks that update derived state outside the triggering user’s permissions — all of these run as a SYSTEM_USER:

const SYSTEM_USER: PipelineUser = {
id: 0,
tenantId: 0, // set per call site
roles: ["system"],
};

The "system" role does not exist in any user record; the framework synthesises it. Field access rules and ownership rules typically grant the "system" role full read and write — without it, a hook that sets a derived counter would fail because the triggering user is not allowed to write that field.

When a feature needs to act as the system from inside a handler, it goes through ctx.queryAs(SYSTEM_USER, ...) or ctx.writeAs(SYSTEM_USER, ...). The audit trail records the original user who triggered the chain plus the system identity that performed the privileged operation — both are visible. There is no way to pretend a system call was a user call, and there is no way to launder a user call through a system bypass that hides the original identity.

Permissions are configuration. Like any configuration in Kumiko, they get validated at boot, not at runtime:

  • A handler with no access declaration is rejected.
  • A field-level rule that references a role no user can hold is flagged.
  • An entity ownership rule that references a non-existent field fails.
  • A $user.* binding to a non-existent property fails.
  • A system-scoped feature without an explicit role gate fails.

Misconfigured permissions are a startup error. Production never sees them.

The three-layer model, the dual-source role merge, and the system identity together produce one property: every authorisation decision lives in declarations, not in handler bodies. A reviewer asking “who can do this?” reads the access block on the handler, the access blocks on the fields, and the access block on the entity. They do not have to read the handler body, and they do not have to grep for if (user.role === ...), because there is none.

The cost is paid up front: every new handler thinks about access, every new field with a sensitive value declares it, every new entity decides whether ownership applies. In return, “did we forget to check” stops being a class of bug.

Field-level access on an employee entity: salary is visible to Admin and Accounting but writable only by Admin; internalNotes is Admin-only on both sides:

export const employeeEntity = createEntity({
table: "read_sample_employees",
fields: {
name: createTextField({ required: true }),
email: createTextField({ required: true }),
// Salary: only Admin and Accounting can read, only Admin can write
salary: createNumberField({
access: { read: ["Admin", "Accounting"], write: ["Admin"] },
}),
// Internal notes: only Admin can read and write
internalNotes: createTextField({
access: { read: ["Admin"], write: ["Admin"] },
}),
},
});

Full source: samples/recipes/field-access.

  • Multi-tenancy — how tenant scoping is applied automatically before any role check runs.
  • Lifecycle and hooks — boot-time validation of access rules, role bindings, and $user.* references.