RBAC & Tenant-Isolation: role origins and the membership-role invariant
How a session’s roles are assembled, why platform-global roles must never live in a tenant membership, and why that invariant needs enforcement at two points — write time and read time.
Context
Section titled “Context”Kumiko is multi-tenant and event-sourced. Access control is role-based:
hasAccess(user, rule) (engine/access.ts) checks whether any of
session.roles matches the handler’s AccessRule. The cross-tenant handler
surface (managed-pages, compliance-profiles, template-resolver,
the user/tenant admin screens) is gated on SystemAdmin. The entire isolation
model rests on one assumption: SystemAdmin cannot be obtained
illegitimately.
A privilege-escalation bug broke exactly that assumption. invite-create
accepted an arbitrary role, accept wrote it 1:1 into the membership, and
login/switch-tenant merged membership roles flat into the session — so a
Tenant-Admin could invite SystemAdmin and the invitee gained platform-wide,
cross-tenant access. (Proven end-to-end before the fix.)
Decision 1 — roles have two origins; hasAccess is origin-blind
Section titled “Decision 1 — roles have two origins; hasAccess is origin-blind”| Origin | Stored in | Scope | Example |
|---|---|---|---|
| Global | users.roles | platform-wide, tenant-independent | SystemAdmin, system |
| Membership | tenant_memberships.roles | one tenant only | Admin, Editor, User |
At login the session is globalRoles ∪ membershipRoles(activeTenant); at
switch-tenant the membership portion is recomputed for the target tenant while
global roles persist. hasAccess checks the merged set flat — it has no
notion of where a role came from. That flatness is intentional (it keeps
authorization a single set-membership test), but it means the only thing
keeping a tenant role from acting platform-wide is that platform roles never
appear in a membership.
Decision 1a — active membership is resolved before roles are assembled
Section titled “Decision 1a — active membership is resolved before roles are assembled”Login, MFA completion and switch-tenant resolve the target membership through
dispatcher.resolveActiveMembership before any role merge runs: is the user a
member of the tenant, is the principal not blocked (principalStatus
contract, fulfilled by user), and is the tenant not in teardown
(tenantLifecycleStatus contract, fulfilled by tenant-lifecycle; a tenant in
destroyRequested is still allowed so its owner can cancel destruction). Only
then does buildSessionRoles merge global + membership roles and
resolveAuthClaims mint the session.
ctx.dispatch.queryAsMember(userId, qn, payload) reuses this same resolution for
background reads (a hook/job reading as a stored member instead of SYSTEM),
but under a stricter, non-interactive policy: no destroyRequested
cancel-window grace, no unknown-principal pass. The resolved SessionUser
carries origin: "member-resolution", no sid, and is read-only — writes
through it fail closed (member_resolution_read_only), and it is never
signed into a JWT.
The resolution is cached per user for the lifetime of the reader: one handler
invocation (shared with its hooks) or one job run. A membership revoked or a
principal set to Restricted while a long-running job loops over
ctx.dispatch.queryAsMember is not noticed until the next run.
Decision 2 — reserved roles are global-only (the invariant)
Section titled “Decision 2 — reserved roles are global-only (the invariant)”{ system, SystemAdmin, all, anonymous } (derived from the engine access
presets: access.privileged ∪ access.all ∪ access.anonymous) are reserved
and must never appear in tenant_memberships.roles. Bootstrap already honoured
this (seedAdmin writes SystemAdmin to global users.roles, never a
membership); the invariant makes every other path consistent. The canonical set
lives in engine/membership-roles.ts (FORBIDDEN_MEMBERSHIP_ROLES).
Decision 3 — enforce at write time AND read time
Section titled “Decision 3 — enforce at write time AND read time”Command-time validation alone is not rebuild-safe, and that is the load-bearing insight of this document.
-
Write time (prevention): every membership-role write chokepoint rejects reserved roles —
seedTenantMembership(covers the three invite-accept branches plus seeding),add-member,update-member-roles, and early ininvite-create(assertAssignableMembershipRoles/findForbiddenMembershipRole). -
Read time (backstop):
stripForbiddenMembershipRolesfilters the membership portion at every JWT mint that derives roles from a membership — login, switch-tenant, invite-accept-with-login, invite-signup-complete. It wraps only the membership array, never the merged result, so a legitimateSystemAdmininglobalRolesis never stripped.
Why both: in an event-sourced system a projection rebuild replays stored
tenant-membership.created events through the apply path, not the handler.
A command-time validator never runs during replay. So a membership row that was
forbidden when it was first written (e.g. a pre-fix exploited event) would be
resurrected into read_tenant_memberships by a rebuild — a row migration does
not help, because the event itself remains. The read-time strip neutralises such
a resurrected role on the way into the session, without any projection surgery.
General principle: a security invariant on an event-sourced projection needs a read-time enforcement point. Command-time validation protects the write, not the replay. Treat every projection as potentially holding a pre-invariant value until a read-time check says otherwise.
Decision 4 — cross-tenant overrides go through one chokepoint
Section titled “Decision 4 — cross-tenant overrides go through one chokepoint”A payload tenantIdOverride (the deliberate cross-tenant escape hatch for
SystemAdmin tooling) is refused by the dispatcher for every caller without the
SystemAdmin role, on writes, queries and streams alike, before the handler
runs (tenantOverrideDenied in engine/cross-tenant.ts, reason
tenant_override_requires_system_admin). A handler therefore never checks the
role itself and cannot forget to.
Write-isolation itself is already strong: the executor derives tenantId from
the session, not the payload — so even with a forged role, writes stay scoped
unless the handler explicitly opts into an override.
The tenantIdOverride chokepoint above governs handlers on a tenant-scoped
feature. A feature that declares r.systemScope() instead loses the
automatic tenant filter entirely; ctx.systemDb
is that pattern’s equivalent self-check requirement.
Decision 5 — one declaration per cross-tenant path
Section titled “Decision 5 — one declaration per cross-tenant path”TenantDb.raw existed as a silent, unfiltered escape hatch off ctx.db until
fw#2860 removed it. Every cross-tenant read/write now goes through
exactly one of the declarations below; framework infrastructure (the
event-store executor, engine steps, jobs, MSP apply) gets its connection
injected by the dispatcher, or resolves it through a framework-private
binding that feature code cannot import.
| Declaration | Scope | What it unlocks | Reason required? | When to use |
|---|---|---|---|---|
r.systemScope() + ctx.systemDb.assertTenantMatch / assertRowsTenant | Feature | A self-check that the acting user’s own tenant matches a row/id — no cross-tenant read, just a fail-closed guard | No (a self-check has nothing to justify) | A r.systemScope() handler that still needs to prove it isn’t drifting outside the acting tenant |
ctx.systemDb.acknowledgeCrossTenant(reason) | Handler/Hook, only inside an r.systemScope() feature | A system-mode TenantDb — unfiltered reads/writes via the usual ctx.db-shaped methods | Yes | A r.systemScope() handler/hook that needs the typed TenantDb surface (selectMany/insertOne/…) across tenants |
ctx.systemDb.unsafeRaw(reason) | Handler/Hook, only inside an r.systemScope() feature | A raw DbRunner — bypasses the TenantDb wrapper entirely | Yes | A r.systemScope() handler/hook that needs a raw-SQL helper (countWhere, transaction, …), which no longer accepts a TenantDb |
escapeHatch: { reason } on the handler/hook → ctx.db.unsafeRaw() | Handler/Hook, tenant-scoped feature | A raw DbRunner for that one declared call site | Yes | A tenant-scoped handler/hook with one specific cross-tenant read/write, without lifting the whole feature to r.systemScope() |
ctx.crossTenantReads (selectMany / fetchOne / count) | Handler or job whose escapeHatch grants crossTenantReads (or unsafeRaw, which implies it); undefined otherwise, for member-resolution reads and inside an agent tool call (dispatchToolCall, nested dispatches included) | Read-only, tenant-unfiltered reads on any table, no writes and no raw runner; grants: ["crossTenantReads"] does not unlock ctx.db.unsafeRaw() | Yes — reported as cross-tenant-read, audited once per report window per handler (system crons: once per process and day), while the metric counts each call | A handler or job that only has to look at other tenants’ rows (e.g. a cross-tenant overview or “does any tenant have work”) without taking the raw runner |
db.global(table) writes | Write handler only, gated by the same escapeHatch: { reason } | Writes to a tenancy: "global" table with the tenant filter lifted | Yes | Writing a tenancy: "global" table’s rows (reads through db.global(table) need no escape hatch) |
Identity-switch queryAs / writeAs | Call | Runs the call as a different, resolved SessionUser — cross-tenant only if that user’s own roles allow it | No (gated by the target user’s own access, not a reason) | Acting on behalf of a specific other user rather than lifting the tenant filter itself |
escapeHatch: { reason } on the entity-convention handlers (defineEntity*Handler / r.crud write/read) | Handler | One convention handler gets a system-mode TenantDb; write verbs address the target row’s own tenant stream; does NOT grant unsafeRaw / db.global writes / identity switch | Yes — reported as acknowledge-cross-tenant | An operator (e.g. SystemAdmin) write/read on one entity-convention handler that must reach rows in any tenant |
ctx.dispatch.queryProjection(qn, { unsafeAllTenants: true }) | Call | Lifts the tenant_id filter on that one projection query | No — today ungated beyond the caller’s own access (tracked as a follow-up issue) | A query handler that must aggregate a projection across every tenant |
r.step.read.findOne / r.step.read.findMany with unsafeAllTenants: { reason } | Step | Reads through ctx.systemDb.unsafeRaw(reason) in an r.systemScope() feature, otherwise ctx.db.unsafeRaw(), which needs the handler’s own escapeHatch: { reason }. Reported as an "unsafe-raw" audit event | Yes — a { reason } object, not a boolean | A declarative handler step that must read rows across tenants; without it the step is tenant-filtered like ctx.db |
Jobs, extension hooks, MSP apply | Framework-provided | A DbRunner/TenantDb handed in by construction — there is no ctx.db.raw to reach for | N/A | Framework-internal call sites only; feature code never resolves this itself |
ctx.systemDb itself exists whenever the handler declares r.systemScope().
That is the grant for the handler’s own unsafeRaw/acknowledgeCrossTenant
calls; no separate reason check applies to the handler. Inside a hook
(e.g. r.hook({ type: "postSave", target: { allOf }, handler, escapeHatch })), the same
ctx.systemDb.unsafeRaw(reason), including on the TenantDb that
acknowledgeCrossTenant hands back, is gated by the hook’s own
escapeHatch: { reason }, independent of whether the enclosing handler
declared one. A hook without its own escapeHatch gets an access_denied
from unsafeRaw, even though ctx.systemDb is present and
acknowledgeCrossTenant’s typed CRUD surface still works unconditionally.
escapeHatch: { reason } on an entity-convention write handler (defineEntityWriteHandler
and the create/update/delete/restore factories) requires SystemAdmin-only access
({ roles: ["SystemAdmin"] }); a looser rule throws at definition time. List and detail
handlers keep access as the only gate. The former crossTenant: true option is removed;
consumers migrate with
bun node_modules/@cosmicdrift/kumiko-feature-ast/src/scripts/codemod/migrate-cross-tenant.ts
(from a consumer repo), which rewrites the call shapes it can derive a handler name and verb
from and lists the rest for manual review. The codemod’s reason contains a placeholder that
must be replaced with the real operator use case; a reason that still carries it is rejected.
Postgres RLS
Section titled “Postgres RLS”Since 0.390.0, row level security is on by default as a second layer under the TypeScript boundary above. The earlier decision against it is superseded by the decision of 2026-10-23 in decisions. Each declaration in Decision 5 maps to a Postgres scope: ctx.db runs in the tenant scope of the call, and ctx.crossTenantReads, a granted unsafeRaw and ctx.systemDb run in the system scope with their reason. Scope per transaction, the 42501 error and the boot checks are described in security defaults.
Job status for app roles
Section titled “Job status for app roles”The jobs feature answers jobs:query:failures and jobs:query:tenant-runs for User, Editor and the admin roles. An app whose members carry their own roles, for example a property manager who should see why the nightly import failed, adds them with readRoles:
createJobsFeature({ readRoles: ["PropertyManager"] });Both queries stay limited to the caller’s own tenant. readRoles must not contain "anonymous"; the feature throws at definition time, because job failures can carry tenant data.
Enforcement
Section titled “Enforcement”packages/guards/src/guard-tenant-escalation.ts (part of PUBLIC_GUARDS; scans source and
tests in all Kumiko repos):
- A — every role-input write handler must have a test asserting a reserved role is rejected.
- B — every membership-derived JWT mint must call
stripForbiddenMembershipRoles. - C — a TenantAdmin-reachable write that reads a global user row via
ctx.db.rawmust check the target’s membership in the caller’s tenant.
The payload tenantIdOverride has no guard check: the dispatcher rejects it centrally for
non-SystemAdmin callers (tenantOverrideDenied, see above), so handlers cannot skip it.
Consequences
Section titled “Consequences”- A new JWT-mint path that reads membership roles must strip them; the guard catches the common shapes, but the rule is the contract.
signup-confirmis intentionally exempt from the strip: its roles come from the compile-timeINITIAL_SIGNUP_ROLESconstant, not a membership.- Apps never re-implement this. The framework owns role assembly; an app only mounts the auth/tenant features and configures them.