From b9f92bbc02d0095edb3c434bc807daccb2db853f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 14:14:15 +0000 Subject: [PATCH] =?UTF-8?q?test(security):=20ADR-0099=20P0=20=E2=80=94=20p?= =?UTF-8?q?robe=20vs=20carried-rung=20equivalence=20gate;=20accept=20ADR-0?= =?UTF-8?q?099?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the P0 equivalence cells the ADR's phasing prescribed (#3211 M1) in authz-matrix-gate.test.ts: seeded-shape probe/rung equivalence, two adversarial KNOWN DIVERGENCE pins (scoped admin_full_access grant; piecemeal platform-exclusive capability), the I2 nesting and I3 narrowing invariants, posture-blindness staging pins for the P1 flip, and the EXTERNAL dead-branch cell. Extract the platform-admin capability probe as the exported pure hasPlatformAdminCapability (mechanical). Flip ADR-0099 to Accepted (2026-07-18) with an acceptance addendum recording the divergence class and its P1 disposition (rung authority, fail-safe narrowing, adjudicated at #3211 G1). Test-only + docs; zero behavior change. plugin-security: 498 passed. Refs #3211 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DAHp4K7FvyMPBY1DPNkmRu --- .changeset/adr-0099-p0-equivalence-gate.md | 5 + ...e-adjudicated-tiering-and-external-rung.md | 28 ++- .../src/authz-matrix-gate.test.ts | 164 +++++++++++++++++- .../plugin-security/src/security-plugin.ts | 20 ++- 4 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 .changeset/adr-0099-p0-equivalence-gate.md diff --git a/.changeset/adr-0099-p0-equivalence-gate.md b/.changeset/adr-0099-p0-equivalence-gate.md new file mode 100644 index 0000000000..8240d1676c --- /dev/null +++ b/.changeset/adr-0099-p0-equivalence-gate.md @@ -0,0 +1,5 @@ +--- +'@objectstack/plugin-security': patch +--- + +ADR-0099 P0: land the probe-vs-carried-rung equivalence gate in the authz matrix (`authz-matrix-gate.test.ts`) — seeded-shape equivalence cells, two adversarial `KNOWN DIVERGENCE` pins (scoped `admin_full_access` grant; piecemeal platform-exclusive capability), the I2 nesting and I3 narrowing invariant cells, posture-blindness staging pins for the P1 flip, and the EXTERNAL dead-branch cell. Extracts the platform-admin capability probe as the exported pure `hasPlatformAdminCapability` (mechanical, behavior unchanged). Test-only gate; the ADR-0099 P1 flip lands behind it (#3211). 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 91b12511f2..d8957bc0bb 100644 --- a/docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md +++ b/docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md @@ -1,10 +1,10 @@ # ADR-0099: Posture-Adjudicated Tiering — one axis for tier decisions, the EXTERNAL rung's enforcement path, no explicit deny -**Status**: Proposed (2026-07-17) +**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 **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. -**Tracking**: framework#2920 (B-track follow-through) · #2947→#2956 (posture carried on `ExecutionContext` — the unblocking prerequisite) · #2946 Finding 2 (the divergence class this ADR closes) +**Tracking**: framework#3211 (P0–P2 implementation) · framework#2920 (B-track follow-through, closed) · #2947→#2956 (posture carried on `ExecutionContext` — the unblocking prerequisite) · #2946 Finding 2 (the divergence class this ADR closes) **Consumers**: `@objectstack/plugin-security` (Layer 0 exemption gate, superuser bypass, explain), `@objectstack/core` (`resolve-authz-context`, `posture-ladder`), `@objectstack/plugin-sharing` (EXTERNAL rung, when it activates), portal/external-identity work (ADR-0090 follow-up #6) --- @@ -309,3 +309,27 @@ Strictly serial, each step behind the matrix: preference), `packages/spec/src/security/sharing.zod.ts:104` (`owner`-rule experimental marker), `packages/spec/src/kernel/execution-context.zod.ts` (`posture` field). + +## Acceptance addendum (2026-07-18) + +Accepted by the maintainer on 2026-07-18. The P0 equivalence gate landed with +this acceptance (#3211 M1, `authz-matrix-gate.test.ts`), and its cells sharpen +D1's behavior contract with one finding: + +- **Seeded shapes verify equivalent.** For every seeded principal shape + (unscoped `admin_full_access` holder, `organization_admin`, baseline member, + the W1 permissive-policy fixture) the capability probe and the carried rung + agree — D1's behavior-preserving claim holds across the seeded surface. +- **Two adversarial shapes diverge (probe `true` / rung `MEMBER`),** pinned as + `KNOWN DIVERGENCE` cells: (a) a **scoped** `admin_full_access` grant — the + set's contents merge (probe sees the platform capabilities) but the resolver + counts only the *unscoped* grant (#2949 rule); (b) a custom set granting a + platform-exclusive capability piecemeal (e.g. `studio.access`) without the + unscoped grant. For these shapes the P1 flip is a **fail-safe narrowing** + (rung ⊆ probe by seed construction — the I3 cell asserts the implication, so + the flip can only withhold an exemption, never widen one), adjudicated + per delta at #3211 G1 with a release-notes callout, and recoverable by + granting the unscoped `admin_full_access`. This is the equivalence gate + 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. 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 eabfbeddba..7dc3ad6c4c 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,9 @@ // objects) and is annotated inline. import { describe, it, expect, vi } from 'vitest'; -import { SecurityPlugin } from './security-plugin.js'; +import { derivePosture } from '@objectstack/core'; +import { SecurityPlugin, hasPlatformAdminCapability } from './security-plugin.js'; +import { PermissionEvaluator } from './permission-evaluator.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; import { RLS_DENY_FILTER } from './rls-compiler.js'; import type { PermissionSet } from '@objectstack/spec/security'; @@ -445,3 +447,163 @@ describe('authz Layer-0 matrix gate — ADR-0095 D1 (post-extraction)', () => { expect(await writeFilter(single, ROLES.member)).toEqual([{ created_by: 'u1' }]); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// ADR-0099 P0 — probe vs carried-rung equivalence gate (#3211 M1) +// ═══════════════════════════════════════════════════════════════════════════ +// +// ADR-0099 D1 makes the CARRIED `ctx.posture` rung the single tier-adjudication +// input; the capability probe (`hasPlatformAdminCapability`, today's Layer 0 +// exemption evidence) demotes to a resolver-less fallback that MAY ONLY NARROW. +// The P1 flip lands behind THIS gate: for every seeded principal shape the two +// derivations must agree; where they disagree the cell is pinned as a +// KNOWN DIVERGENCE and the flip is a per-delta-adjudicated NARROWING (#3211 G1). +// +// The two evidence sources are NOT the same question: +// probe — does the RESOLVED SET CONTENT carry a platform-exclusive capability? +// rung — does the principal hold an UNSCOPED `admin_full_access` GRANT +// (`sys_user_permission_set` row with organization_id == null — +// the #2949 rule, `resolve-authz-context.ts` step 6d)? +// Seeded shapes agree (the unscoped grant is the only seeded path to those +// capabilities). Adversarial shapes below document the divergence class. +describe('ADR-0099 P0 — probe vs carried-rung equivalence (#3211 M1)', () => { + const evaluator = new PermissionEvaluator(); + const byName = (...names: string[]): PermissionSet[] => + ALL_SETS.filter((ps) => names.includes(ps.name)); + const probe = (sets: PermissionSet[]): boolean => + hasPlatformAdminCapability(evaluator.getSystemPermissions(sets)); + + // Every seeded principal shape: the resolved sets the middleware would see, + // and the grant evidence the resolver would see (per resolve-authz-context 6d). + const SEEDED_SHAPES = [ + { + shape: 'platform_admin — UNSCOPED admin_full_access grant', + sets: byName('admin_full_access', 'member_default'), + evidence: { isPlatformAdmin: true, isTenantAdmin: false }, + }, + { + shape: 'org_admin — organization_admin capability, no platform grant', + sets: byName('organization_admin', 'member_default'), + evidence: { isPlatformAdmin: false, isTenantAdmin: true }, + }, + { + shape: 'member — additive baseline only', + sets: byName('member_default'), + evidence: { isPlatformAdmin: false, isTenantAdmin: false }, + }, + { + shape: 'permissive-business-RLS holder (W1 fixture set)', + sets: byName('public_reader', 'member_default'), + evidence: { isPlatformAdmin: false, isTenantAdmin: false }, + }, + ] as const; + + it.each(SEEDED_SHAPES)('[D1 equivalence] $shape: probe agrees with the carried rung', ({ sets, evidence }) => { + expect(probe(sets as PermissionSet[])).toBe(derivePosture(evidence) === 'PLATFORM_ADMIN'); + }); + + // ── KNOWN DIVERGENCE (a) — scoped admin_full_access grant ───────────────── + // A grant of `admin_full_access` SCOPED to one org (organization_id != null) + // merges the set's CONTENTS into the principal's resolved sets (the probe's + // input is identical to a true platform admin's), but the resolver does NOT + // count a scoped grant as platform-admin evidence (#2949) → rung = MEMBER. + // TODAY: probe true → such a principal crosses the Layer 0 wall wherever the + // object posture permits. AFTER P1: rung authoritative → walled to its org. + // This is the P1 NARROWING delta — adjudicated at #3211 G1, release-noted, + // and recoverable by granting the UNSCOPED admin_full_access instead. + it('[KNOWN DIVERGENCE (a)] scoped admin_full_access grant: probe=true, rung=MEMBER', () => { + const sets = byName('admin_full_access', 'member_default'); // contents identical to the unscoped holder + const evidence = { isPlatformAdmin: false, isTenantAdmin: false }; // scoped grant → not counted (#2949) + expect(probe(sets)).toBe(true); + expect(derivePosture(evidence)).toBe('MEMBER'); + }); + + // ── KNOWN DIVERGENCE (b) — piecemeal platform-exclusive capability ──────── + // An admin-authored custom set granting a platform-exclusive capability + // (`studio.access` here) WITHOUT the unscoped admin_full_access grant: + // probe true / rung MEMBER. Same P1 narrowing class as (a). Note the probe + // needs the superuser bit TOO before any exemption fires — this shape only + // reaches the wall if it ALSO composes viewAll/modifyAll from some set. + it('[KNOWN DIVERGENCE (b)] piecemeal studio.access without the unscoped grant: probe=true, rung=MEMBER', () => { + const studioOps: PermissionSet = { + name: 'studio_ops', + label: 'Studio Ops (piecemeal platform capability)', + objects: {}, + systemPermissions: ['studio.access'], + } as any; + expect(probe([studioOps])).toBe(true); + expect(derivePosture({ isPlatformAdmin: false, isTenantAdmin: false })).toBe('MEMBER'); + }); + + // ── [I3 — fallback may only narrow] rung ⊆ probe, never the reverse ─────── + // The unscoped admin_full_access grant carries the platform-exclusive caps by + // seed definition, so rung=PLATFORM_ADMIN ⇒ probe=true over every shape above + // (seeded AND adversarial). The demoted probe can therefore only WIDEN relative + // to the rung — meaning the P1 flip (probe → rung) can only NARROW. The + // reverse implication is exactly what diverges (cells (a)/(b)). + it('[I3] rung=PLATFORM_ADMIN implies probe=true for every shape (flip can only narrow)', () => { + for (const { sets, evidence } of SEEDED_SHAPES) { + if (derivePosture(evidence) === 'PLATFORM_ADMIN') { + expect(probe(sets as PermissionSet[])).toBe(true); + } + } + }); + + // ── [I2 — nesting at the adjudication site] ─────────────────────────────── + // ADR-0095 D2's nesting invariant, asserted over the LOCKED effective-filter + // matrix (not only at derivation): within each object column, visibility never + // widens as the ladder descends. Rank: all-rows (null/BYPASS) > org-scoped > + // owner/self-scoped > denied. Ties are allowed (equal visibility), widening is not. + it('[I2] visibility is monotonically non-widening down the ladder, per object column', () => { + const rank = (cell: unknown): number => { + const s = JSON.stringify(cell); + if (cell === null || s.includes('BYPASS')) return 3; // all rows + if (s.includes('CRUD_DENY') || s.includes(DENY)) return 0; // denied + if (s.includes('created_by') || s.includes('"id"')) return 1; // owner/self-scoped + return 2; // org-scoped + }; + const LADDER_ORDER = ['platform_admin', 'org_admin', 'member'] as const; + for (const [oName, col] of Object.entries(EXPECTED_MATRIX)) { + for (const side of ['read', 'write'] as const) { + for (let i = 1; i < LADDER_ORDER.length; i++) { + const higher = rank(col[LADDER_ORDER[i - 1]][side]); + const lower = rank(col[LADDER_ORDER[i]][side]); + expect(higher, `${oName}.${side}: ${LADDER_ORDER[i - 1]} ⊇ ${LADDER_ORDER[i]}`).toBeGreaterThanOrEqual(lower); + } + } + } + }); + + // ── [I4 staging — the P1 flip target, pinned] ───────────────────────────── + // TODAY the Layer 0 exemption is POSTURE-BLIND: a carried MEMBER rung on the + // ExecutionContext does not wall a scoped-grant holder (the probe path never + // consults it), and a carried PLATFORM_ADMIN rung is not required by a true + // admin. Both cells pin the PRE-FLIP behavior verbatim; under P1 the first + // cell FLIPS to the walled filter ({organization_id:'org-1'}) as the + // adjudicated narrowing, and the second MUST NOT change (rung authoritative). + it('[I4 staging / P1 flip target] carried MEMBER rung does NOT yet wall a scoped-grant holder (posture-blind today)', async () => { + const scopedHolder = { + userId: 'scoped-admin', tenantId: 'org-1', + positions: ['org_member'], permissions: ['admin_full_access'], + posture: 'MEMBER', // carried rung (what resolve-authz-context derives for a SCOPED grant) + }; + expect(await readFilter(OBJECTS.private_obj, scopedHolder)).toBeNull(); // ← flips to {organization_id:'org-1'} at P1 + }); + it('[I4 staging / P1 invariant] a true platform admin with the carried PLATFORM_ADMIN rung stays exempt', async () => { + const carriedAdmin = { ...ROLES.platform_admin, posture: 'PLATFORM_ADMIN' }; + expect(await readFilter(OBJECTS.private_obj, carriedAdmin)).toBeNull(); // ← must NOT change at P1 + }); + + // ── [D3 dead branch] capability evidence can never derive EXTERNAL ──────── + // The EXTERNAL rung activates only from `audience:'external'` (ADR-0090 D10) + // when the portal principal type ships; no combination of capability-grant + // evidence may reach it. (Full EXTERNAL × layer share-fixture cells live in + // posture-ladder.test.ts — the semantics lock — and extend at P3.) + it('[D3 dead branch] no capability-grant evidence derives EXTERNAL', () => { + for (const isPlatformAdmin of [true, false]) { + for (const isTenantAdmin of [true, false]) { + expect(derivePosture({ isPlatformAdmin, isTenantAdmin })).not.toBe('EXTERNAL'); + } + } + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index c1bbc00293..96bc13393a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -83,6 +83,20 @@ const PLATFORM_ADMIN_ONLY_CAPABILITIES: readonly string[] = [ 'manage_users', ]; +/** + * [ADR-0099 P0] Pure form of the platform-admin capability probe: does the held + * capability set contain any platform-EXCLUSIVE capability? Exported so the + * authz matrix gate (`authz-matrix-gate.test.ts`) can assert probe-vs-carried-rung + * equivalence against the EXACT predicate enforcement runs — the P0 gate the + * ADR-0099 P1 flip lands behind. + */ +export function hasPlatformAdminCapability(held: ReadonlySet): boolean { + for (const cap of PLATFORM_ADMIN_ONLY_CAPABILITIES) { + if (held.has(cap)) return true; + } + return false; +} + /** * [ADR-0066 D3/⑤] Object `requiredPermissions` normalized into per-CRUD buckets. * `all` holds capabilities required for EVERY operation (the `string[]` form); @@ -2388,11 +2402,7 @@ export class SecurityPlugin implements Plugin { * the ONLY signal permitted to cross the Layer 0 tenant wall. */ private hasPlatformAdminPosture(permissionSets: PermissionSet[]): boolean { - const held = this.permissionEvaluator.getSystemPermissions(permissionSets); - for (const cap of PLATFORM_ADMIN_ONLY_CAPABILITIES) { - if (held.has(cap)) return true; - } - return false; + return hasPlatformAdminCapability(this.permissionEvaluator.getSystemPermissions(permissionSets)); } private async computeLayeredRlsFilter(