Skip to content

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.

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”
OriginStored inScopeExample
Globalusers.rolesplatform-wide, tenant-independentSystemAdmin, system
Membershiptenant_memberships.rolesone tenant onlyAdmin, 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 in invite-create (assertAssignableMembershipRoles / findForbiddenMembershipRole).

  • Read time (backstop): stripForbiddenMembershipRoles filters 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 legitimate SystemAdmin in globalRoles is 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.

DeclarationScopeWhat it unlocksReason required?When to use
r.systemScope() + ctx.systemDb.assertTenantMatch / assertRowsTenantFeatureA self-check that the acting user’s own tenant matches a row/id — no cross-tenant read, just a fail-closed guardNo (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() featureA system-mode TenantDb — unfiltered reads/writes via the usual ctx.db-shaped methodsYesA 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() featureA raw DbRunner — bypasses the TenantDb wrapper entirelyYesA 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 featureA raw DbRunner for that one declared call siteYesA 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 callA 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) writesWrite handler only, gated by the same escapeHatch: { reason }Writes to a tenancy: "global" table with the tenant filter liftedYesWriting a tenancy: "global" table’s rows (reads through db.global(table) need no escape hatch)
Identity-switch queryAs / writeAsCallRuns the call as a different, resolved SessionUser — cross-tenant only if that user’s own roles allow itNo (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)HandlerOne 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 switchYes — reported as acknowledge-cross-tenantAn operator (e.g. SystemAdmin) write/read on one entity-convention handler that must reach rows in any tenant
ctx.dispatch.queryProjection(qn, { unsafeAllTenants: true })CallLifts the tenant_id filter on that one projection queryNo — 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 }StepReads 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 eventYes — a { reason } object, not a booleanA declarative handler step that must read rows across tenants; without it the step is tenant-filtered like ctx.db
Jobs, extension hooks, MSP applyFramework-providedA DbRunner/TenantDb handed in by construction — there is no ctx.db.raw to reach forN/AFramework-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.

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.

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.

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.raw must 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.

  • 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-confirm is intentionally exempt from the strip: its roles come from the compile-time INITIAL_SIGNUP_ROLES constant, not a membership.
  • Apps never re-implement this. The framework owns role assembly; an app only mounts the auth/tenant features and configures them.