|
32 | 32 | // objects) and is annotated inline. |
33 | 33 |
|
34 | 34 | 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'; |
36 | 38 | import { defaultPermissionSets } from './objects/default-permission-sets.js'; |
37 | 39 | import { RLS_DENY_FILTER } from './rls-compiler.js'; |
38 | 40 | import type { PermissionSet } from '@objectstack/spec/security'; |
@@ -445,3 +447,163 @@ describe('authz Layer-0 matrix gate — ADR-0095 D1 (post-extraction)', () => { |
445 | 447 | expect(await writeFilter(single, ROLES.member)).toEqual([{ created_by: 'u1' }]); |
446 | 448 | }); |
447 | 449 | }); |
| 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 | +}); |
0 commit comments