|
| 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