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:
| Question | Answered by | Where it lives |
|---|---|---|
| Can this user invoke this handler? | Handler-level access | access: { roles: [...] } on the handler |
| Can this user see / write this field? | Field-level access | access: { 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.
Roles come from two places
Section titled “Roles come from two places”A user’s role list is the union of two sets, merged at login:
users.roles— global roles stored on the user record.Sysadminlives here. These apply across every tenant the user touches.tenant_memberships.roles— per-tenant roles.Admin,Editor,Viewerlive 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:
Sysadminis 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
Adminin their own organisation andViewerin a partner’s, and the JWT carries the right set for whichever tenant they are currently operating in.
Handler-level access
Section titled “Handler-level access”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.
Field-level access
Section titled “Field-level access”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.
Row-level access (ownership)
Section titled “Row-level access (ownership)”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.
The system identity
Section titled “The system identity”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.
Boot-time validation
Section titled “Boot-time validation”Permissions are configuration. Like any configuration in Kumiko, they get validated at boot, not at runtime:
- A handler with no
accessdeclaration 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.
What this gives you
Section titled “What this gives you”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.
Live example
Section titled “Live example”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.
See also
Section titled “See also”- 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.