Auth and permissions
In plain words
Section titled “In plain words”Kumiko checks access at three levels: whether a caller may invoke a handler, whether they may read or write a field, and whether they may see a row. Handlers default to deny until an access rule grants a call. Field and row rules narrow what an allowed handler may read or change. Roles can be global or scoped to the current tenant.
Browser sessions and personal access tokens both identify a user. Personal access tokens add scopes and revocation for scripts and external tools. Background jobs use an internal identity and may also carry the triggering user for audit. Kumiko validates access declarations at boot.
Why it exists
Section titled “Why it exists”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 |
Handlers are default-deny: a handler with no access declaration cannot be called. Field and entity rules are optional restrictions on top of handler access. A field without a rule is visible and writable to callers who can use the handler. An entity without a row rule exposes all rows in the current tenant to those callers. There is no hidden “admin can do everything” bypass.
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.SystemAdminlives 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, strips reserved roles from
the membership set, merges the result, and signs it into the JWT. Inside a
handler, ctx.user.roles is the combined list. When the user switches tenant,
the JWT is reissued with the new membership roles.
Two consequences fall out of this:
SystemAdminis the platform-wide operator role. 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: { reason: "..." } }, any authenticated user can call. Note this does not include anonymous; it means “no role check, but you must be logged in”. Thereasonis required and names why the handler needs no role check.{ 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({ personal: "self", find: "none", access: { read: ["Admin", "SystemAdmin"], write: ["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 lets
a User write that field on rows the entity ownership rule already permits,
while only admins can read it. Read and write are independent declarations on
one field.
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:
const orderEntity = createEntity({ fields: { assignedUserId: createTextField({ personal: "ref" }), teamId: createTextField({ personal: false, reason: "technical_reference" }), }, access: { read: { Driver: from("user:id", "assignedUserId"), TeamMember: from("claim:teams:teamId", "teamId"), Admin: "all", }, },});A driver listing orders sees only their own. A team member 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.
from("user:id", "assignedUserId") compares the row column with the current
user ID. Claim rules use qualified claim keys declared through r.claimKey.
Boot validation rejects unknown claims and columns.
A role with handler access but no matching ownership rule sees no rows once an
entity declares an ownership map. Use an explicit "all" rule for roles that
may see every row in the tenant. Boot validation checks role names, claim keys,
and referenced columns so a typo does not become a silent grant.
The system identity
Section titled “The system identity”Some operations need an internal actor. Jobs and privileged handlers can use a system identity scoped to a tenant:
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
const systemUser = createSystemUser(tenantId);The "system" role does not exist in any user record. The framework creates
this identity with a reserved UUID and the current tenant. The role has no
automatic bypass; target handlers must explicitly allow system or another
extra role passed to createSystemUser.
When a feature needs to act as the system from inside a handler, it uses
ctx.dispatch.queryAs(systemUser, ...) or ctx.dispatch.writeAs(systemUser, ...). A switch to
the system identity is denied unless the feature uses r.systemScope() or the
handler or hook declares an escapeHatch with a reason. The audit trail keeps
the initiating user and the system actor.
Token sources: JWT and PAT
Section titled “Token sources: JWT and PAT”Everything above is about what a caller may do once identified. A separate question is how they prove who they are. Kumiko’s HTTP API accepts two credential shapes on the same routes:
- JWT, the interactive credential. Issued at login, carries the merged
role list, and is sent as a cookie or
Authorization: Bearer <jwt>. - Personal Access Token (PAT), the headless credential. A long-lived,
revocable
kpat_…bearer token for CI, scripts, and integrations. The auth middleware routes on the token prefix, resolving a PAT beforejwt.verify, and reads the owner’s roles live from the DB on every request (so a revoke or role change is effective immediately).
A PAT is additionally bounded by scopes, a granted subset of handler
QNs. A PAT request whose dispatch type falls outside its scopes is rejected
fail-closed with access_denied, before the handler runs; a JWT request is
never scope-limited. Roles still apply to both. See the
Personal Access Tokens guide and the
feature reference.
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.
- An ownership rule with an unknown user field or claim fails.
- A handler without a valid access declaration fails, including handlers in a system-scoped feature.
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:
access: { read: ["Admin", "Accounting"], write: ["Admin"] }, }), internalNotes: createTextField({ personal: { of: "id" }, find: "none", access: { read: ["Admin"], write: ["Admin"] }, }), },});
const allRoles = { access: { roles: ["Admin", "Accounting", "Employee"] } } as const;
export const employeeFeature = defineFeature("hr", (r) => { r.crud("employee", employeeEntity, { write: allRoles,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, claims, and ownership columns.