Skip to content

Commit cc8811c

Browse files
committed
test(security): ADR-0099 P2′ — pin the per-object super-bit as the Layer 1 scope axis (#3211 M3′)
The two-axis Amendment's documentation cells (zero behavior change). The original P2 (collapse Layer 1 tier onto posture) was rejected; these cells pin the corrected model in CI: - seeded-face agreement: every seeded super-bit holder is already >= TENANT_ADMIN, so the two axes coincide on the seeded surface (why the collapse looked safe until the delegation case); - the delegation cell (LOAD-BEARING): a MEMBER holding a delegated per-object viewAllRecords/modifyAllRecords on a private object short-circuits Layer 1 (sees all rows in-org) AND stays walled by Layer 0 (org-1 only) — the auditor pattern; a future posture-convergence cleanup must keep this green or it silently deletes the capability; - I7: the holder cannot read/write/insert cross-tenant (the scope axis never crosses a boundary posture has not opened); - contrast: a plain member without the bit is denied on the private object, so the bit is a real grantable capability, not conditionally inert (the ADR-0049 class the collapse would have introduced). New fixture invoice_auditor (delegated per-object super-bit). G2 audit: single authority per axis holds — boundary fact derived once (core resolver) + consumed at the Layer 0 gate (fallback probe narrows only); scope fact evaluated only via hasSuperuser*Bypass. plugin-security 510 passed; tsc + check:role-word + check:doc-authoring clean; plugin-security build green. Refs #3211 - ADR-0099 two-axis Amendment (#3245) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DAHp4K7FvyMPBY1DPNkmRu
1 parent 3263400 commit cc8811c

1 file changed

Lines changed: 98 additions & 2 deletions

File tree

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

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
// objects) and is annotated inline.
3333

3434
import { describe, it, expect, vi } from 'vitest';
35-
import { derivePosture } from '@objectstack/core';
35+
import { derivePosture, POSTURE_RANK } from '@objectstack/core';
3636
import { SecurityPlugin, hasPlatformAdminCapability } from './security-plugin.js';
3737
import { PermissionEvaluator } from './permission-evaluator.js';
3838
import { defaultPermissionSets } from './objects/default-permission-sets.js';
@@ -53,7 +53,19 @@ const publicReader: PermissionSet = {
5353
],
5454
} as any;
5555

56-
const ALL_SETS: PermissionSet[] = [...defaultPermissionSets, publicReader];
56+
// [ADR-0099 two-axis Amendment] A per-object super-bit DELEGATED to a non-admin
57+
// position — the auditor pattern (Salesforce object-level View All / Modify All):
58+
// "read (and write) every row of THIS ONE private object", granted without any
59+
// admin stature. This is the scope-axis primitive posture cannot represent (it is
60+
// per-principal × per-object); the P2′ cells below pin that it short-circuits
61+
// Layer 1 for the holder AND stays walled by Layer 0 (invariant I7).
62+
const invoiceAuditor: PermissionSet = {
63+
name: 'invoice_auditor',
64+
label: 'Invoice Auditor (delegated per-object view/modify all)',
65+
objects: { crm_secret: { allowRead: true, allowCreate: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: true } },
66+
} as any;
67+
68+
const ALL_SETS: PermissionSet[] = [...defaultPermissionSets, publicReader, invoiceAuditor];
5769
const DENY = RLS_DENY_FILTER.id; // the fail-closed sentinel's marker value
5870

