Skip to content

Auth and permissions

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.

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

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.

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

  • users.roles, global roles stored on the user record. SystemAdmin 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, 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:

  • SystemAdmin is 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 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: { reason: "..." } }, any authenticated user can call. Note this does not include anonymous; it means “no role check, but you must be logged in”. The reason is 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.

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.

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.

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.

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 before jwt.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.

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

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:

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.

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