Skip to content

Commit 94f33ec

Browse files
os-zhuangclaude
andauthored
docs(adr): ADR-0095 — authz kernel chain: tenant isolation as Layer 0, monotonic posture ladder, capability-derived posture (#2924)
B0 of tracking issue #2920. Records the kernel-chain decisions (D1 Layer 0 extraction, D2 posture ladder incl. locked EXTERNAL semantics, D3 capability-derived posture), scopes ADR-0073's cross-tenant-RLS claim to the physical topology, documents the OR-merge spec-vs-impl divergence (W1), and rejects the Postgres-native RLS backstop per maintainer decision. Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs Co-authored-by: Claude <noreply@anthropic.com>
1 parent 77d9618 commit 94f33ec

1 file changed

Lines changed: 284 additions & 0 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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

Comments
 (0)