Skip to content

Commit 607e35a

Browse files
committed
feat(plugin-security): extract tenant isolation as Layer 0 (ADR-0095 D1)
Tenant isolation is now Layer 0 — an independent, always-first, AND-composed filter (`tenant-layer.ts`), not a wildcard `tenant_isolation` RLS policy OR-merged with business RLS. Effective filter = Layer0(tenant) AND Layer1(RLS); the two share no compiler, merge step, or bypass bit (closes W1 by construction, W2 structurally). The superuser bypass now exempts the tenant wall only as a Layer 0 rule (platform-admin posture + object posture permits), never via a business-RLS short-circuit. Layer 0 decides "tenant object?" directly from the field set + tenancy posture (NOT via extractTargetField's `=`-only shape match), so a `tenancy.enabled:false` global object correctly contributes nothing. `tenant_isolation` retired from the seeded organization_admin / member_default / viewer_readonly sets; the `_self` / `_org` identity carve-outs and owner_only_writes/deletes are kept. Driver-level `applyTenantScope` untouched. Four accepted behavior deltas (multi-org only; single mode inert), all W1-class toward stronger isolation — see ADR-0095 D1 behavior contract: (a) permissive business policy no longer OR-widens tenant scope (cross-org read leak closed); (b) member by-id writes narrow to owner-only (owner_only_writes no longer OR-diluted) — release-notes BREAKING; (c) members see `tenancy.enabled:false` global catalogs (no phantom org filter); (e) no-active-org writes on a tenant object fail closed. computeWriteCheckFilter gains no tenant post-image check by design (tenant_isolation had only a `using` clause; insert placement is the enterprise auto-stamp's job) — avoids an insert-breaking regression. Also expands ADR-0095 D1 to enumerate the four deltas as-built; updates the matrix gate to post-extraction expectations; updates the dogfood conformance matrix's multi-tenant enforcement site; retitles/adjusts existing tests that hard-coded the retired policy. The extractTargetField `==` blind spot this exposes (still affecting Layer-1 business policies) is tracked in #2936. Verified: plugin-security 355 tests + matrix gate 6 cells green; tsc + build clean; dogfood authz-conformance + single-tenant RLS proofs green (multi-org dogfood requires the enterprise package and is skipped in open-core). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs
1 parent cb2abc5 commit 607e35a

9 files changed

Lines changed: 405 additions & 188 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
ADR-0095 D1: tenant isolation is now **Layer 0** — an independent, always-first,
6+
AND-composed filter (`tenant-layer.ts`), no longer a wildcard `tenant_isolation`
7+
RLS policy OR-merged with business RLS. The effective row filter is
8+
`Layer0(tenant) AND Layer1(business RLS)`; the two share no compiler, merge step,
9+
or bypass bit. The superuser bypass now exempts the tenant wall only as a Layer 0
10+
rule (platform-admin posture + object posture permits: private / platform-global
11+
/ better-auth-managed), never via a business-RLS short-circuit.
12+
13+
**BREAKING (multi-org `tenancy.mode = 'multi'` deployments only; `single` mode is
14+
inert and unchanged).** Retiring the OR-merged tenant policy resolves four
15+
behavior deltas, all toward stronger/correcter isolation:
16+
17+
- **(a) Cross-tenant read leak closed.** A permissive business RLS policy (e.g.
18+
`status == 'public'`) no longer OR-widens tenant scope; a foreign-org row it
19+
matched is now invisible.
20+
- **(b) Member by-id writes narrow to owner-only.** The OR-merge silently widened
21+
`owner_only_writes` (`created_by == me`) back to org-wide, so a member could
22+
by-id update/delete *any* record in their org. Writes are now owner-scoped as
23+
authored. **Migration:** if your deployment intentionally relied on members
24+
editing each other's records org-wide, grant an explicit per-object edit
25+
permission set (position-distributed) where that is wanted — the baseline
26+
`member_default` no longer permits it.
27+
- **(c) Global-catalog objects visible to members.** On a `tenancy.enabled:false`
28+
object, members were scoped by a phantom `organization_id` filter (a column
29+
such objects lack); Layer 0 correctly treats them as non-tenant, so the global
30+
catalog is visible.
31+
- **(e) No-active-org writes fail closed.** A write by a principal with no active
32+
organization on a tenant object is now denied (was owner-scoped only).
33+
34+
`tenant_isolation` is retired from the seeded `organization_admin` /
35+
`member_default` / `viewer_readonly` sets; the `_self` / `_org` identity-table
36+
carve-outs and `owner_only_writes/deletes` are unchanged. Customized seeded sets
37+
keep their overlays (ADR-0094). The driver-level `applyTenantScope` seam is
38+
untouched. See ADR-0095 and framework#2936 (the `extractTargetField` `==` blind
39+
spot this exposes, tracked separately).

docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,43 @@ compiler:
136136
than by auditing every policy. The driver-level `applyTenantScope` seam is
137137
unchanged (defense in depth, now behind a real first line).
138138

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).
139+
**Behavior contract (as built).** The extraction is behavior-preserving under
140+
the matrix gate — every `role × object × operation` cell is identical or a
141+
same-visibility filter simplification (duplicate-OR dedup; dead org-clause
142+
removal on non-tenant objects) — **except four deliberate deltas**. All four are
143+
the same structural defect (the tenant policy sharing the OR-merge / a
144+
`==`-blind field net with business RLS) resolving toward stronger, more correct
145+
isolation; the matrix suite (`plugin-security/authz-matrix-gate.test.ts`) gains
146+
an explicit, annotated cell for each:
147+
148+
- **(a) W1 read.** A permissive business policy (e.g. `status == 'public'`) no
149+
longer OR-widens tenant scope: a foreign-org public row becomes **invisible**
150+
(`Layer0(org) AND Layer1(status==public)`). *The* headline W1 fix.
151+
- **(b) W1 write (its twin).** The same OR-merge silently widened a *restrictive*
152+
write policy: `owner_only_writes` (`created_by == me`) was OR'd with the tenant
153+
policy, so a member's by-id write resolved to `org OR created_by` = **any row
154+
in their org**. Layer 0 AND-composes the tenant scope, so the write narrows to
155+
**owner-only**, as authored. (Release-notes BREAKING — see Consequences.)
156+
- **(c) Platform-global object.** A member reading a `tenancy.enabled:false`
157+
global object was scoped by a *phantom* `organization_id` filter — the seeded
158+
tenant policy uses canonical `==`, which the field-existence/tenancy-disabled
159+
net (`extractTargetField`, single-`=`/`IN` only) cannot parse, so the
160+
tenancy-disabled skip never fired and the policy compiled against a column the
161+
object lacks. Layer 0 decides "tenant object?" **directly** from the field set
162+
+ posture (not via `extractTargetField`), so a global object correctly
163+
contributes nothing and its **catalog is visible**. (The broader
164+
`extractTargetField` `==` blind spot still affects Layer-1 *business* policies
165+
and is tracked separately — out of scope for D1.)
166+
- **(e) No active organization.** A write by a principal with no active org on a
167+
tenant object is now **fail-closed by Layer 0** (was owner-scoped only).
168+
169+
**Not touched.** `computeWriteCheckFilter` gains no tenant post-image check: the
170+
seeded `tenant_isolation` carried only a `using` clause (never a `check`), so
171+
there is no post-image tenant behavior to preserve; insert tenant placement is
172+
owned by the enterprise auto-stamp, and update/delete targeting is enforced by
173+
the Layer 0 pre-image path. Adding a mandatory post-image tenant check would
174+
risk denying legitimate inserts before the auto-stamp runs — a regression, not a
175+
fix — so it is deliberately excluded from D1.
145176

146177
### D2 — A monotonic posture ladder, resolved once
147178

@@ -224,12 +255,18 @@ each step lands only behind the snapshot gate:
224255
need, instead of under portal-feature deadline pressure.
225256

226257
**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.
258+
- The seeded permission sets change (the `tenant_isolation` policy retires from
259+
`organization_admin` / `member_default` / `viewer_readonly`). Environments that
260+
customized seeded sets keep their overlays (ADR-0094 semantics), but the diff
261+
is visible; release notes must call it out.
262+
- **Four behavior deltas ship (release-notes BREAKING), all W1-class** — see the
263+
D1 behavior contract for the enumerated cells. The load-bearing one for
264+
operators is **(b)**: a deployment that relied on *members editing each other's
265+
records within the org* (the OR-merge silently permitting it) will find member
266+
by-id writes now restricted to records they own (`created_by`); grant an
267+
explicit per-object edit set where org-wide editing is intended. Deltas (a) W1
268+
read, (c) global-catalog visibility for members, and (e) fail-closed no-org
269+
writes are each toward stronger/correcter isolation. These ship loudly.
233270
- `resolveAuthzContext` gains one more derived field; hot-path cost is one
234271
enum computation over already-loaded grants (negligible).
235272

packages/dogfood/test/authz-conformance.matrix.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
4141
{ id: 'controlled-by-parent', summary: 'master-detail controlled_by_parent', state: 'enforced',
4242
enforcement: 'plugin-security/security-plugin.ts computeControlledByParentFilter + assertControlledByParentWrite', proof: 'controlled-by-parent.dogfood.test.ts' },
4343
{ id: 'multi-tenant', summary: 'organization isolation', state: 'enforced',
44-
enforcement: '@objectstack/organizations (enterprise) + wildcard tenant RLS', proof: 'rls-multitenant.dogfood.test.ts' },
44+
enforcement: '@objectstack/organizations (enterprise) + Layer 0 tenant wall (plugin-security/tenant-layer.ts, AND-composed ahead of business RLS — ADR-0095 D1)', proof: 'rls-multitenant.dogfood.test.ts' },
4545
{ id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced',
4646
enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' },
4747
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',

0 commit comments

Comments
 (0)