|
| 1 | +# ADR-0095: Authorization Kernel Chain — Tenant Isolation as Layer 0, a Monotonic Posture Ladder, Capability-Derived Posture |
| 2 | + |
| 3 | +**Status**: Proposed (2026-07-14) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0002](./0002-environment-database-isolation.md) (environment-per-database), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (scope depth), [ADR-0066](./0066-unified-authorization-model.md) (unified authz model, superuser bypass), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (permission model v2), [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (tenancy service) |
| 6 | +**Scopes**: the "cross-tenant RLS does not exist here" claim in [ADR-0073](./0073-automation-execution-identity.md) (see Context) |
| 7 | +**Tracking**: framework#2920 (tasks B0–B4) |
| 8 | +**Consumers**: `@objectstack/plugin-security`, `@objectstack/core` (`resolve-authz-context`), `@objectstack/plugin-sharing`, dogfood conformance suite |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## TL;DR |
| 13 | + |
| 14 | +Three decisions restructure the authorization kernel's hot path. The chain is |
| 15 | +**behavior-preserving by contract** — every step lands behind a |
| 16 | +`role × object × expected-visible-rows` matrix snapshot gate, and any delta the |
| 17 | +gate exposes is a bug to chase, not a feature — with one deliberate exception |
| 18 | +called out in D1 (resolving a spec-vs-implementation divergence found while |
| 19 | +drafting this ADR). |
| 20 | + |
| 21 | +1. **D1 — Tenant isolation becomes Layer 0.** The `org_id` tenant filter moves |
| 22 | + out of the business-RLS compile pass into an independent, always-first, |
| 23 | + AND-composed layer with its own code path. A business-RLS bug can no longer |
| 24 | + weaken tenant isolation, because the two no longer share a compiler, a merge |
| 25 | + step, or a bypass bit. |
| 26 | +2. **D2 — A monotonic posture ladder.** Principal tiering becomes an explicit |
| 27 | + enum `PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > EXTERNAL`, resolved once in |
| 28 | + `resolveAuthzContext`. Each rung maps to **exactly one** row-visibility |
| 29 | + injection rule; visibility is strictly nested down the ladder. The |
| 30 | + `EXTERNAL` rung's semantics are defined and test-locked now (explicit shares |
| 31 | + only, never OWD); its enforcement path ships when an external principal type |
| 32 | + exists. |
| 33 | +3. **D3 — Posture derives from capabilities, never from roles.** The |
| 34 | + platform-admin tier derives from held capabilities |
| 35 | + (`viewAllRecords`/`modifyAllRecords`), and better-auth `role='admin'` is |
| 36 | + demoted to *one source that grants* the platform-admin position — never a |
| 37 | + parallel adjudication track (the #2836 class of composition conflict). |
| 38 | + |
| 39 | +**Rejected**: a Postgres-native RLS backstop for Layer 0 (`SET LOCAL` GUC + |
| 40 | +`CREATE POLICY`). Recorded in Alternatives with the rationale; not scheduled. |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## Context |
| 45 | + |
| 46 | +### Where tenant isolation actually lives today |
| 47 | + |
| 48 | +Two deployment topologies coexist, and the honest statement of this ADR's |
| 49 | +scope depends on distinguishing them: |
| 50 | + |
| 51 | +- **Physically isolated (cloud, default)** — one database per environment |
| 52 | + (ADR-0002); the environment is implicit in the connection. ADR-0073's claim |
| 53 | + that "the hard problem (cross-tenant RLS) does not exist in this |
| 54 | + architecture" is true **for this topology** and remains true. |
| 55 | +- **Shared-DB multi-org (`tenancy.mode = 'multi'`)** — a self-hosted EE |
| 56 | + deployment with `@objectstack/organizations` active (ADR-0093): multiple |
| 57 | + organizations share one database, isolated by `organization_id` stamping and |
| 58 | + in-DB row filtering. **This is the load-bearing case for Layer 0.** In |
| 59 | + `single` mode the tenant policies are stripped and this ADR's Layer 0 is a |
| 60 | + no-op by construction — behavior unchanged. |
| 61 | + |
| 62 | +In the multi-org topology, tenant isolation today is smeared across two |
| 63 | +half-seams, neither of which is a single always-first invariant: |
| 64 | + |
| 65 | +1. **A permission-set RLS policy.** `tenant_isolation` is a wildcard |
| 66 | + (`object: '*'`) row policy carried by the seeded permission sets |
| 67 | + (`plugin-security/src/objects/default-permission-sets.ts`), compiled by the |
| 68 | + same `RLSCompiler.compileFilter` pass as every business RLS policy and |
| 69 | + merged with them. |
| 70 | +2. **A driver-level tenant scope.** The SQL driver applies `applyTenantScope` |
| 71 | + keyed off `options.tenantId` when callers pass it — an independent seam, |
| 72 | + but one that depends on every call site remembering to thread the tenant id. |
| 73 | + |
| 74 | +### The structural weaknesses this ADR closes |
| 75 | + |
| 76 | +**W1 — The merge step can widen tenant scope (spec-vs-impl divergence).** |
| 77 | +ADR-0066's precedence rule documents "RLS: OR within an object, **AND with |
| 78 | +tenant-global**". The implementation does not do this: `compileFilter` |
| 79 | +OR-combines **all** applicable policies — the wildcard `tenant_isolation` |
| 80 | +policy is just one disjunct (`rls-compiler.ts`, "Multiple policies: |
| 81 | +OR-combine"). A permissive business policy (e.g. an admin-authored |
| 82 | +`status == 'public'` row rule) is therefore, at the RLS layer, sufficient by |
| 83 | +itself to admit rows from **other organizations**; whether a given deployment |
| 84 | +is actually exposed then depends on the driver half-seam catching it. A |
| 85 | +security invariant must not depend on which of two loosely-coupled seams |
| 86 | +happens to fire. |
| 87 | + |
| 88 | +**W2 — One bypass bit short-circuits both layers.** The posture-gated |
| 89 | +superuser bypass (`security-plugin.ts` `computeRlsFilter`, ADR-0066 ①) skips |
| 90 | +*all* wildcard RLS — business scoping **and** tenant isolation — with a single |
| 91 | +check. The gate is correct today, but structurally the strongest boundary in |
| 92 | +the system (the tenant wall) and the weakest (a business row rule) hang off |
| 93 | +the same short-circuit. |
| 94 | + |
| 95 | +**W3 — Principal tiering is implicit.** "Who is a platform admin / org admin / |
| 96 | +member" is derived in scattered places: unscoped `admin_full_access` grants |
| 97 | +(`resolve-authz-context.ts`), better-auth `role='admin'` normalized to |
| 98 | +`org_admin` via `mapMembershipRole`, capability bits consulted in |
| 99 | +`permission-evaluator.ts`. There is no enum, no single derivation point, and |
| 100 | +"short-circuit order = security boundary" exists only as code layout. The |
| 101 | +#2836 incident (a platform admin who is also an org owner loses `sys_user` |
| 102 | +edit because an explicit per-object deny out-composes a wildcard allow) is |
| 103 | +what dual-track adjudication produces. |
| 104 | + |
| 105 | +**W4 — No external tier exists, even as a reserved concept.** The sharing |
| 106 | +chain (`plugin-sharing/sharing-service.ts`) has no external/portal principal |
| 107 | +concept at all. When one arrives, bolting it on without a reserved rung would |
| 108 | +repeat W3. |
| 109 | + |
| 110 | +## Decisions |
| 111 | + |
| 112 | +### D1 — Tenant isolation is Layer 0: its own code path, always first, AND-composed |
| 113 | + |
| 114 | +`plugin-security` gains a dedicated tenant-scope module (working name |
| 115 | +`tenant-layer.ts`) that computes the Layer 0 filter **outside** the RLS |
| 116 | +compiler: |
| 117 | + |
| 118 | +- **Input**: the ADR-0093 `tenancy` service (`mode`, `isolationActive`) and |
| 119 | + the resolved authz context's `organization_id`. |
| 120 | +- **Rule**: on a tenant-scoped object (has `organization_id`, tenancy not |
| 121 | + disabled) in `multi` mode with isolation active, the filter |
| 122 | + `organization_id == ctx.organization_id` is **AND-composed unconditionally** |
| 123 | + onto every read and write — before, and independently of, whatever Layer 1 |
| 124 | + (business RLS) contributes. In `single` mode Layer 0 contributes nothing |
| 125 | + (parity with today's policy stripping). |
| 126 | +- **Fail-closed**: a missing `organization_id` on the context in `multi` mode |
| 127 | + denies (the same class of answer as today's `RLS_DENY_FILTER` sentinel). |
| 128 | +- **Exemption** is a Layer 0 rule, not an RLS-compiler short-circuit: only a |
| 129 | + `PLATFORM_ADMIN`-posture caller (D2/D3) on an object whose **posture |
| 130 | + permits it** (platform-global `tenancy.enabled:false`, `private`, or |
| 131 | + better-auth-managed — exactly the ADR-0066 ① gate, restated) crosses the |
| 132 | + tenant wall. Layer 1's bypass no longer implies Layer 0's. |
| 133 | +- **The wildcard `tenant_isolation` policy retires** from the seeded |
| 134 | + permission sets once Layer 0 enforces; the RLS compiler then never sees a |
| 135 | + tenant policy, and the OR-merge (W1) is closed **by construction** rather |
| 136 | + than by auditing every policy. The driver-level `applyTenantScope` seam is |
| 137 | + unchanged (defense in depth, now behind a real first line). |
| 138 | + |
| 139 | +**Behavior contract.** The extraction is behavior-preserving under the matrix |
| 140 | +gate, with one deliberate exception: where the current OR-merge (W1) would |
| 141 | +admit a cross-org row that the documented AND semantics forbid, Layer 0 |
| 142 | +enforces the documented semantics. That is a **security fix**, not drift; the |
| 143 | +matrix suite gains explicit rows for it (a permissive business policy × a |
| 144 | +foreign-org row → invisible). |
| 145 | + |
| 146 | +### D2 — A monotonic posture ladder, resolved once |
| 147 | + |
| 148 | +`resolveAuthzContext` resolves and carries an explicit posture: |
| 149 | + |
| 150 | +```ts |
| 151 | +enum AuthzPosture { |
| 152 | + PLATFORM_ADMIN = 3, // crosses the tenant wall where object posture permits (D1) |
| 153 | + TENANT_ADMIN = 2, // all rows within the organization |
| 154 | + MEMBER = 1, // business RLS (ownership / unit depth / sharing) within the organization |
| 155 | + EXTERNAL = 0, // explicitly shared rows only — never OWD-derived visibility |
| 156 | +} |
| 157 | +``` |
| 158 | + |
| 159 | +- **Exactly one injection rule per rung.** Each rung maps to one row-visibility |
| 160 | + rule; a request's effective read filter is `Layer0(posture, object) AND |
| 161 | + Layer1(rung rule)`. No rung consults another rung's rule; there is no |
| 162 | + "admin OR member" composition. |
| 163 | +- **Strict nesting is the tested invariant**: for every object, the visible-row |
| 164 | + set at rung *n* is a superset of rung *n−1*. The |
| 165 | + `resolve-authz-context.test.ts` suite upgrades from per-case assertions to a |
| 166 | + `role × object × expected-rows` matrix snapshot asserting the nesting. |
| 167 | +- **EXTERNAL is defined and locked now, enforced later.** Semantics fixed by |
| 168 | + this ADR: an EXTERNAL principal sees only rows explicitly shared to it — |
| 169 | + sharing rules and OWD-derived baselines never apply; a misconfiguration can |
| 170 | + only *shrink* its visibility, never widen it. No external principal type |
| 171 | + exists today (the sharing chain has no such concept), so the resolver cannot |
| 172 | + yet return `EXTERNAL`; the enum value, its injection rule, and its |
| 173 | + semantics tests land with D2 so the rung cannot be reinvented differently |
| 174 | + when portal/external membership arrives (aligned with ADR-0093's membership |
| 175 | + model when it does). |
| 176 | + |
| 177 | +### D3 — Posture derives from capabilities, never from roles |
| 178 | + |
| 179 | +- `PLATFORM_ADMIN` posture is **derived** from held capability grants — the |
| 180 | + same evidence the superuser bypass already trusts |
| 181 | + (`viewAllRecords`/`modifyAllRecords` via unscoped `admin_full_access`-class |
| 182 | + grants; `permission-evaluator.ts` `hasSuperuserReadBypass`/ |
| 183 | + `hasSuperuserWriteBypass`). The read/write distinction stays where it is |
| 184 | + today: posture selects the tier; the per-side capability bit still gates the |
| 185 | + per-side bypass. |
| 186 | +- `TENANT_ADMIN` derives from org-admin capability grants (today's |
| 187 | + `organization_admin` set), with better-auth `role='admin'` demoted to **one |
| 188 | + source that grants that position** (the existing `mapMembershipRole` |
| 189 | + normalization becomes a grant-provisioning concern, not an |
| 190 | + enforcement-time input). |
| 191 | +- **No enforcement-time code path may consult the better-auth role directly.** |
| 192 | + This removes dual-track adjudication: #2836-class conflicts (explicit deny |
| 193 | + from one track out-composing a wildcard allow from the other) become |
| 194 | + impossible because there is one track. |
| 195 | + |
| 196 | +## Sequencing and the matrix gate (implementation contract) |
| 197 | + |
| 198 | +The chain is strictly serial — `D1 → D2 → D3` (#2920's B1 → B2 → B4) — and |
| 199 | +each step lands only behind the snapshot gate: |
| 200 | + |
| 201 | +0. **Gate first.** The existing conformance matrix |
| 202 | + (`packages/dogfood/test/authz-conformance.matrix.ts`) is integration-layer; |
| 203 | + before D1 begins, an equivalent `role × object × expected-visible-rows` |
| 204 | + snapshot must run at the **unit layer** (inside `plugin-security`'s test |
| 205 | + suite) so the loop is minutes, not a dogfood boot. |
| 206 | +1. **D1** extracts Layer 0 (touches `rls-compiler.ts` call sites, |
| 207 | + `computeRlsFilter`, the seeded permission sets, `resolve-authz-context.ts`). |
| 208 | +2. **D2** introduces the enum and rewires the injection rules (touches |
| 209 | + `resolve-authz-context.ts`, its test matrix). |
| 210 | +3. **D3** re-anchors derivation (touches `resolve-authz-context.ts`, |
| 211 | + `permission-evaluator.ts`, membership-role handling in `plugin-auth`). |
| 212 | + |
| 213 | +## Consequences |
| 214 | + |
| 215 | +**Positive.** |
| 216 | +- Tenant isolation becomes an invariant with one owner, one code path, and an |
| 217 | + explicit exemption rule — not a policy that merges with its inferiors (W1) |
| 218 | + nor a rider on their bypass bit (W2). |
| 219 | +- The security boundary ("who sees what tier of rows") becomes an enumerable, |
| 220 | + snapshot-tested ladder — the property the "explainable authorization" track |
| 221 | + needs the kernel to have. |
| 222 | +- One adjudication track for admin-ness ends the #2836 class. |
| 223 | +- The EXTERNAL rung's deny-by-default semantics are decided once, ahead of |
| 224 | + need, instead of under portal-feature deadline pressure. |
| 225 | + |
| 226 | +**Negative / accepted.** |
| 227 | +- The seeded permission sets change (the `tenant_isolation` policy retires). |
| 228 | + Environments that customized seeded sets keep their overlays (ADR-0094 |
| 229 | + semantics), but the diff is visible; release notes must call it out. |
| 230 | +- W1's resolution is a behavior change in deliberately-misconfigured corners |
| 231 | + (a business policy that was silently widening tenant scope stops doing so). |
| 232 | + This is the point, and it ships loudly. |
| 233 | +- `resolveAuthzContext` gains one more derived field; hot-path cost is one |
| 234 | + enum computation over already-loaded grants (negligible). |
| 235 | + |
| 236 | +**Neutral / explicitly out of scope.** |
| 237 | +- `single`-mode deployments: Layer 0 is inert; nothing changes. |
| 238 | +- The driver-level `applyTenantScope` seam and ADR-0002 physical isolation are |
| 239 | + untouched — Layer 0 sits between them and business RLS. |
| 240 | +- Business RLS semantics (ownership, ADR-0057 depth, sharing, CEL) are |
| 241 | + untouched; only the tenant policy leaves that layer. |
| 242 | + |
| 243 | +## Alternatives considered |
| 244 | + |
| 245 | +- **Keep the policy-shaped tenant filter and fix the merge (AND the wildcard |
| 246 | + in the compiler).** Rejected: it repairs W1 but leaves W2 (shared bypass) |
| 247 | + and keeps the invariant inside the machinery it must be independent of; the |
| 248 | + next compiler change re-opens the question. The ADR-0094 lesson applies: |
| 249 | + enforce structurally, not by auditing the glue. |
| 250 | +- **Postgres-native RLS backstop for Layer 0** (`ENABLE ROW LEVEL SECURITY` + |
| 251 | + `tenant_isolation` policy on `org_id = current_setting('app.org_id')`, with |
| 252 | + `SET LOCAL` per transaction). **Rejected — maintainer decision, 2026-07-14.** |
| 253 | + The cloud topology is already physically isolated per environment |
| 254 | + (ADR-0002); the only beneficiary is the EE shared-DB mode, and the cost is a |
| 255 | + PG-only defense line (SQLite/MySQL excluded, portability contract broken for |
| 256 | + the layer), GUC/pooling hazards (PgBouncer transaction mode vs `SET LOCAL`), |
| 257 | + a doubled test matrix, and BYPASSRLS credential management. Revisit only if |
| 258 | + a shared-database SaaS topology ships as a first-class product. |
| 259 | +- **Store posture on the user record.** Rejected: posture must be *derived* |
| 260 | + from grants at resolve time — a stored tier is a second writable authority |
| 261 | + that can drift from the grants (the ADR-0094 one-authority rule, applied to |
| 262 | + a computed fact). |
| 263 | +- **Model EXTERNAL as a very-restricted MEMBER (no new rung).** Rejected: the |
| 264 | + member rung's rule composes OWD baselines and sharing; external safety |
| 265 | + demands the *absence* of those sources, which a data-driven restriction of |
| 266 | + the member rule cannot guarantee fail-closed. A rung whose rule never |
| 267 | + consults OWD can. |
| 268 | + |
| 269 | +## References |
| 270 | + |
| 271 | +- framework#2920 (tracking; B0 = this ADR, B1 = D1, B2 = D2, B4 = D3) · |
| 272 | + #2836 (dual-track adjudication incident) · #2909 / ADR-0094 (structural |
| 273 | + one-authority precedent). |
| 274 | +- Code (current state): `packages/plugins/plugin-security/src/rls-compiler.ts` |
| 275 | + (OR-merge), `packages/plugins/plugin-security/src/security-plugin.ts` |
| 276 | + (`computeRlsFilter`, posture-gated bypass), |
| 277 | + `packages/plugins/plugin-security/src/objects/default-permission-sets.ts` |
| 278 | + (`tenant_isolation` wildcard policy), |
| 279 | + `packages/core/src/security/resolve-authz-context.ts`, |
| 280 | + `packages/plugins/plugin-security/src/permission-evaluator.ts`, |
| 281 | + `packages/dogfood/test/authz-conformance.matrix.ts`. |
| 282 | +- ADR-0002 (physical isolation), ADR-0066 (precedence rule this ADR makes |
| 283 | + true; superuser bypass gate), ADR-0073 (claim scoped by this ADR), |
| 284 | + ADR-0093 (`tenancy` service consumed by Layer 0). |
0 commit comments