Skip to content

Commit 030bdea

Browse files
committed
feat(core,spec): B2+B4 — posture ladder + capability-derived posture (ADR-0095 D2/D3, #2920)
Resolve an explicit monotonic posture rung (PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > EXTERNAL) once in resolveAuthzContext and carry it on ResolvedAuthzContext.posture. D2 — new @objectstack/core/security module posture-ladder.ts reuses the spec AuthzPosture enum, pins the rung -> injection-rule mapping (one rule per rung), and locks its two ADR-required invariants as unit tests: strict nesting (rung n's visible set superset of n-1) and EXTERNAL deny-by-default (explicit shares only; OWD never widens it). EXTERNAL is defined/test-locked but never resolved (no external principal type yet); the floor is MEMBER. D3 — the rung derives from held capability grants, never a better-auth role: PLATFORM_ADMIN from the unscoped admin_full_access grant (the same viewAllRecords/modifyAllRecords evidence the superuser bypass trusts), TENANT_ADMIN from the organization_admin grant. The better-auth role remains a provisioning source only (auto-org-admin-grant.ts, mapMembershipRole); no enforcement path adjudicates the raw role, closing the #2836 dual-track class. Behavior-preserving: enforcement is unchanged (per-object Layer 0 exemption + per-side superuser bypass still gate access); posture is an additive derived, explainable field. authz-matrix-gate + dogfood authz-conformance stay green. - spec: add ORGANIZATION_ADMIN constant; auto-org-admin-grant.ts reuses it. - resolve-authz-context.test.ts: principal x grants -> posture matrix + nesting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs
1 parent e62c233 commit 030bdea

10 files changed

Lines changed: 526 additions & 2 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
ADR-0095 D2/D3: the authorization kernel now resolves an explicit **posture
7+
ladder** — a monotonic principal tier `PLATFORM_ADMIN > TENANT_ADMIN > MEMBER >
8+
EXTERNAL` — once, in `resolveAuthzContext`, and carries it on
9+
`ResolvedAuthzContext.posture`.
10+
11+
- **D2 — the ladder.** New `@objectstack/core/security` module `posture-ladder.ts`
12+
reuses the spec `AuthzPosture` enum and pins the rung → row-visibility
13+
injection-rule mapping (exactly one rule per rung) plus its two ADR-required
14+
invariants as unit-tested properties: strict nesting (rung *n*'s visible set ⊇
15+
rung *n−1*'s) and the `EXTERNAL` deny-by-default semantics (explicitly shared
16+
rows only — OWD baselines and sharing rules never widen it). `EXTERNAL` is
17+
defined and test-locked now but never resolved: no external principal type
18+
exists yet (portal/ADR-0093), so the resolver's floor is `MEMBER`.
19+
- **D3 — capability-derived, single track.** The rung derives from held
20+
**capability grants**, never a better-auth role: `PLATFORM_ADMIN` from the
21+
unscoped `admin_full_access` grant (the same `viewAllRecords`/`modifyAllRecords`
22+
evidence the superuser bypass trusts), `TENANT_ADMIN` from the
23+
`organization_admin` grant. The better-auth `role='admin'` remains only a
24+
*provisioning source* of those grants (`auto-org-admin-grant.ts`,
25+
`mapMembershipRole`); no enforcement path reads the raw role, closing the
26+
#2836 dual-track adjudication class by construction.
27+
- New spec export `ORGANIZATION_ADMIN` (the org-admin capability-grant name),
28+
alongside the existing `ADMIN_FULL_ACCESS`.
29+
30+
**Behavior-preserving.** Enforcement is unchanged — the per-object Layer 0
31+
exemption and per-side superuser bypass still gate access exactly as before;
32+
`posture` is an additive, derived, explainable field. The `authz-matrix-gate`
33+
unit snapshot and the dogfood authz-conformance matrix stay green. No migration
34+
required.

packages/core/src/security/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,19 @@ export {
8787
type ResolveAuthzInput,
8888
type ResolveLocalizationInput,
8989
} from './resolve-authz-context.js';
90+
91+
// ADR-0095 D2/D3 — the monotonic posture ladder: derivation from capability
92+
// grants + the rung→injection-rule mapping and its tested invariants.
93+
export {
94+
POSTURE_LADDER,
95+
POSTURE_RANK,
96+
POSTURE_INJECTION_RULE,
97+
derivePosture,
98+
postureVisibleRows,
99+
type PostureEvidence,
100+
type LadderRow,
101+
type LadderPrincipal,
102+
} from './posture-ladder.js';
90103
export { isAuthGateAllowlisted, evaluateAuthGate, type AuthGate } from './auth-gate.js';
91104

92105
// ADR-0091 D1/D2 — grant validity windows, the shared resolution-time predicate.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import type { AuthzPosture } from '@objectstack/spec/security';
5+
import {
6+
POSTURE_LADDER,
7+
POSTURE_RANK,
8+
POSTURE_INJECTION_RULE,
9+
derivePosture,
10+
postureVisibleRows,
11+
type LadderRow,
12+
type LadderPrincipal,
13+
} from './posture-ladder.js';
14+
15+
/**
16+
* ADR-0095 D2/D3 posture-ladder invariants. This file locks the two properties
17+
* the ADR requires as TESTED invariants — strict nesting and EXTERNAL
18+
* deny-by-default — plus the capability-only derivation (never a role). The
19+
* end-to-end row behavior is guarded separately by plugin-security's
20+
* `authz-matrix-gate` and the dogfood conformance matrix; here we assert the
21+
* ladder's mathematical shape.
22+
*/
23+
24+
// ── Reference dataset: one principal, escalating posture ─────────────────────
25+
// p is a member of org-1. The dataset is chosen so each rung STRICTLY adds rows.
26+
const p: LadderPrincipal = { userId: 'u1', organizationId: 'org-1' };
27+
const ROWS: LadderRow[] = [
28+
{ id: 'r1', organization_id: 'org-1', owner_id: 'u1' }, // owned by p → MEMBER+
29+
{ id: 'r2', organization_id: 'org-1', owner_id: 'u2', owdVisible: true }, // OWD baseline → MEMBER+
30+
{ id: 'r3', organization_id: 'org-1', owner_id: 'u2', owdVisible: false }, // in-org, hidden → TENANT_ADMIN+
31+
{ id: 'r4', organization_id: 'org-1', owner_id: 'u2', sharedTo: ['u1'] }, // shared to p → EXTERNAL+/MEMBER+
32+
{ id: 'r5', organization_id: 'org-2', owner_id: 'u3' }, // foreign org → PLATFORM_ADMIN+
33+
];
34+
35+
const idsOf = (rows: LadderRow[]) => new Set(rows.map((r) => r.id));
36+
const isSuperset = (big: Set<string>, small: Set<string>) => [...small].every((x) => big.has(x));
37+
38+
describe('AuthzPosture ladder — ordering (ADR-0095 D2)', () => {
39+
it('POSTURE_LADDER is high→low and POSTURE_RANK is strictly monotonic', () => {
40+
expect(POSTURE_LADDER).toEqual(['PLATFORM_ADMIN', 'TENANT_ADMIN', 'MEMBER', 'EXTERNAL']);
41+
expect(POSTURE_RANK).toEqual({ PLATFORM_ADMIN: 3, TENANT_ADMIN: 2, MEMBER: 1, EXTERNAL: 0 });
42+
for (let i = 1; i < POSTURE_LADDER.length; i++) {
43+
expect(POSTURE_RANK[POSTURE_LADDER[i - 1]]).toBeGreaterThan(POSTURE_RANK[POSTURE_LADDER[i]]);
44+
}
45+
});
46+
47+
it('every rung maps to exactly one injection rule', () => {
48+
for (const rung of POSTURE_LADDER) {
49+
expect(typeof POSTURE_INJECTION_RULE[rung]).toBe('string');
50+
expect(POSTURE_INJECTION_RULE[rung].length).toBeGreaterThan(0);
51+
}
52+
expect(Object.keys(POSTURE_INJECTION_RULE).sort()).toEqual([...POSTURE_LADDER].sort());
53+
});
54+
});
55+
56+
describe('AuthzPosture ladder — strict nesting invariant (ADR-0095 D2)', () => {
57+
it('visible(rung n) ⊇ visible(rung n−1) for every adjacent pair, strictly here', () => {
58+
const visible: Record<AuthzPosture, Set<string>> = {
59+
EXTERNAL: idsOf(postureVisibleRows('EXTERNAL', ROWS, p)),
60+
MEMBER: idsOf(postureVisibleRows('MEMBER', ROWS, p)),
61+
TENANT_ADMIN: idsOf(postureVisibleRows('TENANT_ADMIN', ROWS, p)),
62+
PLATFORM_ADMIN: idsOf(postureVisibleRows('PLATFORM_ADMIN', ROWS, p)),
63+
};
64+
65+
// The concrete visible sets for this dataset (documents the ladder).
66+
expect(visible.EXTERNAL).toEqual(new Set(['r4']));
67+
expect(visible.MEMBER).toEqual(new Set(['r1', 'r2', 'r4']));
68+
expect(visible.TENANT_ADMIN).toEqual(new Set(['r1', 'r2', 'r3', 'r4']));
69+
expect(visible.PLATFORM_ADMIN).toEqual(new Set(['r1', 'r2', 'r3', 'r4', 'r5']));
70+
71+
// Superset chain (⊇) AND strict growth (⊋) up the ladder — from low rank up.
72+
const lowToHigh = [...POSTURE_LADDER].reverse(); // EXTERNAL → PLATFORM_ADMIN
73+
for (let i = 1; i < lowToHigh.length; i++) {
74+
const lower = visible[lowToHigh[i - 1]];
75+
const higher = visible[lowToHigh[i]];
76+
expect(isSuperset(higher, lower)).toBe(true); // ⊇ (the invariant)
77+
expect(higher.size).toBeGreaterThan(lower.size); // ⊋ (this dataset)
78+
}
79+
});
80+
});
81+
82+
describe('AuthzPosture ladder — EXTERNAL semantics lock (ADR-0095 D2)', () => {
83+
it('EXTERNAL sees ONLY explicitly shared rows — OWD never widens it', () => {
84+
// A principal with zero shares sees nothing, even though r2 is OWD-visible.
85+
const stranger: LadderPrincipal = { userId: 'ext-1', organizationId: 'org-1' };
86+
expect(postureVisibleRows('EXTERNAL', ROWS, stranger)).toEqual([]);
87+
88+
// Make EVERY row OWD-visible: EXTERNAL is still empty for the stranger —
89+
// a permissive OWD baseline cannot widen an external principal's visibility.
90+
const allPublic = ROWS.map((r) => ({ ...r, owdVisible: true }));
91+
expect(postureVisibleRows('EXTERNAL', allPublic, stranger)).toEqual([]);
92+
93+
// The only source is explicit shares: p sees exactly the row shared to p.
94+
const seen = postureVisibleRows('EXTERNAL', allPublic, p);
95+
expect(seen.map((r) => r.id)).toEqual(['r4']);
96+
expect(seen.every((r) => (r.sharedTo ?? []).includes(p.userId))).toBe(true);
97+
});
98+
99+
it('a misconfiguration can only SHRINK external visibility, never widen it', () => {
100+
// Dropping a share removes exactly that row; nothing else appears.
101+
const withoutShare = ROWS.map((r) => (r.id === 'r4' ? { ...r, sharedTo: [] } : r));
102+
expect(postureVisibleRows('EXTERNAL', withoutShare, p)).toEqual([]);
103+
});
104+
});
105+
106+
describe('AuthzPosture — derivation from capability grants only (ADR-0095 D3)', () => {
107+
it('maps held capability grants to a rung; platform wins over tenant', () => {
108+
expect(derivePosture({ isPlatformAdmin: true, isTenantAdmin: false })).toBe('PLATFORM_ADMIN');
109+
expect(derivePosture({ isPlatformAdmin: true, isTenantAdmin: true })).toBe('PLATFORM_ADMIN');
110+
expect(derivePosture({ isPlatformAdmin: false, isTenantAdmin: true })).toBe('TENANT_ADMIN');
111+
expect(derivePosture({ isPlatformAdmin: false, isTenantAdmin: false })).toBe('MEMBER');
112+
});
113+
114+
it('never derives EXTERNAL (no external principal type exists yet)', () => {
115+
const combos: Array<[boolean, boolean]> = [
116+
[true, true], [true, false], [false, true], [false, false],
117+
];
118+
for (const [isPlatformAdmin, isTenantAdmin] of combos) {
119+
expect(derivePosture({ isPlatformAdmin, isTenantAdmin })).not.toBe('EXTERNAL');
120+
}
121+
});
122+
});
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ── The monotonic posture ladder (ADR-0095 D2/D3) ───────────────────────────
5+
*
6+
* The principal-tiering enum resolved ONCE in `resolveAuthzContext`
7+
* (`PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > EXTERNAL`). This module owns two
8+
* things and deliberately nothing more:
9+
*
10+
* 1. **Derivation (D3).** {@link derivePosture} maps held *capability grants*
11+
* — never a better-auth role — to a rung. `PLATFORM_ADMIN` derives from the
12+
* unscoped `admin_full_access` grant (the `viewAllRecords`/`modifyAllRecords`
13+
* evidence the superuser bypass already trusts); `TENANT_ADMIN` from the
14+
* `organization_admin` grant. The better-auth `role='admin'` is upstream a
15+
* *provisioning source* of those grants (`auto-org-admin-grant.ts`), so it
16+
* never re-enters adjudication here — the #2836 dual-track class is closed
17+
* by construction.
18+
*
19+
* 2. **The rung → injection-rule mapping + its tested invariants (D2).** Each
20+
* rung maps to EXACTLY ONE row-visibility injection rule
21+
* ({@link POSTURE_INJECTION_RULE}). {@link postureVisibleRows} is the
22+
* REFERENCE MODEL of those rules over a synthetic row-set — it locks the two
23+
* properties the ADR requires as invariants: strict nesting (rung n's
24+
* visible set ⊇ rung n−1's) and the EXTERNAL deny-by-default semantics
25+
* (explicit shares only, OWD never widens it).
26+
*
27+
* This module is NOT the enforcement path. The effective read/write filter is
28+
* `Layer0(tenant) AND Layer1(business RLS)`, computed in `@objectstack/plugin-
29+
* security` (`tenant-layer.ts` + `security-plugin.ts`), and the real behavior
30+
* guard is the `authz-matrix-gate` unit snapshot + the dogfood conformance
31+
* matrix. The reference model here exists so the ladder's *mathematical*
32+
* properties can be asserted at the unit layer without an enforcement boot, and
33+
* so the EXTERNAL rung — which has no enforcement path yet — cannot be
34+
* reinvented differently when portal/external membership arrives.
35+
*/
36+
37+
import type { AuthzPosture } from '@objectstack/spec/security';
38+
39+
/**
40+
* The rung ordering, high privilege → low, matching the spec enum's numeric
41+
* values (`PLATFORM_ADMIN=3 … EXTERNAL=0`). Visibility grows monotonically UP
42+
* this ladder (see {@link postureVisibleRows}).
43+
*/
44+
export const POSTURE_LADDER = [
45+
'PLATFORM_ADMIN',
46+
'TENANT_ADMIN',
47+
'MEMBER',
48+
'EXTERNAL',
49+
] as const satisfies readonly AuthzPosture[];
50+
51+
/** Numeric rank per rung (mirrors the spec `AuthzPosture` enum values). */
52+
export const POSTURE_RANK: Record<AuthzPosture, number> = {
53+
PLATFORM_ADMIN: 3,
54+
TENANT_ADMIN: 2,
55+
MEMBER: 1,
56+
EXTERNAL: 0,
57+
};
58+
59+
/**
60+
* The ONE row-visibility injection rule each rung maps to (ADR-0095 D2). Prose,
61+
* because the machine artifacts live in enforcement (Layer 0 + the per-rung
62+
* Layer 1 rule); this is the enumerable contract the explain track reports and
63+
* {@link postureVisibleRows} models.
64+
*/
65+
export const POSTURE_INJECTION_RULE: Record<AuthzPosture, string> = {
66+
PLATFORM_ADMIN:
67+
'Layer 0 exemption where the object posture permits (private / platform-global / better-auth-managed) — crosses the tenant wall; org-scoped like TENANT_ADMIN on ordinary tenant business objects.',
68+
TENANT_ADMIN:
69+
'All rows within the active organization (organization_id == ctx.tenantId); no ownership / depth / sharing narrowing.',
70+
MEMBER:
71+
'Business RLS within the organization — ownership (owner / unit depth), the OWD baseline, and explicit sharing.',
72+
EXTERNAL:
73+
'Explicitly shared rows ONLY — OWD baselines and sharing rules never apply; a misconfiguration can only shrink visibility, never widen it.',
74+
};
75+
76+
/** Capability-grant evidence the posture derivation consumes (ADR-0095 D3). */
77+
export interface PostureEvidence {
78+
/**
79+
* Holds the UNSCOPED platform-admin capability grant (`admin_full_access` →
80+
* `viewAllRecords`/`modifyAllRecords`) — the same evidence the superuser
81+
* bypass trusts. NOT a better-auth role.
82+
*/
83+
isPlatformAdmin: boolean;
84+
/**
85+
* Holds the org-admin capability grant (`organization_admin`, tenant-scoped
86+
* `viewAllRecords`/`modifyAllRecords`). Provisioned from the better-auth
87+
* owner/admin role upstream, consumed here only as a held capability.
88+
*/
89+
isTenantAdmin: boolean;
90+
}
91+
92+
/**
93+
* Resolve the principal's posture rung from held capability grants (ADR-0095 D3).
94+
*
95+
* Returns `PLATFORM_ADMIN` | `TENANT_ADMIN` | `MEMBER`. It NEVER returns
96+
* `EXTERNAL`: no external principal type exists yet (the sharing chain has no
97+
* portal/guest-share concept — ADR-0095 W4). The `EXTERNAL` rung, its injection
98+
* rule, and its semantics are defined and test-locked ({@link postureVisibleRows},
99+
* {@link POSTURE_INJECTION_RULE}) so that when portal/external membership lands
100+
* (ADR-0093) the derivation gains an EXTERNAL branch HERE without the rung being
101+
* reinvented. `MEMBER` is the authenticated-principal floor.
102+
*/
103+
export function derivePosture(evidence: PostureEvidence): AuthzPosture {
104+
if (evidence.isPlatformAdmin) return 'PLATFORM_ADMIN';
105+
if (evidence.isTenantAdmin) return 'TENANT_ADMIN';
106+
return 'MEMBER';
107+
}
108+
109+
// ── Reference visibility model (invariant lock, NOT enforcement) ─────────────
110+
111+
/** A synthetic record for the ladder reference model. */
112+
export interface LadderRow {
113+
id: string;
114+
/** The row's tenant. `undefined` = a non-tenant (platform-global) row. */
115+
organization_id?: string;
116+
/** The row's owner (drives the MEMBER ownership disjunct). */
117+
owner_id?: string;
118+
/**
119+
* Whether an OWD-derived source would admit this row for a member (public
120+
* baseline / criteria sharing). EXTERNAL deliberately ignores this field.
121+
*/
122+
owdVisible?: boolean;
123+
/** User ids this row is EXPLICITLY shared to (the only EXTERNAL source). */
124+
sharedTo?: readonly string[];
125+
}
126+
127+
/** The principal the reference model evaluates a rung for. */
128+
export interface LadderPrincipal {
129+
userId: string;
130+
/** The principal's active organization (undefined for an unscoped principal). */
131+
organizationId?: string;
132+
}
133+
134+
function isSharedTo(row: LadderRow, userId: string): boolean {
135+
return (row.sharedTo ?? []).includes(userId);
136+
}
137+
138+
/** EXTERNAL rung: explicitly shared rows ONLY — never OWD, never org-wide. */
139+
function externalVisible(rows: readonly LadderRow[], p: LadderPrincipal): LadderRow[] {
140+
return rows.filter((r) => isSharedTo(r, p.userId));
141+
}
142+
143+
/**
144+
* MEMBER rung: business RLS within the org — ownership OR OWD baseline OR
145+
* explicit sharing. Composed as `EXTERNAL ∪ (in-org ownership/OWD)` so the
146+
* EXTERNAL ⊆ MEMBER leg of the nesting invariant holds by construction.
147+
*/
148+
function memberVisible(rows: readonly LadderRow[], p: LadderPrincipal): LadderRow[] {
149+
const shared = new Set(externalVisible(rows, p));
150+
return rows.filter(
151+
(r) =>
152+
shared.has(r) ||
153+
(r.organization_id === p.organizationId && (r.owner_id === p.userId || r.owdVisible === true)),
154+
);
155+
}
156+
157+
/**
158+
* TENANT_ADMIN rung: all rows in the active organization. Composed as
159+
* `MEMBER ∪ (all in-org)` so MEMBER ⊆ TENANT_ADMIN holds by construction.
160+
*/
161+
function tenantAdminVisible(rows: readonly LadderRow[], p: LadderPrincipal): LadderRow[] {
162+
const member = new Set(memberVisible(rows, p));
163+
return rows.filter((r) => member.has(r) || r.organization_id === p.organizationId);
164+
}
165+
166+
/** PLATFORM_ADMIN rung: crosses the tenant wall — every row (⊇ TENANT_ADMIN trivially). */
167+
function platformAdminVisible(rows: readonly LadderRow[]): LadderRow[] {
168+
return [...rows];
169+
}
170+
171+
/**
172+
* Reference model of the per-rung injection rule: the visible-row set a rung
173+
* would resolve to over `rows` for `principal`. Used to lock the ADR-0095 D2
174+
* invariants (strict nesting + EXTERNAL deny-by-default). NOT an enforcement
175+
* path — see the module header.
176+
*/
177+
export function postureVisibleRows(
178+
posture: AuthzPosture,
179+
rows: readonly LadderRow[],
180+
principal: LadderPrincipal,
181+
): LadderRow[] {
182+
switch (posture) {
183+
case 'PLATFORM_ADMIN':
184+
return platformAdminVisible(rows);
185+
case 'TENANT_ADMIN':
186+
return tenantAdminVisible(rows, principal);
187+
case 'MEMBER':
188+
return memberVisible(rows, principal);
189+
case 'EXTERNAL':
190+
return externalVisible(rows, principal);
191+
}
192+
}

0 commit comments

Comments
 (0)