diff --git a/.changeset/adr-0099-p2prime-two-axis-cells.md b/.changeset/adr-0099-p2prime-two-axis-cells.md new file mode 100644 index 0000000000..2e176bd822 --- /dev/null +++ b/.changeset/adr-0099-p2prime-two-axis-cells.md @@ -0,0 +1,5 @@ +--- +'@objectstack/plugin-security': patch +--- + +ADR-0099 P2′ (#3211 M3′): pin the two-axis Amendment in the authz matrix. The original P2 (collapse the Layer 1 tier onto posture) was rejected — Layer 1's tier input is the per-object super-bit, a per-principal × per-object delegation primitive posture cannot represent. New cells pin: seeded-face agreement (seeded super-bit holders are already ≥ TENANT_ADMIN), the load-bearing delegation cell (a MEMBER with a delegated per-object `viewAllRecords`/`modifyAllRecords` short-circuits Layer 1 yet stays walled by Layer 0 — the auditor pattern), invariant I7 (the scope axis never crosses a boundary posture has not opened), and the contrast that the bit is a real grantable capability, not conditionally inert. Test-only; zero behavior change. diff --git a/docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md b/docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md index d8957bc0bb..c988261c8a 100644 --- a/docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md +++ b/docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md @@ -1,6 +1,6 @@ # ADR-0099: Posture-Adjudicated Tiering — one axis for tier decisions, the EXTERNAL rung's enforcement path, no explicit deny -**Status**: Accepted (2026-07-18; proposed 2026-07-17) — P0 equivalence gate landed with the acceptance (see the Acceptance addendum); P1/P2 flips gated on the #3211 G1 delta adjudication +**Status**: Accepted (2026-07-18; proposed 2026-07-17) — P0 equivalence gate landed with the acceptance (see the Acceptance addendum); P1 shipped (#3226). Amended 2026-07-18: **two-axis correction** — D2's Layer 1 tier input corrected to the per-object scope bits, original P2 superseded by P2′ (see the Amendment) **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) (the posture ladder + Layer 0 — Accepted, implemented; this ADR is its adjudication follow-through), [ADR-0066](./0066-unified-authorization-model.md) (superuser bypass ①; precedence), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (D10 principal taxonomy / `audience`, D11 external OWD), [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (membership lifecycle — where an external principal type would come from), [ADR-0094](./0094-sys-permission-set-pure-projection.md) (one-authority precedent) **Composes with**: [ADR-0096](./0096-execution-surface-identity-admission.md) — 0096 governs **admission** (may this call reach the engine, as whom); this ADR governs **tiering** (given an admitted principal, which tier of rows each layer grants). Orthogonal axes, deliberately separate ADRs. @@ -121,9 +121,13 @@ Every enforcement-time **tier** decision reads `ctx.posture`: ADR-0096 D3 is progressively eliminating). When both signals are present the carried rung wins; a disagreement is logged as a defect signal, and the **narrower** verdict is enforced (fail-safe). -- **Layer 1 short-circuit**: the *tier* question ("does business RLS apply at - all inside the org") reads posture (`>= TENANT_ADMIN` short-circuits, per - today's semantics); the *side* question keeps its per-side capability bit — +- **Layer 1 short-circuit** *(superseded by the two-axis Amendment below)*: the + original text read the tier from posture (`>= TENANT_ADMIN`) and claimed + today's semantics. The pre-P2 verification (framework#3211) showed that is + NOT today's semantics: the tier input is the **per-object** super-bit, which + a custom set can delegate to a non-admin principal — a per-principal × + per-object fact the per-principal posture cannot represent. Layer 1's tier + input therefore **stays the per-side, per-object capability bit**; `viewAllRecords` gates the read bypass, `modifyAllRecords` the write bypass, exactly as today. Posture never grants a side the bits don't. - **Explain** reports the carried rung it read — the same field, not a @@ -148,6 +152,12 @@ it would have caught). | **Write path** | tier does not bypass write guards: readonly strip (#2957), identity write guard (ADR-0092), Layer 0 write wall (#2946) all still apply | same | same | shares with edit access only | | **Explain** | reports the carried rung — identical input as enforcement, by construction | same | same | same | +> **Amendment note (2026-07-18):** the **Layer 1** row above is corrected by +> the two-axis Amendment — the tier at Layer 1 is *not* selected by posture; +> it is selected by the per-object super-bits (a `MEMBER` holding a delegated +> per-object `viewAllRecords` short-circuits business RLS on that object, +> tenant-walled as ever). The Layer 0 and EXTERNAL rows stand as written. + Invariants (each carries a matrix cell): - **I1** — `TENANT_ADMIN` never crosses Layer 0. No capability composition may @@ -233,8 +243,11 @@ Strictly serial, each step behind the matrix: rung (probe demoted to fallback; stale comment at `security-plugin.ts:71-77` retired). Behavior-preserving under P0's gate. Unblocked today (#2956); small. -- **P2** — Layer 1 short-circuit reads posture for tier (side bits unchanged). - Behavior-preserving under the gate. +- **P2** *(superseded — rejected at adjudication, replaced by P2′; see the + Amendment)* — the original "Layer 1 short-circuit reads posture for tier" + turned out NOT to be behavior-preserving: it would inert the delegated + per-object super-bit for non-admin principals. P2′ is documentation cells + only (seeded-face agreement, the delegation cell, I7), zero behavior change. - **P3 (demand-gated)** — EXTERNAL derivation + injection when the external principal type ships (portal track). New behavior for a new principal type; no existing principal's visibility changes. If any breaking surface @@ -333,3 +346,64 @@ D1's behavior contract with one finding: doing its job as specified ("any cell where they disagree … must be resolved before the flip") — the resolution is D1's rung authority, not a probe repair. + +## Amendment — the two-axis correction (2026-07-18) + +**Trigger.** P1 shipped behavior-preserving on the seeded face (#3226). The +pre-P2 verification (framework#3211) then found that D2's Layer 1 tier claim +was **not** behavior-preserving as written. Layer 1's tier input today is the +per-object super-bit — a **per-principal × per-object delegation primitive**: +a custom permission set may grant `viewAllRecords` on one object (all invoices +to an auditor position) without granting any admin stature. This is the +industry-standard shape (Salesforce object-level View All / Modify All, +org-level per-entity access in comparable platforms). Posture is +**per-principal** — one rung per principal — and structurally cannot carry +per-object scope: a MEMBER with a delegated invoice view-all and a plain +MEMBER sit on the same rung. Enforcing D2's MEMBER cell literally would have +(a) made a declared, grantable bit conditionally inert — the +declared-but-unenforced class ADR-0049 forbids and the silent-miss failure +mode this platform removes everywhere else — and (b) deleted a mainstream +delegation capability, forcing over-grants (promote the auditor to org admin) +to recover it. + +**Decision (maintainer, 2026-07-18).** The model is corrected to **two +orthogonal axes, each with a single authority** — the table's Layer 1 row is +amended rather than enforced: + +- **Posture is the boundary axis** (per-principal). It exclusively governs the + ladder's *ends*: the Layer 0 cross-tenant exemption at `PLATFORM_ADMIN` + (P1, shipped) and the EXTERNAL rung's wholesale replacement of the member + pipeline (P3, demand-gated). No other enforcement site consults posture for + tiering. +- **The per-object super-bits are the scope axis** (per-principal × + per-object). `viewAllRecords` / `modifyAllRecords` remain the only Layer 1 + tier input — per side, per object, delegable to any position; today's + semantics, unchanged. +- **D1's "single adjudication input" refines to "single authority per + axis."** The disease this ADR treats — one fact re-derived at multiple + sites — stays dead: the boundary fact has one derivation (the resolver) and + one enforcement reader (the Layer 0 gate); the scope fact has one evaluator + (`PermissionEvaluator.hasSuperuser*Bypass`). Neither is derived anywhere + else. +- **I7 (new — the I5 dual):** the scope axis never crosses a boundary the + posture axis has not opened. A super-bit holder below `PLATFORM_ADMIN` + stays tenant-walled (enforced by P1; pinned in the matrix), and an EXTERNAL + principal's explicit-share rule replaces Layer 1 regardless of any held + bits (pinned when P3 lands). +- **I2 clarification:** nesting is a per-rung *floor* invariant. Additive + per-object grants widen visibility within a rung (two MEMBERs may differ) + without violating the ladder; no rung's floor exceeds the rung above. +- **P2 → P2′.** The original P2 flip is rejected. P2′ lands documentation + cells only: the seeded-face agreement pin (every seeded super-bit holder + sits ≥ `TENANT_ADMIN`, so the axes agree on the seeded surface), the + **delegation cell** (a MEMBER holding a custom per-object `viewAllRecords` + on a private object sees all rows in-org and remains tenant-walled — the + pattern is load-bearing and must not be "cleaned up" by a future + convergence attempt), and the I7 cells. Zero behavior change. An + authoring-time advisory for high-privilege bit grants (via + `describeHighPrivilegeBits`) is tracked separately. + +The AI-authoring rule this yields is one sentence — *bits pick your row scope +on an object; posture picks which tenant boundaries exist for you at all* — +which is the smallest mental model the platform can offer an agent writing +permissions, with no conditionally-dead declarations. diff --git a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts index 6fa831d7e4..1f3b0050b4 100644 --- a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts +++ b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts @@ -32,7 +32,7 @@ // objects) and is annotated inline. import { describe, it, expect, vi } from 'vitest'; -import { derivePosture } from '@objectstack/core'; +import { derivePosture, POSTURE_RANK } from '@objectstack/core'; import { SecurityPlugin, hasPlatformAdminCapability } from './security-plugin.js'; import { PermissionEvaluator } from './permission-evaluator.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; @@ -53,7 +53,19 @@ const publicReader: PermissionSet = { ], } as any; -const ALL_SETS: PermissionSet[] = [...defaultPermissionSets, publicReader]; +// [ADR-0099 two-axis Amendment] A per-object super-bit DELEGATED to a non-admin +// position — the auditor pattern (Salesforce object-level View All / Modify All): +// "read (and write) every row of THIS ONE private object", granted without any +// admin stature. This is the scope-axis primitive posture cannot represent (it is +// per-principal × per-object); the P2′ cells below pin that it short-circuits +// Layer 1 for the holder AND stays walled by Layer 0 (invariant I7). +const invoiceAuditor: PermissionSet = { + name: 'invoice_auditor', + label: 'Invoice Auditor (delegated per-object view/modify all)', + objects: { crm_secret: { allowRead: true, allowCreate: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: true } }, +} as any; + +const ALL_SETS: PermissionSet[] = [...defaultPermissionSets, publicReader, invoiceAuditor]; const DENY = RLS_DENY_FILTER.id; // the fail-closed sentinel's marker value // ── Minimal middleware harness ────────────────────────────────────────────── @@ -676,3 +688,87 @@ describe('ADR-0099 P1 — Layer 0 exemption reads the carried rung (#3211 M2)', expect(authzWarn).toHaveLength(0); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// ADR-0099 P2′ — two-axis: per-object super-bit is the Layer 1 scope, +// posture is only the boundary (#3211 M3′) +// ═══════════════════════════════════════════════════════════════════════════ +// +// The two-axis Amendment (2026-07-18): the original P2 (collapse the Layer 1 +// tier onto posture) was REJECTED — Layer 1's tier input is the per-object +// super-bit, a per-principal × per-object DELEGATION primitive that posture (one +// rung per principal) structurally cannot carry. Collapsing it would have made a +// declared, grantable bit conditionally inert (the ADR-0049 class) and deleted +// the mainstream "delegate view-all of one object to a non-admin" capability. +// +// These are documentation cells, zero behavior change. They pin: +// 1. the two axes AGREE on the seeded surface (every seeded super-bit holder +// is already >= TENANT_ADMIN — so the seeded face never needed collapsing); +// 2. the DELEGATION cell — a MEMBER holding a delegated per-object super-bit +// short-circuits Layer 1 (all rows in-org) AND stays walled by Layer 0 +// (I7). LOAD-BEARING: a future "posture convergence" cleanup MUST keep this +// green or it silently deletes the auditor pattern; +// 3. I7 — the scope axis never crosses a boundary the posture axis has not +// opened (the holder cannot read/write/insert cross-tenant). +describe("ADR-0099 P2′ — per-object super-bit is the Layer 1 scope axis (#3211 M3′)", () => { + const evaluator = new PermissionEvaluator(); + const byName = (...names: string[]): PermissionSet[] => + ALL_SETS.filter((ps) => names.includes(ps.name)); + const tenantPlus = (evidence: { isPlatformAdmin: boolean; isTenantAdmin: boolean }): boolean => + POSTURE_RANK[derivePosture(evidence)] >= POSTURE_RANK.TENANT_ADMIN; + + // ── Seeded-face agreement ───────────────────────────────────────────────── + // On the seeded surface the two axes coincide: the ONLY seeded sets granting a + // super-bit are admin_full_access + organization_admin, both of which resolve + // to posture >= TENANT_ADMIN. So "holds the super-bit" and "posture >= + // TENANT_ADMIN" agree for every seeded principal — which is exactly why the + // seeded matrix never moved under P1, and why the collapse *looked* safe until + // the delegation case (below) showed the axes are not the same fact. + const SEEDED = [ + { shape: 'admin_full_access holder', sets: byName('admin_full_access', 'member_default'), evidence: { isPlatformAdmin: true, isTenantAdmin: false } }, + { shape: 'organization_admin holder', sets: byName('organization_admin', 'member_default'), evidence: { isPlatformAdmin: false, isTenantAdmin: true } }, + { shape: 'baseline member', sets: byName('member_default'), evidence: { isPlatformAdmin: false, isTenantAdmin: false } }, + ] as const; + + it.each(SEEDED)('[seeded agreement] $shape: holds Layer 1 super-bit ⟺ posture ≥ TENANT_ADMIN', ({ sets, evidence }) => { + const holdsReadBit = evaluator.hasSuperuserReadBypass('crm_secret', sets as PermissionSet[], { isPrivate: true }); + expect(holdsReadBit).toBe(tenantPlus(evidence)); + }); + + // ── The delegation cell — LOAD-BEARING (the auditor pattern) ─────────────── + // A MEMBER holding a per-object view/modify-all on ONE private object. Posture + // is MEMBER, so the two axes DIVERGE here (super-bit true, posture < TENANT_ADMIN) + // — the case the original P2 would have broken. It must: short-circuit Layer 1 + // (see every row IN-ORG) yet stay walled by Layer 0 (org-1 only). + const auditor = { + userId: 'auditor', tenantId: 'org-1', + positions: ['org_member'], permissions: ['invoice_auditor'], + posture: 'MEMBER', + }; + + it('[delegation] MEMBER with a delegated per-object view-all sees ALL rows in-org (Layer 1 short-circuit)', async () => { + // Layer 1 short-circuits to null (the super-bit); Layer 0 AND-composes the + // org wall (posture is not PLATFORM_ADMIN) → effective read = the org wall. + expect(await readFilter(OBJECTS.private_obj, auditor)).toEqual({ organization_id: 'org-1' }); + }); + + it('[delegation / I7] the holder stays tenant-walled on write (Layer 0 pre-image, not a full BYPASS)', async () => { + // modifyAllRecords short-circuits Layer 1, but isPlatformAdmin=false so Layer 0 + // is the write pre-image: the holder is boxed to org-1 (contrast: a true + // platform admin returns BYPASS here). The scope bit did not cross the boundary. + expect(await writeFilter(OBJECTS.private_obj, auditor)).toEqual([{ organization_id: 'org-1' }]); + }); + + it('[delegation / I7] a supplied cross-tenant organization_id on insert is DENIED for the holder', async () => { + expect(await insertOrg(OBJECTS.private_obj, auditor, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError'); + }); + + // ── Contrast — the bit is a REAL, grantable capability, not conditionally inert ─ + // Without the delegation a plain member cannot even read the private object (the + // wildcard grant does not cover a private posture — ADR-0066 ④). So the per-object + // bit is precisely what unlocks view-all; collapsing the tier onto posture would + // have made writing that bit a silent no-op for a member (the ADR-0049 class). + it('[contrast] a plain member WITHOUT the delegated bit is denied on the private object', async () => { + expect(await readFilter(OBJECTS.private_obj, ROLES.member)).toBe('CRUD_DENY:PermissionDeniedError'); + }); +});