Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/adr-0095-d1-tenant-layer-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@objectstack/plugin-security": minor
---

ADR-0095 D1: tenant isolation is now **Layer 0** — an independent, always-first,
AND-composed filter (`tenant-layer.ts`), no longer a wildcard `tenant_isolation`
RLS policy OR-merged with business RLS. The effective row filter is
`Layer0(tenant) AND Layer1(business RLS)`; the two share no compiler, merge step,
or bypass bit. The superuser bypass now exempts the tenant wall only as a Layer 0
rule (platform-admin posture + object posture permits: private / platform-global
/ better-auth-managed), never via a business-RLS short-circuit.

**BREAKING (multi-org `tenancy.mode = 'multi'` deployments only; `single` mode is
inert and unchanged).** Retiring the OR-merged tenant policy resolves four
behavior deltas, all toward stronger/correcter isolation:

- **(a) Cross-tenant read leak closed.** A permissive business RLS policy (e.g.
`status == 'public'`) no longer OR-widens tenant scope; a foreign-org row it
matched is now invisible.
- **(b) Member by-id writes narrow to owner-only.** The OR-merge silently widened
`owner_only_writes` (`created_by == me`) back to org-wide, so a member could
by-id update/delete *any* record in their org. Writes are now owner-scoped as
authored. **Migration:** if your deployment intentionally relied on members
editing each other's records org-wide, grant an explicit per-object edit
permission set (position-distributed) where that is wanted — the baseline
`member_default` no longer permits it.
- **(c) Global-catalog objects visible to members.** On a `tenancy.enabled:false`
object, members were scoped by a phantom `organization_id` filter (a column
such objects lack); Layer 0 correctly treats them as non-tenant, so the global
catalog is visible.
- **(e) No-active-org writes fail closed.** A write by a principal with no active
organization on a tenant object is now denied (was owner-scoped only).

`tenant_isolation` is retired from the seeded `organization_admin` /
`member_default` / `viewer_readonly` sets; the `_self` / `_org` identity-table
carve-outs and `owner_only_writes/deletes` are unchanged. Customized seeded sets
keep their overlays (ADR-0094). The driver-level `applyTenantScope` seam is
untouched. See ADR-0095 and framework#2936 (the `extractTargetField` `==` blind
spot this exposes, tracked separately).
61 changes: 49 additions & 12 deletions docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,43 @@ compiler:
than by auditing every policy. The driver-level `applyTenantScope` seam is
unchanged (defense in depth, now behind a real first line).

**Behavior contract.** The extraction is behavior-preserving under the matrix
gate, with one deliberate exception: where the current OR-merge (W1) would
admit a cross-org row that the documented AND semantics forbid, Layer 0
enforces the documented semantics. That is a **security fix**, not drift; the
matrix suite gains explicit rows for it (a permissive business policy × a
foreign-org row → invisible).
**Behavior contract (as built).** The extraction is behavior-preserving under
the matrix gate — every `role × object × operation` cell is identical or a
same-visibility filter simplification (duplicate-OR dedup; dead org-clause
removal on non-tenant objects) — **except four deliberate deltas**. All four are
the same structural defect (the tenant policy sharing the OR-merge / a
`==`-blind field net with business RLS) resolving toward stronger, more correct
isolation; the matrix suite (`plugin-security/authz-matrix-gate.test.ts`) gains
an explicit, annotated cell for each:

- **(a) W1 read.** A permissive business policy (e.g. `status == 'public'`) no
longer OR-widens tenant scope: a foreign-org public row becomes **invisible**
(`Layer0(org) AND Layer1(status==public)`). *The* headline W1 fix.
- **(b) W1 write (its twin).** The same OR-merge silently widened a *restrictive*
write policy: `owner_only_writes` (`created_by == me`) was OR'd with the tenant
policy, so a member's by-id write resolved to `org OR created_by` = **any row
in their org**. Layer 0 AND-composes the tenant scope, so the write narrows to
**owner-only**, as authored. (Release-notes BREAKING — see Consequences.)
- **(c) Platform-global object.** A member reading a `tenancy.enabled:false`
global object was scoped by a *phantom* `organization_id` filter — the seeded
tenant policy uses canonical `==`, which the field-existence/tenancy-disabled
net (`extractTargetField`, single-`=`/`IN` only) cannot parse, so the
tenancy-disabled skip never fired and the policy compiled against a column the
object lacks. Layer 0 decides "tenant object?" **directly** from the field set
+ posture (not via `extractTargetField`), so a global object correctly
contributes nothing and its **catalog is visible**. (The broader
`extractTargetField` `==` blind spot still affects Layer-1 *business* policies
and is tracked separately — out of scope for D1.)
- **(e) No active organization.** A write by a principal with no active org on a
tenant object is now **fail-closed by Layer 0** (was owner-scoped only).

**Not touched.** `computeWriteCheckFilter` gains no tenant post-image check: the
seeded `tenant_isolation` carried only a `using` clause (never a `check`), so
there is no post-image tenant behavior to preserve; insert tenant placement is
owned by the enterprise auto-stamp, and update/delete targeting is enforced by
the Layer 0 pre-image path. Adding a mandatory post-image tenant check would
risk denying legitimate inserts before the auto-stamp runs — a regression, not a
fix — so it is deliberately excluded from D1.

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

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

**Negative / accepted.**
- The seeded permission sets change (the `tenant_isolation` policy retires).
Environments that customized seeded sets keep their overlays (ADR-0094
semantics), but the diff is visible; release notes must call it out.
- W1's resolution is a behavior change in deliberately-misconfigured corners
(a business policy that was silently widening tenant scope stops doing so).
This is the point, and it ships loudly.
- The seeded permission sets change (the `tenant_isolation` policy retires from
`organization_admin` / `member_default` / `viewer_readonly`). Environments that
customized seeded sets keep their overlays (ADR-0094 semantics), but the diff
is visible; release notes must call it out.
- **Four behavior deltas ship (release-notes BREAKING), all W1-class** — see the
D1 behavior contract for the enumerated cells. The load-bearing one for
operators is **(b)**: a deployment that relied on *members editing each other's
records within the org* (the OR-merge silently permitting it) will find member
by-id writes now restricted to records they own (`created_by`); grant an
explicit per-object edit set where org-wide editing is intended. Deltas (a) W1
read, (c) global-catalog visibility for members, and (e) fail-closed no-org
writes are each toward stronger/correcter isolation. These ship loudly.
- `resolveAuthzContext` gains one more derived field; hot-path cost is one
enum computation over already-loaded grants (negligible).

Expand Down
2 changes: 1 addition & 1 deletion packages/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
{ id: 'controlled-by-parent', summary: 'master-detail controlled_by_parent', state: 'enforced',
enforcement: 'plugin-security/security-plugin.ts computeControlledByParentFilter + assertControlledByParentWrite', proof: 'controlled-by-parent.dogfood.test.ts' },
{ id: 'multi-tenant', summary: 'organization isolation', state: 'enforced',
enforcement: '@objectstack/organizations (enterprise) + wildcard tenant RLS', proof: 'rls-multitenant.dogfood.test.ts' },
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' },
{ id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced',
enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' },
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
Expand Down
Loading