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
34 changes: 34 additions & 0 deletions .changeset/adr-0095-d2-d3-posture-ladder.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
122 changes: 122 additions & 0 deletions packages/core/src/security/posture-ladder.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>, small: Set<string>) => [...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<AuthzPosture, Set<string>> = {
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');
}
});
});
192 changes: 192 additions & 0 deletions packages/core/src/security/posture-ladder.ts
Original file line number Diff line number Diff line change
@@ -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<AuthzPosture, number> = {
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<AuthzPosture, string> = {
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);
}
}
Loading