Skip to content

Commit 2f3c641

Browse files
os-zhuangclaude
andauthored
test(security): ADR-0099 P0 — probe vs carried-rung equivalence gate; accept ADR-0099 (#3218)
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 Claude-Session: https://claude.ai/code/session_01DAHp4K7FvyMPBY1DPNkmRu Co-authored-by: Claude <noreply@anthropic.com>
1 parent 369eb6e commit 2f3c641

4 files changed

Lines changed: 209 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
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).

docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# ADR-0099: Posture-Adjudicated Tiering — one axis for tier decisions, the EXTERNAL rung's enforcement path, no explicit deny
22

3-
**Status**: Proposed (2026-07-17)
3+
**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
44
**Deciders**: ObjectStack Protocol Architects
55
**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)
66
**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.
7-
**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)
7+
**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)
88
**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)
99

1010
---
@@ -309,3 +309,27 @@ Strictly serial, each step behind the matrix:
309309
preference), `packages/spec/src/security/sharing.zod.ts:104` (`owner`-rule
310310
experimental marker), `packages/spec/src/kernel/execution-context.zod.ts`
311311
(`posture` field).
312+
313+
## Acceptance addendum (2026-07-18)
314+
315+
Accepted by the maintainer on 2026-07-18. The P0 equivalence gate landed with
316+
this acceptance (#3211 M1, `authz-matrix-gate.test.ts`), and its cells sharpen
317+
D1's behavior contract with one finding:
318+
319+
- **Seeded shapes verify equivalent.** For every seeded principal shape
320+
(unscoped `admin_full_access` holder, `organization_admin`, baseline member,
321+
the W1 permissive-policy fixture) the capability probe and the carried rung
322+
agree — D1's behavior-preserving claim holds across the seeded surface.
323+
- **Two adversarial shapes diverge (probe `true` / rung `MEMBER`),** pinned as
324+
`KNOWN DIVERGENCE` cells: (a) a **scoped** `admin_full_access` grant — the
325+
set's contents merge (probe sees the platform capabilities) but the resolver
326+
counts only the *unscoped* grant (#2949 rule); (b) a custom set granting a
327+
platform-exclusive capability piecemeal (e.g. `studio.access`) without the
328+
unscoped grant. For these shapes the P1 flip is a **fail-safe narrowing**
329+
(rung ⊆ probe by seed construction — the I3 cell asserts the implication, so
330+
the flip can only withhold an exemption, never widen one), adjudicated
331+
per delta at #3211 G1 with a release-notes callout, and recoverable by
332+
granting the unscoped `admin_full_access`. This is the equivalence gate
333+
doing its job as specified ("any cell where they disagree … must be
334+
resolved before the flip") — the resolution is D1's rung authority, not a
335+
probe repair.

packages/plugins/plugin-security/src/authz-matrix-gate.test.ts

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
// objects) and is annotated inline.
3333

3434
import { describe, it, expect, vi } from 'vitest';
35-
import { SecurityPlugin } from './security-plugin.js';
35+
import { derivePosture } from '@objectstack/core';
36+
import { SecurityPlugin, hasPlatformAdminCapability } from './security-plugin.js';
37+
import { PermissionEvaluator } from './permission-evaluator.js';
3638
import { defaultPermissionSets } from './objects/default-permission-sets.js';
3739
import { RLS_DENY_FILTER } from './rls-compiler.js';
3840
import type { PermissionSet } from '@objectstack/spec/security';
@@ -445,3 +447,163 @@ describe('authz Layer-0 matrix gate — ADR-0095 D1 (post-extraction)', () => {
445447
expect(await writeFilter(single, ROLES.member)).toEqual([{ created_by: 'u1' }]);
446448
});
447449
});
450+
451+
// ═══════════════════════════════════════════════════════════════════════════
452+
// ADR-0099 P0 — probe vs carried-rung equivalence gate (#3211 M1)
453+
// ═══════════════════════════════════════════════════════════════════════════
454+
//
455+
// ADR-0099 D1 makes the CARRIED `ctx.posture` rung the single tier-adjudication
456+
// input; the capability probe (`hasPlatformAdminCapability`, today's Layer 0
457+
// exemption evidence) demotes to a resolver-less fallback that MAY ONLY NARROW.
458+
// The P1 flip lands behind THIS gate: for every seeded principal shape the two
459+
// derivations must agree; where they disagree the cell is pinned as a
460+
// KNOWN DIVERGENCE and the flip is a per-delta-adjudicated NARROWING (#3211 G1).
461+
//
462+
// The two evidence sources are NOT the same question:
463+
// probe — does the RESOLVED SET CONTENT carry a platform-exclusive capability?
464+
// rung — does the principal hold an UNSCOPED `admin_full_access` GRANT
465+
// (`sys_user_permission_set` row with organization_id == null —
466+
// the #2949 rule, `resolve-authz-context.ts` step 6d)?
467+
// Seeded shapes agree (the unscoped grant is the only seeded path to those
468+
// capabilities). Adversarial shapes below document the divergence class.
469+
describe('ADR-0099 P0 — probe vs carried-rung equivalence (#3211 M1)', () => {
470+
const evaluator = new PermissionEvaluator();
471+
const byName = (...names: string[]): PermissionSet[] =>
472+
ALL_SETS.filter((ps) => names.includes(ps.name));
473+
const probe = (sets: PermissionSet[]): boolean =>
474+
hasPlatformAdminCapability(evaluator.getSystemPermissions(sets));
475+
476+
// Every seeded principal shape: the resolved sets the middleware would see,
477+
// and the grant evidence the resolver would see (per resolve-authz-context 6d).
478+
const SEEDED_SHAPES = [
479+
{
480+
shape: 'platform_admin — UNSCOPED admin_full_access grant',
481+
sets: byName('admin_full_access', 'member_default'),
482+
evidence: { isPlatformAdmin: true, isTenantAdmin: false },
483+
},
484+
{
485+
shape: 'org_admin — organization_admin capability, no platform grant',
486+
sets: byName('organization_admin', 'member_default'),
487+
evidence: { isPlatformAdmin: false, isTenantAdmin: true },
488+
},
489+
{
490+
shape: 'member — additive baseline only',
491+
sets: byName('member_default'),
492+
evidence: { isPlatformAdmin: false, isTenantAdmin: false },
493+
},
494+
{
495+
shape: 'permissive-business-RLS holder (W1 fixture set)',
496+
sets: byName('public_reader', 'member_default'),
497+
evidence: { isPlatformAdmin: false, isTenantAdmin: false },
498+
},
499+
] as const;
500+
501+
it.each(SEEDED_SHAPES)('[D1 equivalence] $shape: probe agrees with the carried rung', ({ sets, evidence }) => {
502+
expect(probe(sets as PermissionSet[])).toBe(derivePosture(evidence) === 'PLATFORM_ADMIN');
503+
});
504+
505+
// ── KNOWN DIVERGENCE (a) — scoped admin_full_access grant ─────────────────
506+
// A grant of `admin_full_access` SCOPED to one org (organization_id != null)
507+
// merges the set's CONTENTS into the principal's resolved sets (the probe's
508+
// input is identical to a true platform admin's), but the resolver does NOT
509+
// count a scoped grant as platform-admin evidence (#2949) → rung = MEMBER.
510+
// TODAY: probe true → such a principal crosses the Layer 0 wall wherever the
511+
// object posture permits. AFTER P1: rung authoritative → walled to its org.
512+
// This is the P1 NARROWING delta — adjudicated at #3211 G1, release-noted,
513+
// and recoverable by granting the UNSCOPED admin_full_access instead.
514+
it('[KNOWN DIVERGENCE (a)] scoped admin_full_access grant: probe=true, rung=MEMBER', () => {
515+
const sets = byName('admin_full_access', 'member_default'); // contents identical to the unscoped holder
516+
const evidence = { isPlatformAdmin: false, isTenantAdmin: false }; // scoped grant → not counted (#2949)
517+
expect(probe(sets)).toBe(true);
518+
expect(derivePosture(evidence)).toBe('MEMBER');
519+
});
520+
521+
// ── KNOWN DIVERGENCE (b) — piecemeal platform-exclusive capability ────────
522+
// An admin-authored custom set granting a platform-exclusive capability
523+
// (`studio.access` here) WITHOUT the unscoped admin_full_access grant:
524+
// probe true / rung MEMBER. Same P1 narrowing class as (a). Note the probe
525+
// needs the superuser bit TOO before any exemption fires — this shape only
526+
// reaches the wall if it ALSO composes viewAll/modifyAll from some set.
527+
it('[KNOWN DIVERGENCE (b)] piecemeal studio.access without the unscoped grant: probe=true, rung=MEMBER', () => {
528+
const studioOps: PermissionSet = {
529+
name: 'studio_ops',
530+
label: 'Studio Ops (piecemeal platform capability)',
531+
objects: {},
532+
systemPermissions: ['studio.access'],
533+
} as any;
534+
expect(probe([studioOps])).toBe(true);
535+
expect(derivePosture({ isPlatformAdmin: false, isTenantAdmin: false })).toBe('MEMBER');
536+
});
537+
538+
// ── [I3 — fallback may only narrow] rung ⊆ probe, never the reverse ───────
539+
// The unscoped admin_full_access grant carries the platform-exclusive caps by
540+
// seed definition, so rung=PLATFORM_ADMIN ⇒ probe=true over every shape above
541+
// (seeded AND adversarial). The demoted probe can therefore only WIDEN relative
542+
// to the rung — meaning the P1 flip (probe → rung) can only NARROW. The
543+
// reverse implication is exactly what diverges (cells (a)/(b)).
544+
it('[I3] rung=PLATFORM_ADMIN implies probe=true for every shape (flip can only narrow)', () => {
545+
for (const { sets, evidence } of SEEDED_SHAPES) {
546+
if (derivePosture(evidence) === 'PLATFORM_ADMIN') {
547+
expect(probe(sets as PermissionSet[])).toBe(true);
548+
}
549+
}
550+
});
551+
552+
// ── [I2 — nesting at the adjudication site] ───────────────────────────────
553+
// ADR-0095 D2's nesting invariant, asserted over the LOCKED effective-filter
554+
// matrix (not only at derivation): within each object column, visibility never
555+
// widens as the ladder descends. Rank: all-rows (null/BYPASS) > org-scoped >
556+
// owner/self-scoped > denied. Ties are allowed (equal visibility), widening is not.
557+
it('[I2] visibility is monotonically non-widening down the ladder, per object column', () => {
558+
const rank = (cell: unknown): number => {
559+
const s = JSON.stringify(cell);
560+
if (cell === null || s.includes('BYPASS')) return 3; // all rows
561+
if (s.includes('CRUD_DENY') || s.includes(DENY)) return 0; // denied
562+
if (s.includes('created_by') || s.includes('"id"')) return 1; // owner/self-scoped
563+
return 2; // org-scoped
564+
};
565+
const LADDER_ORDER = ['platform_admin', 'org_admin', 'member'] as const;
566+
for (const [oName, col] of Object.entries(EXPECTED_MATRIX)) {
567+
for (const side of ['read', 'write'] as const) {
568+
for (let i = 1; i < LADDER_ORDER.length; i++) {
569+
const higher = rank(col[LADDER_ORDER[i - 1]][side]);
570+
const lower = rank(col[LADDER_ORDER[i]][side]);
571+
expect(higher, `${oName}.${side}: ${LADDER_ORDER[i - 1]}${LADDER_ORDER[i]}`).toBeGreaterThanOrEqual(lower);
572+
}
573+
}
574+
}
575+
});
576+
577+
// ── [I4 staging — the P1 flip target, pinned] ─────────────────────────────
578+
// TODAY the Layer 0 exemption is POSTURE-BLIND: a carried MEMBER rung on the
579+
// ExecutionContext does not wall a scoped-grant holder (the probe path never
580+
// consults it), and a carried PLATFORM_ADMIN rung is not required by a true
581+
// admin. Both cells pin the PRE-FLIP behavior verbatim; under P1 the first
582+
// cell FLIPS to the walled filter ({organization_id:'org-1'}) as the
583+
// adjudicated narrowing, and the second MUST NOT change (rung authoritative).
584+
it('[I4 staging / P1 flip target] carried MEMBER rung does NOT yet wall a scoped-grant holder (posture-blind today)', async () => {
585+
const scopedHolder = {
586+
userId: 'scoped-admin', tenantId: 'org-1',
587+
positions: ['org_member'], permissions: ['admin_full_access'],
588+
posture: 'MEMBER', // carried rung (what resolve-authz-context derives for a SCOPED grant)
589+
};
590+
expect(await readFilter(OBJECTS.private_obj, scopedHolder)).toBeNull(); // ← flips to {organization_id:'org-1'} at P1
591+
});
592+
it('[I4 staging / P1 invariant] a true platform admin with the carried PLATFORM_ADMIN rung stays exempt', async () => {
593+
const carriedAdmin = { ...ROLES.platform_admin, posture: 'PLATFORM_ADMIN' };
594+
expect(await readFilter(OBJECTS.private_obj, carriedAdmin)).toBeNull(); // ← must NOT change at P1
595+
});
596+
597+
// ── [D3 dead branch] capability evidence can never derive EXTERNAL ────────
598+
// The EXTERNAL rung activates only from `audience:'external'` (ADR-0090 D10)
599+
// when the portal principal type ships; no combination of capability-grant
600+
// evidence may reach it. (Full EXTERNAL × layer share-fixture cells live in
601+
// posture-ladder.test.ts — the semantics lock — and extend at P3.)
602+
it('[D3 dead branch] no capability-grant evidence derives EXTERNAL', () => {
603+
for (const isPlatformAdmin of [true, false]) {
604+
for (const isTenantAdmin of [true, false]) {
605+
expect(derivePosture({ isPlatformAdmin, isTenantAdmin })).not.toBe('EXTERNAL');
606+
}
607+
}
608+
});
609+
});

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,20 @@ const PLATFORM_ADMIN_ONLY_CAPABILITIES: readonly string[] = [
8383
'manage_users',
8484
];
8585

86+
/**
87+
* [ADR-0099 P0] Pure form of the platform-admin capability probe: does the held
88+
* capability set contain any platform-EXCLUSIVE capability? Exported so the
89+
* authz matrix gate (`authz-matrix-gate.test.ts`) can assert probe-vs-carried-rung
90+
* equivalence against the EXACT predicate enforcement runs — the P0 gate the
91+
* ADR-0099 P1 flip lands behind.
92+
*/
93+
export function hasPlatformAdminCapability(held: ReadonlySet<string>): boolean {
94+
for (const cap of PLATFORM_ADMIN_ONLY_CAPABILITIES) {
95+
if (held.has(cap)) return true;
96+
}
97+
return false;
98+
}
99+
86100
/**
87101
* [ADR-0066 D3/⑤] Object `requiredPermissions` normalized into per-CRUD buckets.
88102
* `all` holds capabilities required for EVERY operation (the `string[]` form);
@@ -2388,11 +2402,7 @@ export class SecurityPlugin implements Plugin {
23882402
* the ONLY signal permitted to cross the Layer 0 tenant wall.
23892403
*/
23902404
private hasPlatformAdminPosture(permissionSets: PermissionSet[]): boolean {
2391-
const held = this.permissionEvaluator.getSystemPermissions(permissionSets);
2392-
for (const cap of PLATFORM_ADMIN_ONLY_CAPABILITIES) {
2393-
if (held.has(cap)) return true;
2394-
}
2395-
return false;
2405+
return hasPlatformAdminCapability(this.permissionEvaluator.getSystemPermissions(permissionSets));
23962406
}
23972407

23982408
private async computeLayeredRlsFilter(

0 commit comments

Comments
 (0)