Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/adr-0099-p2prime-two-axis-cells.md
Original file line number Diff line number Diff line change
@@ -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.
86 changes: 80 additions & 6 deletions docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
100 changes: 98 additions & 2 deletions packages/plugins/plugin-security/src/authz-matrix-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 ──────────────────────────────────────────────
Expand Down Expand Up @@ -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');
});
});