5971
// ── Minimal middleware harness ──────────────────────────────────────────────
@@ -676,3 +688,87 @@ describe('ADR-0099 P1 — Layer 0 exemption reads the carried rung (#3211 M2)',
676688
expect(authzWarn).toHaveLength(0);
677689
});
678690
});
691+
692+
// ═══════════════════════════════════════════════════════════════════════════
693+
// ADR-0099 P2′ — two-axis: per-object super-bit is the Layer 1 scope,
694+
// posture is only the boundary (#3211 M3′)
695+
// ═══════════════════════════════════════════════════════════════════════════
696+
//
697+
// The two-axis Amendment (2026-07-18): the original P2 (collapse the Layer 1
698+
// tier onto posture) was REJECTED — Layer 1's tier input is the per-object
699+
// super-bit, a per-principal × per-object DELEGATION primitive that posture (one
700+
// rung per principal) structurally cannot carry. Collapsing it would have made a
701+
// declared, grantable bit conditionally inert (the ADR-0049 class) and deleted
702+
// the mainstream "delegate view-all of one object to a non-admin" capability.
703+
//
704+
// These are documentation cells, zero behavior change. They pin:
705+
// 1. the two axes AGREE on the seeded surface (every seeded super-bit holder
706+
// is already >= TENANT_ADMIN — so the seeded face never needed collapsing);
707+
// 2. the DELEGATION cell — a MEMBER holding a delegated per-object super-bit
708+
// short-circuits Layer 1 (all rows in-org) AND stays walled by Layer 0
709+
// (I7). LOAD-BEARING: a future "posture convergence" cleanup MUST keep this
710+
// green or it silently deletes the auditor pattern;
711+
// 3. I7 — the scope axis never crosses a boundary the posture axis has not
712+
// opened (the holder cannot read/write/insert cross-tenant).
713+
describe("ADR-0099 P2′ — per-object super-bit is the Layer 1 scope axis (#3211 M3′)", () => {
714+
const evaluator = new PermissionEvaluator();
715+
const byName = (...names: string[]): PermissionSet[] =>
716+
ALL_SETS.filter((ps) => names.includes(ps.name));
717+
const tenantPlus = (evidence: { isPlatformAdmin: boolean; isTenantAdmin: boolean }): boolean =>
718+
POSTURE_RANK[derivePosture(evidence)] >= POSTURE_RANK.TENANT_ADMIN;
719+
720+
// ── Seeded-face agreement ─────────────────────────────────────────────────
721+
// On the seeded surface the two axes coincide: the ONLY seeded sets granting a
722+
// super-bit are admin_full_access + organization_admin, both of which resolve
723+
// to posture >= TENANT_ADMIN. So "holds the super-bit" and "posture >=
724+
// TENANT_ADMIN" agree for every seeded principal — which is exactly why the
725+
// seeded matrix never moved under P1, and why the collapse *looked* safe until
726+
// the delegation case (below) showed the axes are not the same fact.
727+
const SEEDED = [
728+
{ shape: 'admin_full_access holder', sets: byName('admin_full_access', 'member_default'), evidence: { isPlatformAdmin: true, isTenantAdmin: false } },
729+
{ shape: 'organization_admin holder', sets: byName('organization_admin', 'member_default'), evidence: { isPlatformAdmin: false, isTenantAdmin: true } },
730+
{ shape: 'baseline member', sets: byName('member_default'), evidence: { isPlatformAdmin: false, isTenantAdmin: false } },
731+
] as const;
732+
733+
it.each(SEEDED)('[seeded agreement] $shape: holds Layer 1 super-bit ⟺ posture ≥ TENANT_ADMIN', ({ sets, evidence }) => {
734+
const holdsReadBit = evaluator.hasSuperuserReadBypass('crm_secret', sets as PermissionSet[], { isPrivate: true });
735+
expect(holdsReadBit).toBe(tenantPlus(evidence));
736+
});
737+
738+
// ── The delegation cell — LOAD-BEARING (the auditor pattern) ───────────────
739+
// A MEMBER holding a per-object view/modify-all on ONE private object. Posture
740+
// is MEMBER, so the two axes DIVERGE here (super-bit true, posture < TENANT_ADMIN)
741+
// — the case the original P2 would have broken. It must: short-circuit Layer 1
742+
// (see every row IN-ORG) yet stay walled by Layer 0 (org-1 only).
743+
const auditor = {
744+
userId: 'auditor', tenantId: 'org-1',
745+
positions: ['org_member'], permissions: ['invoice_auditor'],
746+
posture: 'MEMBER',
747+
};
748+
749+
it('[delegation] MEMBER with a delegated per-object view-all sees ALL rows in-org (Layer 1 short-circuit)', async () => {
750+
// Layer 1 short-circuits to null (the super-bit); Layer 0 AND-composes the
751+
// org wall (posture is not PLATFORM_ADMIN) → effective read = the org wall.
752+
expect(await readFilter(OBJECTS.private_obj, auditor)).toEqual({ organization_id: 'org-1' });
753+
});
754+
755+
it('[delegation / I7] the holder stays tenant-walled on write (Layer 0 pre-image, not a full BYPASS)', async () => {
756+
// modifyAllRecords short-circuits Layer 1, but isPlatformAdmin=false so Layer 0
757+
// is the write pre-image: the holder is boxed to org-1 (contrast: a true
758+
// platform admin returns BYPASS here). The scope bit did not cross the boundary.
759+
expect(await writeFilter(OBJECTS.private_obj, auditor)).toEqual([{ organization_id: 'org-1' }]);
760+
});
761+
762+
it('[delegation / I7] a supplied cross-tenant organization_id on insert is DENIED for the holder', async () => {
763+
expect(await insertOrg(OBJECTS.private_obj, auditor, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError');
764+
});
765+
766+
// ── Contrast — the bit is a REAL, grantable capability, not conditionally inert ─
767+
// Without the delegation a plain member cannot even read the private object (the
768+
// wildcard grant does not cover a private posture — ADR-0066 ④). So the per-object
769+
// bit is precisely what unlocks view-all; collapsing the tier onto posture would
770+
// have made writing that bit a silent no-op for a member (the ADR-0049 class).
771+
it('[contrast] a plain member WITHOUT the delegated bit is denied on the private object', async () => {
772+
expect(await readFilter(OBJECTS.private_obj, ROLES.member)).toBe('CRUD_DENY:PermissionDeniedError');
773+
});
774+
});

0 commit comments

Comments
 (0)