From 030bdeac29117f3215662110a806e9a911296631 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:38:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(core,spec):=20B2+B4=20=E2=80=94=20posture?= =?UTF-8?q?=20ladder=20+=20capability-derived=20posture=20(ADR-0095=20D2/D?= =?UTF-8?q?3,=20#2920)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs --- .changeset/adr-0095-d2-d3-posture-ladder.md | 34 ++++ packages/core/src/security/index.ts | 13 ++ .../core/src/security/posture-ladder.test.ts | 122 +++++++++++ packages/core/src/security/posture-ladder.ts | 192 ++++++++++++++++++ .../security/resolve-authz-context.test.ts | 111 ++++++++++ .../src/security/resolve-authz-context.ts | 33 +++ .../src/auto-org-admin-grant.ts | 6 +- packages/spec/api-surface.json | 2 + packages/spec/src/identity/eval-user.zod.ts | 14 +- packages/spec/src/index.ts | 1 + 10 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 .changeset/adr-0095-d2-d3-posture-ladder.md create mode 100644 packages/core/src/security/posture-ladder.test.ts create mode 100644 packages/core/src/security/posture-ladder.ts diff --git a/.changeset/adr-0095-d2-d3-posture-ladder.md b/.changeset/adr-0095-d2-d3-posture-ladder.md new file mode 100644 index 0000000000..ea6e6a7e03 --- /dev/null +++ b/.changeset/adr-0095-d2-d3-posture-ladder.md @@ -0,0 +1,34 @@ +--- +"@objectstack/core": minor +"@objectstack/spec": minor +--- + +ADR-0095 D2/D3: the authorization kernel now resolves an explicit **posture +ladder** — a monotonic principal tier `PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > +EXTERNAL` — once, in `resolveAuthzContext`, and carries it on +`ResolvedAuthzContext.posture`. + +- **D2 — the ladder.** New `@objectstack/core/security` module `posture-ladder.ts` + reuses the spec `AuthzPosture` enum and pins the rung → row-visibility + injection-rule mapping (exactly one rule per rung) plus its two ADR-required + invariants as unit-tested properties: strict nesting (rung *n*'s visible set ⊇ + rung *n−1*'s) and the `EXTERNAL` deny-by-default semantics (explicitly shared + rows only — OWD baselines and sharing rules never widen it). `EXTERNAL` is + defined and test-locked now but never resolved: no external principal type + exists yet (portal/ADR-0093), so the resolver's floor is `MEMBER`. +- **D3 — capability-derived, single track.** 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='admin'` remains only a + *provisioning source* of those grants (`auto-org-admin-grant.ts`, + `mapMembershipRole`); no enforcement path reads the raw role, closing the + #2836 dual-track adjudication class by construction. +- New spec export `ORGANIZATION_ADMIN` (the org-admin capability-grant name), + alongside the existing `ADMIN_FULL_ACCESS`. + +**Behavior-preserving.** Enforcement is unchanged — the per-object Layer 0 +exemption and per-side superuser bypass still gate access exactly as before; +`posture` is an additive, derived, explainable field. The `authz-matrix-gate` +unit snapshot and the dogfood authz-conformance matrix stay green. No migration +required. diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 1973264831..a503fef5c3 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -87,6 +87,19 @@ export { type ResolveAuthzInput, type ResolveLocalizationInput, } from './resolve-authz-context.js'; + +// ADR-0095 D2/D3 — the monotonic posture ladder: derivation from capability +// grants + the rung→injection-rule mapping and its tested invariants. +export { + POSTURE_LADDER, + POSTURE_RANK, + POSTURE_INJECTION_RULE, + derivePosture, + postureVisibleRows, + type PostureEvidence, + type LadderRow, + type LadderPrincipal, +} from './posture-ladder.js'; export { isAuthGateAllowlisted, evaluateAuthGate, type AuthGate } from './auth-gate.js'; // ADR-0091 D1/D2 — grant validity windows, the shared resolution-time predicate. diff --git a/packages/core/src/security/posture-ladder.test.ts b/packages/core/src/security/posture-ladder.test.ts new file mode 100644 index 0000000000..db80735fd7 --- /dev/null +++ b/packages/core/src/security/posture-ladder.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import type { AuthzPosture } from '@objectstack/spec/security'; +import { + POSTURE_LADDER, + POSTURE_RANK, + POSTURE_INJECTION_RULE, + derivePosture, + postureVisibleRows, + type LadderRow, + type LadderPrincipal, +} from './posture-ladder.js'; + +/** + * ADR-0095 D2/D3 posture-ladder invariants. This file locks the two properties + * the ADR requires as TESTED invariants — strict nesting and EXTERNAL + * deny-by-default — plus the capability-only derivation (never a role). The + * end-to-end row behavior is guarded separately by plugin-security's + * `authz-matrix-gate` and the dogfood conformance matrix; here we assert the + * ladder's mathematical shape. + */ + +// ── Reference dataset: one principal, escalating posture ───────────────────── +// p is a member of org-1. The dataset is chosen so each rung STRICTLY adds rows. +const p: LadderPrincipal = { userId: 'u1', organizationId: 'org-1' }; +const ROWS: LadderRow[] = [ + { id: 'r1', organization_id: 'org-1', owner_id: 'u1' }, // owned by p → MEMBER+ + { id: 'r2', organization_id: 'org-1', owner_id: 'u2', owdVisible: true }, // OWD baseline → MEMBER+ + { id: 'r3', organization_id: 'org-1', owner_id: 'u2', owdVisible: false }, // in-org, hidden → TENANT_ADMIN+ + { id: 'r4', organization_id: 'org-1', owner_id: 'u2', sharedTo: ['u1'] }, // shared to p → EXTERNAL+/MEMBER+ + { id: 'r5', organization_id: 'org-2', owner_id: 'u3' }, // foreign org → PLATFORM_ADMIN+ +]; + +const idsOf = (rows: LadderRow[]) => new Set(rows.map((r) => r.id)); +const isSuperset = (big: Set, small: Set) => [...small].every((x) => big.has(x)); + +describe('AuthzPosture ladder — ordering (ADR-0095 D2)', () => { + it('POSTURE_LADDER is high→low and POSTURE_RANK is strictly monotonic', () => { + expect(POSTURE_LADDER).toEqual(['PLATFORM_ADMIN', 'TENANT_ADMIN', 'MEMBER', 'EXTERNAL']); + expect(POSTURE_RANK).toEqual({ PLATFORM_ADMIN: 3, TENANT_ADMIN: 2, MEMBER: 1, EXTERNAL: 0 }); + for (let i = 1; i < POSTURE_LADDER.length; i++) { + expect(POSTURE_RANK[POSTURE_LADDER[i - 1]]).toBeGreaterThan(POSTURE_RANK[POSTURE_LADDER[i]]); + } + }); + + it('every rung maps to exactly one injection rule', () => { + for (const rung of POSTURE_LADDER) { + expect(typeof POSTURE_INJECTION_RULE[rung]).toBe('string'); + expect(POSTURE_INJECTION_RULE[rung].length).toBeGreaterThan(0); + } + expect(Object.keys(POSTURE_INJECTION_RULE).sort()).toEqual([...POSTURE_LADDER].sort()); + }); +}); + +describe('AuthzPosture ladder — strict nesting invariant (ADR-0095 D2)', () => { + it('visible(rung n) ⊇ visible(rung n−1) for every adjacent pair, strictly here', () => { + const visible: Record> = { + EXTERNAL: idsOf(postureVisibleRows('EXTERNAL', ROWS, p)), + MEMBER: idsOf(postureVisibleRows('MEMBER', ROWS, p)), + TENANT_ADMIN: idsOf(postureVisibleRows('TENANT_ADMIN', ROWS, p)), + PLATFORM_ADMIN: idsOf(postureVisibleRows('PLATFORM_ADMIN', ROWS, p)), + }; + + // The concrete visible sets for this dataset (documents the ladder). + expect(visible.EXTERNAL).toEqual(new Set(['r4'])); + expect(visible.MEMBER).toEqual(new Set(['r1', 'r2', 'r4'])); + expect(visible.TENANT_ADMIN).toEqual(new Set(['r1', 'r2', 'r3', 'r4'])); + expect(visible.PLATFORM_ADMIN).toEqual(new Set(['r1', 'r2', 'r3', 'r4', 'r5'])); + + // Superset chain (⊇) AND strict growth (⊋) up the ladder — from low rank up. + const lowToHigh = [...POSTURE_LADDER].reverse(); // EXTERNAL → PLATFORM_ADMIN + for (let i = 1; i < lowToHigh.length; i++) { + const lower = visible[lowToHigh[i - 1]]; + const higher = visible[lowToHigh[i]]; + expect(isSuperset(higher, lower)).toBe(true); // ⊇ (the invariant) + expect(higher.size).toBeGreaterThan(lower.size); // ⊋ (this dataset) + } + }); +}); + +describe('AuthzPosture ladder — EXTERNAL semantics lock (ADR-0095 D2)', () => { + it('EXTERNAL sees ONLY explicitly shared rows — OWD never widens it', () => { + // A principal with zero shares sees nothing, even though r2 is OWD-visible. + const stranger: LadderPrincipal = { userId: 'ext-1', organizationId: 'org-1' }; + expect(postureVisibleRows('EXTERNAL', ROWS, stranger)).toEqual([]); + + // Make EVERY row OWD-visible: EXTERNAL is still empty for the stranger — + // a permissive OWD baseline cannot widen an external principal's visibility. + const allPublic = ROWS.map((r) => ({ ...r, owdVisible: true })); + expect(postureVisibleRows('EXTERNAL', allPublic, stranger)).toEqual([]); + + // The only source is explicit shares: p sees exactly the row shared to p. + const seen = postureVisibleRows('EXTERNAL', allPublic, p); + expect(seen.map((r) => r.id)).toEqual(['r4']); + expect(seen.every((r) => (r.sharedTo ?? []).includes(p.userId))).toBe(true); + }); + + it('a misconfiguration can only SHRINK external visibility, never widen it', () => { + // Dropping a share removes exactly that row; nothing else appears. + const withoutShare = ROWS.map((r) => (r.id === 'r4' ? { ...r, sharedTo: [] } : r)); + expect(postureVisibleRows('EXTERNAL', withoutShare, p)).toEqual([]); + }); +}); + +describe('AuthzPosture — derivation from capability grants only (ADR-0095 D3)', () => { + it('maps held capability grants to a rung; platform wins over tenant', () => { + expect(derivePosture({ isPlatformAdmin: true, isTenantAdmin: false })).toBe('PLATFORM_ADMIN'); + expect(derivePosture({ isPlatformAdmin: true, isTenantAdmin: true })).toBe('PLATFORM_ADMIN'); + expect(derivePosture({ isPlatformAdmin: false, isTenantAdmin: true })).toBe('TENANT_ADMIN'); + expect(derivePosture({ isPlatformAdmin: false, isTenantAdmin: false })).toBe('MEMBER'); + }); + + it('never derives EXTERNAL (no external principal type exists yet)', () => { + const combos: Array<[boolean, boolean]> = [ + [true, true], [true, false], [false, true], [false, false], + ]; + for (const [isPlatformAdmin, isTenantAdmin] of combos) { + expect(derivePosture({ isPlatformAdmin, isTenantAdmin })).not.toBe('EXTERNAL'); + } + }); +}); diff --git a/packages/core/src/security/posture-ladder.ts b/packages/core/src/security/posture-ladder.ts new file mode 100644 index 0000000000..a7f58fcd66 --- /dev/null +++ b/packages/core/src/security/posture-ladder.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ── The monotonic posture ladder (ADR-0095 D2/D3) ─────────────────────────── + * + * The principal-tiering enum resolved ONCE in `resolveAuthzContext` + * (`PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > EXTERNAL`). This module owns two + * things and deliberately nothing more: + * + * 1. **Derivation (D3).** {@link derivePosture} maps held *capability grants* + * — never a better-auth role — to a rung. `PLATFORM_ADMIN` derives from the + * unscoped `admin_full_access` grant (the `viewAllRecords`/`modifyAllRecords` + * evidence the superuser bypass already trusts); `TENANT_ADMIN` from the + * `organization_admin` grant. The better-auth `role='admin'` is upstream a + * *provisioning source* of those grants (`auto-org-admin-grant.ts`), so it + * never re-enters adjudication here — the #2836 dual-track class is closed + * by construction. + * + * 2. **The rung → injection-rule mapping + its tested invariants (D2).** Each + * rung maps to EXACTLY ONE row-visibility injection rule + * ({@link POSTURE_INJECTION_RULE}). {@link postureVisibleRows} is the + * REFERENCE MODEL of those rules over a synthetic row-set — it locks the two + * properties the ADR requires as invariants: strict nesting (rung n's + * visible set ⊇ rung n−1's) and the EXTERNAL deny-by-default semantics + * (explicit shares only, OWD never widens it). + * + * This module is NOT the enforcement path. The effective read/write filter is + * `Layer0(tenant) AND Layer1(business RLS)`, computed in `@objectstack/plugin- + * security` (`tenant-layer.ts` + `security-plugin.ts`), and the real behavior + * guard is the `authz-matrix-gate` unit snapshot + the dogfood conformance + * matrix. The reference model here exists so the ladder's *mathematical* + * properties can be asserted at the unit layer without an enforcement boot, and + * so the EXTERNAL rung — which has no enforcement path yet — cannot be + * reinvented differently when portal/external membership arrives. + */ + +import type { AuthzPosture } from '@objectstack/spec/security'; + +/** + * The rung ordering, high privilege → low, matching the spec enum's numeric + * values (`PLATFORM_ADMIN=3 … EXTERNAL=0`). Visibility grows monotonically UP + * this ladder (see {@link postureVisibleRows}). + */ +export const POSTURE_LADDER = [ + 'PLATFORM_ADMIN', + 'TENANT_ADMIN', + 'MEMBER', + 'EXTERNAL', +] as const satisfies readonly AuthzPosture[]; + +/** Numeric rank per rung (mirrors the spec `AuthzPosture` enum values). */ +export const POSTURE_RANK: Record = { + PLATFORM_ADMIN: 3, + TENANT_ADMIN: 2, + MEMBER: 1, + EXTERNAL: 0, +}; + +/** + * The ONE row-visibility injection rule each rung maps to (ADR-0095 D2). Prose, + * because the machine artifacts live in enforcement (Layer 0 + the per-rung + * Layer 1 rule); this is the enumerable contract the explain track reports and + * {@link postureVisibleRows} models. + */ +export const POSTURE_INJECTION_RULE: Record = { + PLATFORM_ADMIN: + '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.', + TENANT_ADMIN: + 'All rows within the active organization (organization_id == ctx.tenantId); no ownership / depth / sharing narrowing.', + MEMBER: + 'Business RLS within the organization — ownership (owner / unit depth), the OWD baseline, and explicit sharing.', + EXTERNAL: + 'Explicitly shared rows ONLY — OWD baselines and sharing rules never apply; a misconfiguration can only shrink visibility, never widen it.', +}; + +/** Capability-grant evidence the posture derivation consumes (ADR-0095 D3). */ +export interface PostureEvidence { + /** + * Holds the UNSCOPED platform-admin capability grant (`admin_full_access` → + * `viewAllRecords`/`modifyAllRecords`) — the same evidence the superuser + * bypass trusts. NOT a better-auth role. + */ + isPlatformAdmin: boolean; + /** + * Holds the org-admin capability grant (`organization_admin`, tenant-scoped + * `viewAllRecords`/`modifyAllRecords`). Provisioned from the better-auth + * owner/admin role upstream, consumed here only as a held capability. + */ + isTenantAdmin: boolean; +} + +/** + * Resolve the principal's posture rung from held capability grants (ADR-0095 D3). + * + * Returns `PLATFORM_ADMIN` | `TENANT_ADMIN` | `MEMBER`. It NEVER returns + * `EXTERNAL`: no external principal type exists yet (the sharing chain has no + * portal/guest-share concept — ADR-0095 W4). The `EXTERNAL` rung, its injection + * rule, and its semantics are defined and test-locked ({@link postureVisibleRows}, + * {@link POSTURE_INJECTION_RULE}) so that when portal/external membership lands + * (ADR-0093) the derivation gains an EXTERNAL branch HERE without the rung being + * reinvented. `MEMBER` is the authenticated-principal floor. + */ +export function derivePosture(evidence: PostureEvidence): AuthzPosture { + if (evidence.isPlatformAdmin) return 'PLATFORM_ADMIN'; + if (evidence.isTenantAdmin) return 'TENANT_ADMIN'; + return 'MEMBER'; +} + +// ── Reference visibility model (invariant lock, NOT enforcement) ───────────── + +/** A synthetic record for the ladder reference model. */ +export interface LadderRow { + id: string; + /** The row's tenant. `undefined` = a non-tenant (platform-global) row. */ + organization_id?: string; + /** The row's owner (drives the MEMBER ownership disjunct). */ + owner_id?: string; + /** + * Whether an OWD-derived source would admit this row for a member (public + * baseline / criteria sharing). EXTERNAL deliberately ignores this field. + */ + owdVisible?: boolean; + /** User ids this row is EXPLICITLY shared to (the only EXTERNAL source). */ + sharedTo?: readonly string[]; +} + +/** The principal the reference model evaluates a rung for. */ +export interface LadderPrincipal { + userId: string; + /** The principal's active organization (undefined for an unscoped principal). */ + organizationId?: string; +} + +function isSharedTo(row: LadderRow, userId: string): boolean { + return (row.sharedTo ?? []).includes(userId); +} + +/** EXTERNAL rung: explicitly shared rows ONLY — never OWD, never org-wide. */ +function externalVisible(rows: readonly LadderRow[], p: LadderPrincipal): LadderRow[] { + return rows.filter((r) => isSharedTo(r, p.userId)); +} + +/** + * MEMBER rung: business RLS within the org — ownership OR OWD baseline OR + * explicit sharing. Composed as `EXTERNAL ∪ (in-org ownership/OWD)` so the + * EXTERNAL ⊆ MEMBER leg of the nesting invariant holds by construction. + */ +function memberVisible(rows: readonly LadderRow[], p: LadderPrincipal): LadderRow[] { + const shared = new Set(externalVisible(rows, p)); + return rows.filter( + (r) => + shared.has(r) || + (r.organization_id === p.organizationId && (r.owner_id === p.userId || r.owdVisible === true)), + ); +} + +/** + * TENANT_ADMIN rung: all rows in the active organization. Composed as + * `MEMBER ∪ (all in-org)` so MEMBER ⊆ TENANT_ADMIN holds by construction. + */ +function tenantAdminVisible(rows: readonly LadderRow[], p: LadderPrincipal): LadderRow[] { + const member = new Set(memberVisible(rows, p)); + return rows.filter((r) => member.has(r) || r.organization_id === p.organizationId); +} + +/** PLATFORM_ADMIN rung: crosses the tenant wall — every row (⊇ TENANT_ADMIN trivially). */ +function platformAdminVisible(rows: readonly LadderRow[]): LadderRow[] { + return [...rows]; +} + +/** + * Reference model of the per-rung injection rule: the visible-row set a rung + * would resolve to over `rows` for `principal`. Used to lock the ADR-0095 D2 + * invariants (strict nesting + EXTERNAL deny-by-default). NOT an enforcement + * path — see the module header. + */ +export function postureVisibleRows( + posture: AuthzPosture, + rows: readonly LadderRow[], + principal: LadderPrincipal, +): LadderRow[] { + switch (posture) { + case 'PLATFORM_ADMIN': + return platformAdminVisible(rows); + case 'TENANT_ADMIN': + return tenantAdminVisible(rows, principal); + case 'MEMBER': + return memberVisible(rows, principal); + case 'EXTERNAL': + return externalVisible(rows, principal); + } +} diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 6ca8090a0c..c9e8a5961d 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'; import { resolveAuthzContext, resolveLocalizationContext } from './resolve-authz-context.js'; +import { POSTURE_RANK } from './posture-ladder.js'; +import type { AuthzPosture } from '@objectstack/spec/security'; /** * Contract test for the SINGLE authorization resolver. Every authorization @@ -264,3 +266,112 @@ describe('audience anchors in the resolver (ADR-0090 D5)', () => { }); }); +/** + * [ADR-0095 D2/D3] Posture-ladder resolution. A `principal × grants → posture` + * matrix asserting the rung is DERIVED from held capability grants + * (`admin_full_access` → PLATFORM_ADMIN; `organization_admin` → TENANT_ADMIN; + * otherwise MEMBER), never from a better-auth role, plus the strict-nesting + * ordering (PLATFORM_ADMIN > TENANT_ADMIN > MEMBER). `EXTERNAL` is never + * resolved — no external principal type exists yet. + */ +describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { + // Each fixture returns the ql tables + the session getter for one principal. + const FIXTURES: Record = { + // Unscoped admin_full_access grant → the platform-admin capability. + platform_admin: { + ql: makeQl({ + sys_user: [{ id: 'pa' }], + sys_member: [], + sys_user_position: [], + sys_user_permission_set: [{ user_id: 'pa', permission_set_id: 'psA', organization_id: null }], + sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], + }), + getSession: session('pa'), + }, + // Org-scoped organization_admin grant (auto-provisioned from role=admin). + tenant_admin: { + ql: makeQl({ + sys_user: [{ id: 'ta' }], + sys_member: [{ user_id: 'ta', role: 'admin', organization_id: 'o1' }], + sys_user_position: [], + sys_user_permission_set: [{ user_id: 'ta', permission_set_id: 'psO', organization_id: 'o1' }], + sys_permission_set: [{ id: 'psO', name: 'organization_admin' }], + }), + getSession: session('ta', { org: 'o1' }), + }, + // Ordinary member — no admin capability grant. + member: { + ql: makeQl({ + sys_user: [{ id: 'm' }], + sys_member: [{ user_id: 'm', role: 'member', organization_id: 'o1' }], + sys_user_position: [], + sys_user_permission_set: [], + sys_permission_set: [], + }), + getSession: session('m', { org: 'o1' }), + }, + // Authenticated but no active org — still the MEMBER floor, not EXTERNAL. + no_org_member: { + ql: makeQl({ + sys_user: [{ id: 'n' }], + sys_member: [], + sys_user_position: [], + sys_user_permission_set: [], + }), + getSession: session('n'), + }, + }; + + const EXPECTED_POSTURE: Record = { + platform_admin: 'PLATFORM_ADMIN', + tenant_admin: 'TENANT_ADMIN', + member: 'MEMBER', + no_org_member: 'MEMBER', + }; + + it('resolves the principal × grants → posture matrix', async () => { + const actual: Record = {}; + for (const [name, fx] of Object.entries(FIXTURES)) { + const ctx = await resolveAuthzContext({ ql: fx.ql, headers: H(), getSession: fx.getSession }); + actual[name] = ctx.posture; + } + expect(actual).toEqual(EXPECTED_POSTURE); + }); + + it('posture is strictly nested: PLATFORM_ADMIN > TENANT_ADMIN > MEMBER', async () => { + const rank = async (name: string) => { + const fx = FIXTURES[name]; + const ctx = await resolveAuthzContext({ ql: fx.ql, headers: H(), getSession: fx.getSession }); + return POSTURE_RANK[ctx.posture!]; + }; + expect(await rank('platform_admin')).toBeGreaterThan(await rank('tenant_admin')); + expect(await rank('tenant_admin')).toBeGreaterThan(await rank('member')); + }); + + it('platform-admin grant wins over a co-held org-admin grant (capability, not role)', async () => { + // A principal who is BOTH an org admin (role) AND holds the unscoped + // platform grant resolves PLATFORM_ADMIN — derivation reads the capability, + // so the higher rung wins regardless of the better-auth role. + const ql = makeQl({ + sys_user: [{ id: 'both' }], + sys_member: [{ user_id: 'both', role: 'admin', organization_id: 'o1' }], + sys_user_position: [], + sys_user_permission_set: [ + { user_id: 'both', permission_set_id: 'psA', organization_id: null }, + { user_id: 'both', permission_set_id: 'psO', organization_id: 'o1' }, + ], + sys_permission_set: [ + { id: 'psA', name: 'admin_full_access' }, + { id: 'psO', name: 'organization_admin' }, + ], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('both', { org: 'o1' }) }); + expect(ctx.posture).toBe('PLATFORM_ADMIN'); + }); + + it('anonymous principal carries no posture rung', async () => { + const ctx = await resolveAuthzContext({ ql: makeQl({}), headers: H(), getSession: async () => undefined }); + expect(ctx.posture).toBeUndefined(); + }); +}); + diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index b4e7764ea5..84840c6164 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -29,10 +29,13 @@ import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN, ADMIN_FULL_ACCESS, + ORGANIZATION_ADMIN, } from '@objectstack/spec'; +import type { AuthzPosture } from '@objectstack/spec/security'; import { resolveApiKeyPrincipal } from './api-key.js'; import { isGrantActive } from './grant-validity.js'; +import { derivePosture } from './posture-ladder.js'; /** The transport-agnostic authorization envelope produced from a request. */ export interface ResolvedAuthzContext { @@ -46,6 +49,16 @@ export interface ResolvedAuthzContext { tabPermissions?: Record; /** Fellow-org user IDs for RLS scoping of identity tables (`id IN (...)`). */ org_user_ids: string[]; + /** + * [ADR-0095 D2/D3] The monotonic posture rung this principal resolves to, + * DERIVED once here from held capability grants (never a better-auth role): + * `PLATFORM_ADMIN` (unscoped `admin_full_access`) > `TENANT_ADMIN` + * (`organization_admin`) > `MEMBER` (the authenticated floor). `EXTERNAL` is + * defined/test-locked but never resolved yet (no external principal type — + * see `posture-ladder.ts`). Present only for an authenticated principal; + * anonymous requests carry no rung. + */ + posture?: AuthzPosture; } export interface ResolveAuthzInput { @@ -146,6 +159,11 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise) => void; diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 20e0ede1c7..af11f4f6bf 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -59,6 +59,7 @@ "MigrationStep (interface)", "MigrationTodo (interface)", "NormalizeStackInputOptions (interface)", + "ORGANIZATION_ADMIN (const)", "ObjectOSCapabilities (type)", "ObjectOSCapabilitiesSchema (const)", "ObjectQLCapabilities (type)", @@ -4292,6 +4293,7 @@ "InvitationStatus (type)", "Member (type)", "MemberSchema (const)", + "ORGANIZATION_ADMIN (const)", "Organization (type)", "OrganizationSchema (const)", "Position (type)", diff --git a/packages/spec/src/identity/eval-user.zod.ts b/packages/spec/src/identity/eval-user.zod.ts index d92237d29e..6e87602fcc 100644 --- a/packages/spec/src/identity/eval-user.zod.ts +++ b/packages/spec/src/identity/eval-user.zod.ts @@ -53,10 +53,22 @@ export type BuiltinIdentityName = (typeof BUILTIN_IDENTITY_NAMES)[number]; /** * Permission-set name whose unscoped grant is the source of truth for - * `platform_admin` (ADR-0068 D2). + * `platform_admin` (ADR-0068 D2). Under ADR-0095 D3 this is also the capability + * grant the `PLATFORM_ADMIN` posture rung derives from (it carries + * `viewAllRecords`/`modifyAllRecords`). */ export const ADMIN_FULL_ACCESS = 'admin_full_access'; +/** + * Permission-set name whose grant is the source of truth for the `TENANT_ADMIN` + * posture rung (ADR-0095 D3). Auto-granted (org-scoped) to every `sys_member` + * whose better-auth role contains `owner`/`admin` by + * `plugin-security/src/auto-org-admin-grant.ts` — so the better-auth role is a + * *provisioning source* of this capability grant, never an enforcement input. + * It carries `viewAllRecords`/`modifyAllRecords` but is tenant-scoped by Layer 0. + */ +export const ORGANIZATION_ADMIN = 'organization_admin'; + /** Human-readable metadata for the built-in identity names (seeded into `sys_position`; AI grounding). */ export const BUILTIN_IDENTITY_METADATA: Record = { [BUILTIN_IDENTITY_PLATFORM_ADMIN]: { label: 'Platform Admin', description: 'Platform operator (SaaS admin). NOT a tenant user role.' }, diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 5ae349f2d5..72580b9bd5 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -157,5 +157,6 @@ export { BUILTIN_IDENTITY_ORG_ADMIN, BUILTIN_IDENTITY_ORG_MEMBER, ADMIN_FULL_ACCESS, + ORGANIZATION_ADMIN, } from './identity/eval-user.zod'; export type { EvalUser, EvalUserInput, BuiltinIdentityName } from './identity/eval-user.zod';