From a2fad102386dd3e6482d99a8ec9b296bf6292f1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 05:36:19 +0000 Subject: [PATCH 1/5] wip: platform-admin config anchor (L2) --- packages/core/package.json | 1 + .../security/admin-standing-surface.test.ts | 115 ++++++- .../src/security/admin-standing-surface.ts | 85 ++++- packages/core/src/security/index.ts | 22 ++ .../core/src/security/platform-admin.test.ts | 238 +++++++++++++ packages/core/src/security/platform-admin.ts | 294 ++++++++++++++++ ...uthz-context.platform-admin-config.test.ts | 322 ++++++++++++++++++ .../src/security/resolve-authz-context.ts | 78 ++++- .../plugin-auth/src/last-admin-guard.ts | 175 +++++++++- pnpm-lock.yaml | 7 +- 10 files changed, 1302 insertions(+), 35 deletions(-) create mode 100644 packages/core/src/security/platform-admin.test.ts create mode 100644 packages/core/src/security/platform-admin.ts create mode 100644 packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 40e77c21a7..6f7b6247ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*", "zod": "^4.4.3" }, "keywords": [ diff --git a/packages/core/src/security/admin-standing-surface.test.ts b/packages/core/src/security/admin-standing-surface.test.ts index 6ade76ef49..5b398a3306 100644 --- a/packages/core/src/security/admin-standing-surface.test.ts +++ b/packages/core/src/security/admin-standing-surface.test.ts @@ -43,6 +43,7 @@ import { describe, it, expect } from 'vitest'; import { ADMIN_STANDING_SURFACE, adminStandingTables } from './admin-standing-surface.js'; +import { resetPlatformAdminEmailMemo } from './platform-admin.js'; import { resolveAuthzContext } from './resolve-authz-context.js'; /** table -> every column name the resolver touched on it. */ @@ -129,7 +130,24 @@ const NOW = Date.parse('2026-08-15T00:00:00.000Z'); * Fixture variants, each reaching the platform-admin derivation and each * deliberately taking a different side of the resolver's conditional reads. */ -const VARIANTS: Record>>; org?: string }> = { +const VARIANTS: Record< + string, + { + tables: Record>>; + org?: string; + /** + * [#11663 L2] `OS_PLATFORM_OWNER_EMAIL` for this variant. The config anchor + * reads `sys_user.email` and `sys_user.email_verified` ONLY when the + * deployment declared administrators (pin P2 — an empty list answers + * "not an admin" before touching any row), so those two columns are + * invisible to every fixture that leaves the variable unset. That is + * exactly the "a conditional read is invisible in a fixture that never + * takes the branch" hazard this file's header names, so the branch gets a + * variant of its own. + */ + platformAdminEmails?: string; + } +> = { // Snake_case rows, unscoped in-window grant, active set: the happy platform-admin path. 'snake-case rows, standing intact': { org: 'org_1', @@ -241,17 +259,64 @@ const VARIANTS: Record(value: string | undefined, body: () => Promise): Promise { + const prev = process.env.OS_PLATFORM_OWNER_EMAIL; + if (value === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = value; + resetPlatformAdminEmailMemo(); + try { + return await body(); + } finally { + if (prev === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = prev; + resetPlatformAdminEmailMemo(); + } +} + async function observe(variant: keyof typeof VARIANTS): Promise { const seen: Observation = new Map(); - const { tables, org } = VARIANTS[variant]; - await resolveAuthzContext({ - ql: makeRecordingQl(tables, seen), - headers: headers(), - getSession: sessionFor('usr_1', org), - nowMs: NOW, - }); + const { tables, org, platformAdminEmails } = VARIANTS[variant]!; + await withPlatformAdminEmails(platformAdminEmails, () => + resolveAuthzContext({ + ql: makeRecordingQl(tables, seen), + headers: headers(), + getSession: sessionFor('usr_1', org), + nowMs: NOW, + }), + ); return seen; } @@ -291,12 +356,14 @@ describe('[#8734] ADMIN_STANDING_SURFACE is what resolveAuthzContext actually re ); it('reaches the platform-admin derivation — otherwise the observation proves nothing', async () => { - const ctx = await resolveAuthzContext({ - ql: makeRecordingQl(VARIANTS['snake-case rows, standing intact']!.tables, new Map()), - headers: headers(), - getSession: sessionFor('usr_1', 'org_1'), - nowMs: NOW, - }); + const ctx = await withPlatformAdminEmails(undefined, () => + resolveAuthzContext({ + ql: makeRecordingQl(VARIANTS['snake-case rows, standing intact']!.tables, new Map()), + headers: headers(), + getSession: sessionFor('usr_1', 'org_1'), + nowMs: NOW, + }), + ); // A positive control on the fixture itself: if the happy variant ever stops // resolving a platform admin, every column below it goes unobserved and the // equality above starts passing over a path nothing walked. @@ -316,6 +383,26 @@ describe('[#8734] ADMIN_STANDING_SURFACE is what resolveAuthzContext actually re expect(new Set(perVariant.values()).size).toBe(perVariant.size); }); + it('[#11663 L2] the config variant reaches the CONFIG anchor, not a grant', async () => { + // The second positive control, for the second anchor. Without it the two + // new sys_user columns could go unobserved (the branch never taken) and the + // equality above would start passing over a path nothing walked — the exact + // shape the fixture-variant note at the top of this file warns about. + const v = VARIANTS['config-anchored platform admin']!; + const ctx = await withPlatformAdminEmails(v.platformAdminEmails, () => + resolveAuthzContext({ + ql: makeRecordingQl(v.tables, new Map()), + headers: headers(), + getSession: sessionFor('usr_1', v.org), + nowMs: NOW, + }), + ); + expect(ctx.posture).toBe('PLATFORM_ADMIN'); + expect(ctx.positions).toContain('platform_admin'); + // …and it really is the config route: there is no grant row in the fixture. + expect(v.tables.sys_user_permission_set).toEqual([]); + }); + it('every declared table carries a reason, and only deriving tables carry columns', () => { for (const [table, entry] of Object.entries(ADMIN_STANDING_SURFACE)) { expect(entry.reason.length, `${table} needs a reason`).toBeGreaterThan(40); diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index c4c6a7d5ff..43101cf7f6 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -55,6 +55,20 @@ * the table-level half of the same guarantee: a resolver that starts deriving * administrator standing from a new table would otherwise be invisible to a * column-set comparison, because the new table appears in neither side's list. + * + * ## ⚠️ Tables are no longer the whole surface (#11663 L2) + * + * Since the platform-admin re-anchor's core leg, one input to the administrator + * derivation is NOT a table at all: the deployment's declared administrator + * list, read from the environment on every resolution + * (`security/platform-admin.ts`). A file that listed only tables would go on + * being perfectly accurate about the tables while silently claiming the + * derivation reads nothing else — the same shape as the stale comment this file + * replaced, one level up. {@link ADMIN_STANDING_NON_TABLE_INPUTS} is the place + * that says so, and it is deliberately a SEPARATE export rather than a + * pseudo-row in the table map: the map is compared for equality against + * observed table reads, and a pseudo-row would have to be excluded from that + * comparison by name, which is exactly the kind of special case that rots. */ /** How a table this resolver reads relates to "who is an administrator". */ @@ -83,9 +97,11 @@ export interface AdminStandingTable { * principal, and therefore all of `resolveUserAuthzGrants`. The API-key * ADMISSION path (`resolveApiKeyAdmission`) is outside it on purpose: it * authenticates a principal and seeds `permissions` with the key's scopes, and - * confers no administrator standing of its own — `hasPlatformAdminGrant` (§6b) - * is set only from a `sys_permission_set` row reached through an UNSCOPED - * `sys_user_permission_set` grant, never from a scope string. + * confers no administrator standing of its own — `hasPlatformAdminGrant` is set + * from a `sys_permission_set` row reached through an UNSCOPED + * `sys_user_permission_set` grant (§6b) or from the deployment config matched + * against the caller's own STORED `sys_user` row (§6b-config), never from a + * scope string and never from the caller-seedable `grants.email`. */ export const ADMIN_STANDING_SURFACE: Readonly> = { sys_permission_set: { @@ -146,12 +162,25 @@ export const ADMIN_STANDING_SURFACE: Readonly }, sys_user: { - role: 'reads-only', + role: 'derives', reason: - 'Read for the `current_user.email` RLS fallback and the ADR-0024 `ai_seat` synthesis (§7). ' - + 'Neither confers administrator standing. The guard does watch this table, but for the ' - + 'ban/delete WRITE SHAPES — `banned` is never read here, so it is not a derivation column ' - + 'and carries no standing-key list.', + '[#11663 L2] RECLASSIFIED from `reads-only`. This table used to be read only for the ' + + '`current_user.email` RLS fallback and the ADR-0024 `ai_seat` synthesis (§7), and the ' + + 'note here said so: "Neither confers administrator standing." That sentence is now FALSE. ' + + 'The config anchor (§6b-config) matches the row\'s own `email` against the deployment\'s ' + + 'declared administrator list and requires `email_verified` to read verified, so a write ' + + 'that changes either column takes platform-admin standing away from a config-derived ' + + 'administrator — an address change and an email_verified reset are both ordinary, ' + + 'reachable writes, and neither touches a grant table. `banned` stays absent from the ' + + 'column list because the resolver still never reads it; the guard watches the ban/delete ' + + 'WRITE SHAPES on this table for its own reasons, which is a different question from what ' + + 'this resolver consumes.', + columns: [ + 'id', + 'email', + 'email_verified', + 'ai_access', + ], }, sys_user_position: { @@ -180,6 +209,46 @@ export const ADMIN_STANDING_SURFACE: Readonly }, }; +/** A derivation input that is not a table — see {@link ADMIN_STANDING_NON_TABLE_INPUTS}. */ +export interface AdminStandingNonTableInput { + /** How the value reaches the resolver, e.g. `env` for a process environment variable. */ + readonly kind: 'env'; + /** The exact spelling an operator sets — quotable verbatim in a refusal message. */ + readonly name: string; + /** What it decides, and what a break-glass guard can and cannot do about it. */ + readonly reason: string; +} + +/** + * [#11663 L2] Inputs to the administrator derivation that no table write can + * reach — declared here so this file's silence about them cannot be read as + * "the derivation reads only tables". + * + * The practical consequence is the one worth writing down: a break-glass guard + * simulates a pending WRITE, and there is no write to simulate for any of + * these. Standing that rests on one of them is taken away by changing the + * deployment's configuration and rolling the process, which is deliberately + * outside every in-product path — including every path an agent could be talked + * into calling. That is the whole point of the config anchor, and it is also + * the reason a guard cannot promise to prevent this class of lockout: it can + * only refuse the writes it can see. + */ +export const ADMIN_STANDING_NON_TABLE_INPUTS: readonly AdminStandingNonTableInput[] = [ + { + kind: 'env', + name: 'OS_PLATFORM_OWNER_EMAIL', + reason: + 'The deployment\'s declared platform administrator(s) — one address or a comma-separated ' + + 'list, matched case-insensitively against `sys_user.email` and conferring standing only ' + + 'when that row\'s `email_verified` reads verified (§6b-config). Read live on every ' + + 'derivation with a per-process memo keyed on the raw string, so a rolled process picks up ' + + 'a change with no special path. Unset, blank, or carrying any unparseable entry means ' + + 'ZERO config-derived administrators, fail closed. No runtime write reaches it, so no ' + + 'break-glass guard can simulate a change to it: revocation is a configuration change plus ' + + 'a process roll, by design.', + }, +]; + /** The tables a write to which can change who is an administrator. */ export function adminStandingTables(): string[] { return Object.entries(ADMIN_STANDING_SURFACE) diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 017f00d67d..678738e8d5 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -155,11 +155,33 @@ export { isRowActive, type ActivatableRow } from './row-active.js'; // single source `plugin-auth`'s break-glass standing-key lists correspond to. export { ADMIN_STANDING_SURFACE, + ADMIN_STANDING_NON_TABLE_INPUTS, adminStandingTables, adminStandingColumns, type AdminStandingTable, + type AdminStandingNonTableInput, } from './admin-standing-surface.js'; +// [#11663 L2] The DEPLOYMENT-CONFIG anchor for PLATFORM_ADMIN — parse, +// normalization and match predicate for `OS_PLATFORM_OWNER_EMAIL`, consumed by +// `resolve-authz-context.ts` §6b-config. Exported so the sibling legs +// (plugin-auth's break-glass guard, plugin-security's bootstrap, the audit +// surface) ask the SAME question instead of re-implementing the parse — which +// is the whole reason the config read has exactly one home. +export { + PLATFORM_ADMIN_EMAIL_SEPARATOR, + normalizePlatformAdminEmail, + parsePlatformAdminEmails, + resolvePlatformAdminEmails, + resetPlatformAdminEmailMemo, + matchesConfiguredPlatformAdmin, + reportLegacyPlatformAdminGrant, + resetLegacyPlatformAdminGrantReport, + setPlatformAdminConfigSink, + type PlatformAdminEmailConfig, + type PlatformAdminConfigSink, +} from './platform-admin.js'; + // [#7678] ADR-0090 D5/D9 — the audience-binding suggestion `?status=` vocabulary, // shared by the runtime dispatcher's `/security` domain and the live REST route. export { diff --git a/packages/core/src/security/platform-admin.test.ts b/packages/core/src/security/platform-admin.test.ts new file mode 100644 index 0000000000..88139834bd --- /dev/null +++ b/packages/core/src/security/platform-admin.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11663 L2] The config anchor's PARSE and MATCH halves, unit-tested apart + * from the resolver that consumes them. + * + * The derivation itself (does a request resolve `PLATFORM_ADMIN`?) is pinned + * next door in `resolve-authz-context.platform-admin-config.test.ts`. This file + * covers the two things that file cannot show cheaply: every arm of the + * fail-closed parse (Choice 2B), and that the match predicate never looks at a + * row it has no business looking at. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { + matchesConfiguredPlatformAdmin, + normalizePlatformAdminEmail, + parsePlatformAdminEmails, + reportLegacyPlatformAdminGrant, + resetLegacyPlatformAdminGrantReport, + resetPlatformAdminEmailMemo, + resolvePlatformAdminEmails, + setPlatformAdminConfigSink, + type PlatformAdminConfigSink, +} from './platform-admin.js'; + +const ENV = 'OS_PLATFORM_OWNER_EMAIL'; + +function makeSink(): PlatformAdminConfigSink & { errors: string[]; warns: string[] } { + const errors: string[] = []; + const warns: string[] = []; + return { errors, warns, error: (m) => errors.push(m), warn: (m) => warns.push(m) }; +} + +let ambient: string | undefined; +let sink: ReturnType; + +beforeEach(() => { + ambient = process.env[ENV]; + delete process.env[ENV]; + resetPlatformAdminEmailMemo(); + resetLegacyPlatformAdminGrantReport(); + sink = makeSink(); + setPlatformAdminConfigSink(sink); +}); + +afterEach(() => { + if (ambient === undefined) delete process.env[ENV]; + else process.env[ENV] = ambient; + resetPlatformAdminEmailMemo(); + resetLegacyPlatformAdminGrantReport(); + setPlatformAdminConfigSink(undefined); +}); + +describe('normalizePlatformAdminEmail — ONE normalization, both sides', () => { + it('trims and lowercases, and answers empty for a non-string', () => { + expect(normalizePlatformAdminEmail(' Ada@Example.COM ')).toBe('ada@example.com'); + expect(normalizePlatformAdminEmail(undefined)).toBe(''); + expect(normalizePlatformAdminEmail(null)).toBe(''); + expect(normalizePlatformAdminEmail(42)).toBe(''); + }); +}); + +describe('[Choice 2B] parsePlatformAdminEmails', () => { + it('unset and blank are the same outcome: zero administrators, no refusal', () => { + for (const raw of [undefined, '', ' ', '\t\n']) { + const parsed = parsePlatformAdminEmails(raw); + expect(parsed.emails, `raw=${JSON.stringify(raw)}`).toEqual([]); + expect(parsed.refusal).toBeUndefined(); + } + }); + + it('normalizes once, collapses duplicates, drops blanks, keeps declared order', () => { + const parsed = parsePlatformAdminEmails(' Ops@Corp.example , , second@corp.example ,ops@corp.example,'); + expect(parsed.emails).toEqual(['ops@corp.example', 'second@corp.example']); + expect(parsed.refusal).toBeUndefined(); + }); + + it('accepts the shapes a deployment legitimately declares', () => { + // `a@b.c` is this leg's own acceptance-criterion value and `admin@localhost` + // is an ordinary development address. Both are REJECTED by zod 4's + // `.email()`, which is why this predicate is a shape check — see the note + // on `isParseableAddress`. A tightening that breaks this test is a + // tightening that locks a deployment out of its own administration. + const parsed = parsePlatformAdminEmails('a@b.c,admin@localhost,ops+admin@corp.example'); + expect(parsed.emails).toEqual(['a@b.c', 'admin@localhost', 'ops+admin@corp.example']); + expect(parsed.refusal).toBeUndefined(); + }); + + it('⛔ one unparseable entry fails the WHOLE variable closed — never skip-and-continue', () => { + for (const bad of ['not-an-email', '@corp.example', 'ops@', 'a@b@c', 'two words@corp.example']) { + const parsed = parsePlatformAdminEmails(`good@corp.example,${bad},also.good@corp.example`); + // The point of the arm: the two VALID entries do not survive either. A + // parse that kept them would hand the deployment a narrower administrator + // set than the operator declared, with nothing anywhere to notice. + expect(parsed.emails, `bad=${JSON.stringify(bad)}`).toEqual([]); + expect(parsed.refusal, `bad=${JSON.stringify(bad)}`).toContain(ENV); + expect(parsed.refusal).toContain(bad); + expect(parsed.raw).toContain('good@corp.example'); + } + }); + + it('a refused variable is reported as refused, not as unset', () => { + // Both answer "zero config-derived administrators"; only one of them is an + // operator mistake, and a caller must be able to tell them apart. + expect(parsePlatformAdminEmails(undefined).refusal).toBeUndefined(); + expect(parsePlatformAdminEmails('nonsense').refusal).toBeDefined(); + }); +}); + +describe('[Choice 3A] resolvePlatformAdminEmails — live read, memo keyed on the raw string', () => { + it('reads process.env live: a changed value is picked up on the next call', () => { + expect(resolvePlatformAdminEmails().emails).toEqual([]); + process.env[ENV] = 'first@corp.example'; + expect(resolvePlatformAdminEmails().emails).toEqual(['first@corp.example']); + process.env[ENV] = 'second@corp.example'; + expect(resolvePlatformAdminEmails().emails).toEqual(['second@corp.example']); + delete process.env[ENV]; + expect(resolvePlatformAdminEmails().emails).toEqual([]); + }); + + it('returns the SAME parse object while the raw string is unchanged', () => { + process.env[ENV] = 'ops@corp.example, second@corp.example'; + const a = resolvePlatformAdminEmails(); + const b = resolvePlatformAdminEmails(); + // Identity, not equality: this is what makes the memo observable at all, + // and re-parsing per request is the cost 3A's memo exists to avoid on the + // authorization hot path. + expect(b).toBe(a); + }); + + it('is LOUD about a refused variable, exactly once per distinct raw value', () => { + process.env[ENV] = 'nonsense'; + resolvePlatformAdminEmails(); + resolvePlatformAdminEmails(); + resolvePlatformAdminEmails(); + expect(sink.errors).toHaveLength(1); + expect(sink.errors[0]).toContain(ENV); + expect(sink.errors[0]).toContain('ZERO config-derived platform'); + + process.env[ENV] = 'also nonsense'; + resolvePlatformAdminEmails(); + expect(sink.errors).toHaveLength(2); + }); + + it('says NOTHING about an unset variable', () => { + // The shipped default for every `single`-posture deployment. Warning on it + // is how a log people read becomes a log people skim; a walled posture with + // the variable unset already refuses boot one layer up. + resolvePlatformAdminEmails(); + expect(sink.errors).toEqual([]); + expect(sink.warns).toEqual([]); + }); +}); + +describe('matchesConfiguredPlatformAdmin — verified match only, fail closed', () => { + const config = parsePlatformAdminEmails('ops@corp.example, second@corp.example'); + + it('a verified row whose email is on the list matches', () => { + expect(matchesConfiguredPlatformAdmin({ email: 'ops@corp.example', email_verified: true }, config)).toBe(true); + // Every representation the drivers hand back for the boolean column. + expect(matchesConfiguredPlatformAdmin({ email: 'second@corp.example', email_verified: 1 }, config)).toBe(true); + expect(matchesConfiguredPlatformAdmin({ email: 'ops@corp.example', email_verified: '1' }, config)).toBe(true); + expect(matchesConfiguredPlatformAdmin({ email: 'ops@corp.example', email_verified: 'true' }, config)).toBe(true); + }); + + it('matches case-insensitively on BOTH sides', () => { + const mixed = parsePlatformAdminEmails('Ops@Corp.Example'); + expect(matchesConfiguredPlatformAdmin({ email: 'OPS@corp.EXAMPLE', email_verified: true }, mixed)).toBe(true); + }); + + it('⛔ an UNVERIFIED account holding a configured address confers nothing', () => { + for (const v of [false, 0, '0', 'false', null, undefined, 'TRUE', 'yes']) { + expect( + matchesConfiguredPlatformAdmin({ email: 'ops@corp.example', email_verified: v }, config), + `email_verified=${JSON.stringify(v)}`, + ).toBe(false); + } + // An ABSENT column reads unverified — the arm that matters for every row + // that predates the column. + expect(matchesConfiguredPlatformAdmin({ email: 'ops@corp.example' }, config)).toBe(false); + }); + + it('an address that is not on the list confers nothing, however verified', () => { + expect(matchesConfiguredPlatformAdmin({ email: 'nobody@corp.example', email_verified: true }, config)).toBe(false); + expect(matchesConfiguredPlatformAdmin({ email: '', email_verified: true }, config)).toBe(false); + expect(matchesConfiguredPlatformAdmin({ email_verified: true }, config)).toBe(false); + }); + + it('a REFUSED variable confers nothing on anybody', () => { + const refused = parsePlatformAdminEmails('ops@corp.example,nonsense'); + expect(refused.refusal).toBeDefined(); + expect(matchesConfiguredPlatformAdmin({ email: 'ops@corp.example', email_verified: true }, refused)).toBe(false); + }); + + it('[pin P2] an EMPTY list answers false WITHOUT reading the row at all', () => { + // Not a style preference: the resolver relies on this short-circuit to keep + // the `sys_user` read conditional on config, which is what leaves the + // pinned batch-equivalence query multiset untouched for a deployment that + // declared no administrators. A `Proxy` that throws on any property access + // is the only way to assert "did not read" rather than "read and ignored". + const explodes = new Proxy( + { email: 'ops@corp.example', email_verified: true }, + { + get(_t, prop) { + throw new Error(`matchesConfiguredPlatformAdmin read '${String(prop)}' on an empty config`); + }, + }, + ); + expect(matchesConfiguredPlatformAdmin(explodes, parsePlatformAdminEmails(undefined))).toBe(false); + }); + + it('a missing row is not an administrator', () => { + expect(matchesConfiguredPlatformAdmin(undefined, config)).toBe(false); + expect(matchesConfiguredPlatformAdmin(null, config)).toBe(false); + expect(matchesConfiguredPlatformAdmin('usr_1', config)).toBe(false); + }); +}); + +describe('[#11663 P5] reportLegacyPlatformAdminGrant', () => { + it('names the holder, the variable and the line to add — once per process', () => { + reportLegacyPlatformAdminGrant({ userId: 'usr_1', email: 'Ada@Example.com' }); + reportLegacyPlatformAdminGrant({ userId: 'usr_2', email: 'bob@example.com' }); + expect(sink.warns).toHaveLength(1); + expect(sink.warns[0]).toContain('usr_1'); + expect(sink.warns[0]).toContain(`${ENV}=ada@example.com`); + expect(sink.warns[0]).toContain('admin_full_access'); + }); + + it('falls back to a placeholder when the row was never loaded', () => { + // The notice must never force a `sys_user` read of its own — see the + // §6b-config branch, which passes the memoized row only if it is already + // there. A fully-seeded API-key principal has no row loaded. + reportLegacyPlatformAdminGrant({ userId: 'usr_1' }); + expect(sink.warns[0]).toContain(`${ENV}=`); + }); +}); diff --git a/packages/core/src/security/platform-admin.ts b/packages/core/src/security/platform-admin.ts new file mode 100644 index 0000000000..54db420f23 --- /dev/null +++ b/packages/core/src/security/platform-admin.ts @@ -0,0 +1,294 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * platform-admin.ts — the CONFIG half of the platform-admin derivation + * (#11663 leg L2, design comment 5394453215 §2/§3/§4, maintainer acceptance + * 2026-08-25 「接受你的建议,继续」, bundle 1A/2B/3A/4A/5A/6A/7A). + * + * ## What this module answers + * + * "Is the principal behind THIS `sys_user` row one of the platform + * administrators the DEPLOYMENT declared?" — the deployment-config anchor for + * `PLATFORM_ADMIN`, beside the stored-grant anchor (ADR-0068 D2) that + * `resolve-authz-context.ts` §6b already reads. Both routes meet at the ONE + * derivation site; there is deliberately no second one. + * + * ## The ruled shape, and why each half is not negotiable here + * + * - **Choice 1A — one variable.** `OS_PLATFORM_OWNER_EMAIL` + * ({@link PLATFORM_OWNER_EMAIL_ENV}), the name plugin-auth's walled boot + * refusal and plugin-security's elevation refusal already quote. A second + * spelling is a second door: generated configs and docs would carry both and + * one of them would silently do nothing. + * - **Choice 2B — a comma-separated LIST**, because a single configured address + * is a single point of human failure and losing that mailbox leaves a + * deployment with no administrator and no in-product recovery. One separator, + * one normalization (`trim().toLowerCase()`), duplicates collapsed, blank + * entries dropped — and **any UNPARSEABLE entry fails the WHOLE variable + * closed** ({@link parsePlatformAdminEmails}). ⛔ Never skip-and-continue: a + * dropped malformed entry turns a config typo into either a silent lockout or, + * worse, a silently NARROWER administrator set nobody notices. + * - **Choice 3A — live read per derivation**, with a per-process memo keyed on + * the RAW string ({@link resolvePlatformAdminEmails}), so a rolled process + * picks up a revocation with no special path and the hot authorization path + * still re-parses only when the operator's value actually changes. ⛔ There is + * no runtime mutation endpoint and none may be added: an endpoint whose only + * job is to change who is a superuser is the highest-value target on the + * platform and precisely the surface an agent could be talked into calling. + * - **Verified-email match ONLY.** An unverified account holding a configured + * address confers nothing ({@link matchesConfiguredPlatformAdmin} consults + * `isEmailVerifiedUserRow`, whose allow-list reads an ABSENT column as + * unverified). + * - **Empty/unset = ZERO platform admins, fail closed.** The derivation answers + * `false` on an empty list before it looks at any row. + * + * ## ⚠️ The single most important mechanical pin + * + * The address compared here is the one on the caller's **own stored `sys_user` + * row**, never `grants.email`. `resolveUserAuthzGrants` seeds `grants.email` + * from `opts.seedEmail` — a CALLER/SESSION-supplied string that wins over the + * stored read — so deriving superuser standing from it would open a new + * escalation channel inside the very change meant to close one. That is why + * {@link matchesConfiguredPlatformAdmin} takes a ROW and reads `row.email` + * itself, and why `resolve-authz-context.ts` hands it `getUserRow()`. + */ + +import { isEmailVerifiedUserRow, PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail } from '@objectstack/types'; + +/** + * The one separator {@link parsePlatformAdminEmails} splits on (Choice 2B). + * Same shape as `OS_CORS_ORIGIN`, the existing comma-separated precedent. + */ +export const PLATFORM_ADMIN_EMAIL_SEPARATOR = ','; + +/** + * The ONE normalization, applied to both sides of every comparison: trim, then + * lowercase. Email domains are case-insensitive and every mailbox this platform + * issues is too, so an operator who types `Ada@Example.com` and a row storing + * `ada@example.com` must be one administrator, not two half-matches. + */ +export function normalizePlatformAdminEmail(value: unknown): string { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +/** + * Is one already-normalized entry a usable address? + * + * Deliberately a SHAPE check, not RFC-5322 and not zod's `.email()`. Measured + * before choosing (zod 4.4.3, the version this package resolves): + * `z.string().email()` rejects `a@b.c` (its domain pattern demands a + * two-character-or-longer final label) and `admin@localhost`. Both are + * addresses a deployment can legitimately declare — `a@b.c` is this leg's own + * acceptance-criterion value — and refusing one fails the WHOLE variable + * closed under Choice 2B, i.e. it LOCKS THE DEPLOYMENT OUT of its own + * administration. The hazard this predicate exists to catch is an operator + * typo (a forgotten separator, a pasted sentence, a bare name), not a + * standards deviation, so it asks only what a `sys_user.email` must minimally + * be to ever match: one `@`, something either side of it, and no whitespace. + * + * ⛔ Do not tighten this into a "real" email validator. Over-strictness here is + * not a stricter contract, it is an unrecoverable lockout on a value nobody can + * fix from inside the product. + */ +function isParseableAddress(entry: string): boolean { + if (/\s/.test(entry)) return false; + const at = entry.indexOf('@'); + if (at <= 0) return false; // absent, or an empty local part + if (entry.indexOf('@', at + 1) !== -1) return false; // more than one `@` + return at < entry.length - 1; // a non-empty domain part +} + +/** The parsed state of `OS_PLATFORM_OWNER_EMAIL` for one raw value. */ +export interface PlatformAdminEmailConfig { + /** + * Normalized, de-duplicated administrator addresses in the order the operator + * declared them. EMPTY when the variable is unset, blank, or refused — those + * three are one outcome by design (zero config-derived administrators), and + * they are told apart by {@link refusal} rather than by a second empty value. + */ + readonly emails: readonly string[]; + /** What the operator actually typed, when the variable was set to anything. */ + readonly raw?: string; + /** + * Set when the variable was DECLARED but refused, naming the offending entry. + * `emails` is empty in that case: the whole variable fails closed, never the + * one entry (Choice 2B). + */ + readonly refusal?: string; +} + +const EMPTY_CONFIG: PlatformAdminEmailConfig = Object.freeze({ emails: Object.freeze([]) as readonly string[] }); + +/** + * Parse one raw `OS_PLATFORM_OWNER_EMAIL` value into the administrator list. + * + * Pure — no env read, no logging — so the whole parse is testable as a + * function of its input. {@link resolvePlatformAdminEmails} is the env-reading, + * memoizing, once-per-value-loud wrapper around it. + */ +export function parsePlatformAdminEmails(raw: string | undefined): PlatformAdminEmailConfig { + if (raw == null) return EMPTY_CONFIG; + const text = String(raw); + if (text.trim() === '') return EMPTY_CONFIG; + + const emails: string[] = []; + for (const piece of text.split(PLATFORM_ADMIN_EMAIL_SEPARATOR)) { + const entry = normalizePlatformAdminEmail(piece); + // Blanks are DROPPED, not refused: a trailing separator or a line wrapped + // for readability is a formatting habit, not a typo that changes who + // administers the deployment. + if (entry === '') continue; + if (!isParseableAddress(entry)) { + return { + emails: Object.freeze([]) as readonly string[], + raw: text, + refusal: + `${PLATFORM_OWNER_EMAIL_ENV} entry ${JSON.stringify(piece)} is not an email address, so the ` + + 'WHOLE variable is refused and this deployment has ZERO config-derived platform ' + + 'administrators. The entry is not skipped on purpose: silently dropping it would leave ' + + 'a narrower administrator set than the operator declared, with nothing to notice. Fix ' + + `the entry, or remove it — ${PLATFORM_OWNER_EMAIL_ENV} takes one address or a ` + + 'comma-separated list of them.', + }; + } + // Duplicates collapse; first declaration wins the position. + if (!emails.includes(entry)) emails.push(entry); + } + + return { emails: Object.freeze(emails) as readonly string[], raw: text }; +} + +/** + * Sink for the refusal notice. `console` by default so the loudness does not + * depend on any host wiring it up — a deployment that declared administrators + * and got none must never find that out silently. Swappable for tests. + */ +export interface PlatformAdminConfigSink { + error(message: string): void; + warn(message: string): void; +} + +const defaultSink: PlatformAdminConfigSink = { + error: (m) => console.error(m), + warn: (m) => console.warn(m), +}; +let sink: PlatformAdminConfigSink = defaultSink; + +/** Redirect this module's notices (tests). Returns the previous sink. */ +export function setPlatformAdminConfigSink(next: PlatformAdminConfigSink | undefined): PlatformAdminConfigSink { + const prev = sink; + sink = next ?? defaultSink; + return prev; +} + +/** + * The per-process memo (Choice 3A). Keyed on the RAW string, so a value the + * operator has not changed is parsed once and a value they HAVE changed is + * re-read on the very next derivation — 3A's semantics at 3B's cost, with no + * cached second copy of the answer to drift from `process.env`. + * + * `NOT_MEMOIZED` is a sentinel rather than `undefined` because `undefined` is + * itself a legal memo key (the variable unset). + */ +const NOT_MEMOIZED = Symbol('platform-admin-config-not-memoized'); +let memoKey: string | undefined | typeof NOT_MEMOIZED = NOT_MEMOIZED; +let memoValue: PlatformAdminEmailConfig = EMPTY_CONFIG; + +/** + * Resolve the deployment's declared platform administrators — live from the + * environment, memoized on the raw string, and LOUD exactly once per distinct + * refused value. + * + * Silence for an UNSET variable is deliberate and is not the same decision: + * every `single`-posture deployment runs that way by design (Choice 4A leaves + * first-user promotion in place there), and warning on the shipped default is + * how a log people read becomes a log people skim. A walled posture with the + * variable unset already REFUSES BOOT one layer up, in plugin-auth. + */ +export function resolvePlatformAdminEmails(): PlatformAdminEmailConfig { + const raw = resolvePlatformOwnerEmail(); + if (memoKey !== NOT_MEMOIZED && memoKey === raw) return memoValue; + + const parsed = parsePlatformAdminEmails(raw); + memoKey = raw; + memoValue = parsed; + // Once per distinct raw value, which for a real deployment is once per + // process: the memo boundary IS the "have we said this already" boundary, so + // this can never become a per-request line. + if (parsed.refusal) sink.error(`[authz] ${parsed.refusal}`); + return parsed; +} + +/** Drop the memo — for tests that drive several values through one process. */ +export function resetPlatformAdminEmailMemo(): void { + memoKey = NOT_MEMOIZED; + memoValue = EMPTY_CONFIG; +} + +/** + * Does this stored `sys_user` row belong to a declared platform administrator? + * + * Fail-closed on every axis: an empty/refused config answers `false` without + * looking at the row at all, an address that is not on the list answers + * `false`, and an address that IS on the list but whose `email_verified` + * column does not read verified answers `false` too. The last one is the point + * of the whole leg — an unverified account holding a configured address confers + * nothing, so an attacker who registers the operator's address before the + * operator does gains no standing by it. + * + * ⚠️ `row` MUST be the caller's own stored `sys_user` row. See this module's + * header: `grants.email` is caller-seedable and reading it here would be an + * escalation channel. + */ +export function matchesConfiguredPlatformAdmin( + row: unknown, + config: PlatformAdminEmailConfig, +): boolean { + if (config.emails.length === 0) return false; + if (!row || typeof row !== 'object') return false; + const email = normalizePlatformAdminEmail((row as { email?: unknown }).email); + if (email === '' || !config.emails.includes(email)) return false; + return isEmailVerifiedUserRow(row); +} + +/** + * [#11663 P5] The migration pointer for the LEGACY anchor. + * + * Nothing is revoked in this leg: an unscoped, in-window `admin_full_access` + * grant still confers `PLATFORM_ADMIN` exactly as it did (design §5 step 3 — + * config-derived standing is ADDED, which is what makes this safe to land ahead + * of every deployment setting the variable). What changes is that the row is now + * the OLD anchor, so a holder whose standing rests on it alone is told, once, + * which config line re-anchors them before the row route is removed. + * + * Once per process, naming ONE holder. Deliberately not once per holder: this + * runs inside the authorization path, and an unbounded per-user ledger there is + * a memory surface for something whose whole job is to say "go look at the + * configuration". The population question (who ALL the administrators are) is + * the audit surface's, filed as its own leg. + */ +let legacyGrantPointerSaid = false; + +export function reportLegacyPlatformAdminGrant(input: { + userId: string; + email?: unknown; +}): void { + if (legacyGrantPointerSaid) return; + legacyGrantPointerSaid = true; + const email = normalizePlatformAdminEmail(input.email); + sink.warn( + `[authz] user ${input.userId} holds PLATFORM_ADMIN through the legacy unscoped ` + + `'admin_full_access' grant row, not through ${PLATFORM_OWNER_EMAIL_ENV}. The grant row is ` + + 'the OLD anchor and is honoured for now; it is removed in a later release. Re-anchor this ' + + `deployment by declaring its administrators in configuration: ${PLATFORM_OWNER_EMAIL_ENV}=` + + `${email || ''}` + + ' (comma-separated for several), and make sure each account\'s email is VERIFIED — an ' + + 'unverified account holding a configured address is not an administrator. Reported once ' + + 'per process; further holders are not listed.', + ); +} + +/** Drop the once-per-process latch — for tests. */ +export function resetLegacyPlatformAdminGrantReport(): void { + legacyGrantPointerSaid = false; +} diff --git a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts new file mode 100644 index 0000000000..2e5289e868 --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts @@ -0,0 +1,322 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11663 L2] The DERIVATION half of the platform-admin re-anchor: does a + * request actually resolve `PLATFORM_ADMIN` from the deployment's declared + * administrator list, and — the arms that matter more — does it refuse to in + * every case where it must? + * + * The card's acceptance criterion, verbatim, is the first `describe` below: + * "with `OS_PLATFORM_OWNER_EMAIL=a@b.c` and a VERIFIED account `a@b.c`, + * derivation yields `PLATFORM_ADMIN` with the declared capability set; + * unset/empty/malformed variable yields zero config-derived admins (loudly); + * legacy grant path still honoured and logging its deprecation pointer." + * + * ⭐ The single most important test in this file is + * "a session payload carrying a configured address over a sys_user row that + * does not resolves NON-admin". `resolveUserAuthzGrants` seeds `grants.email` + * from `opts.seedEmail` — a caller/session-supplied string that deliberately + * WINS over the stored read for RLS purposes — so a derivation that reached for + * `grants.email` would turn the change meant to CLOSE an escalation channel + * into one that opens a new one. That test fails if anyone ever makes that + * substitution, and it is the reason `matchesConfiguredPlatformAdmin` takes a + * row rather than an address. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ADMIN_FULL_ACCESS, ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; + +import { + resetLegacyPlatformAdminGrantReport, + resetPlatformAdminEmailMemo, + setPlatformAdminConfigSink, + type PlatformAdminConfigSink, +} from './platform-admin.js'; +import { + hasPlatformAdminStanding, + resolveAuthzContext, + resolveUserAuthzGrants, +} from './resolve-authz-context.js'; + +const ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const NOW = Date.parse('2026-08-29T00:00:00.000Z'); + +interface Recorded { object: string; where: unknown } + +/** A minimal ObjectQL double that records the reads it served. */ +function makeQl(tables: Record>>) { + const calls: Recorded[] = []; + const matches = (row: Record, where: any): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); + return { + calls, + async find(object: string, opts: any) { + calls.push({ object, where: opts?.where }); + const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + }; +} + +/** A `sys_user`-only fixture: no grant rows anywhere, so standing can only be config-derived. */ +const configOnlyTables = (user: Record) => ({ + sys_user: [user], + sys_member: [], + sys_user_position: [], + sys_position: [], + sys_position_permission_set: [], + sys_user_permission_set: [], + sys_permission_set: [], +}); + +function makeSink(): PlatformAdminConfigSink & { errors: string[]; warns: string[] } { + const errors: string[] = []; + const warns: string[] = []; + return { errors, warns, error: (m) => errors.push(m), warn: (m) => warns.push(m) }; +} + +let ambient: string | undefined; +let sink: ReturnType; + +beforeEach(() => { + ambient = process.env[ENV]; + delete process.env[ENV]; + resetPlatformAdminEmailMemo(); + resetLegacyPlatformAdminGrantReport(); + sink = makeSink(); + setPlatformAdminConfigSink(sink); +}); + +afterEach(() => { + if (ambient === undefined) delete process.env[ENV]; + else process.env[ENV] = ambient; + resetPlatformAdminEmailMemo(); + resetLegacyPlatformAdminGrantReport(); + setPlatformAdminConfigSink(undefined); +}); + +/** Set the variable and drop the memo, so each arm is read from its own value. */ +function declare(value: string | undefined): void { + if (value === undefined) delete process.env[ENV]; + else process.env[ENV] = value; + resetPlatformAdminEmailMemo(); +} + +describe('[#11663 L2] acceptance criterion — the configured, VERIFIED account', () => { + it('yields PLATFORM_ADMIN with the DECLARED capability set', async () => { + declare('a@b.c'); + const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'a@b.c', email_verified: true })); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + + expect(grants.posture).toBe('PLATFORM_ADMIN'); + // `platform_admin` LEADS the list — the ordering §6c establishes and that + // downstream consumers read as "the strongest position first". + expect(grants.positions[0]).toBe('platform_admin'); + expect(grants.permissions).toContain(ADMIN_FULL_ACCESS); + // [Choice 6A] The capability CONTENT is the spec's one declaration, not a + // second copy living in core. Equality both ways: a derived admin that + // carried MORE than the declaration would be a silent privilege widening, + // and one that carried less would be a silent narrowing. + expect(grants.systemPermissions.sort()).toEqual( + [...(ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? [])].sort(), + ); + }); + + it('answers the id-shaped predicate too, with no grant row in sight', async () => { + declare('a@b.c'); + const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'a@b.c', email_verified: true })); + // `hasPlatformAdminStanding` is a PROJECTION of the same derivation, so the + // second anchor reaches every id-shaped judge for free — that is the whole + // reason #10348-C consolidated them onto it before this leg ran. + await expect(hasPlatformAdminStanding(ql, 'usr_1')).resolves.toBe(true); + }); + + it('resolves the same standing through the full request path', async () => { + declare('a@b.c'); + const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'a@b.c', email_verified: true })); + const ctx = await resolveAuthzContext({ + ql, + headers: new Headers(), + getSession: async () => ({ user: { id: 'usr_1', email: 'a@b.c' }, session: {} }), + nowMs: NOW, + }); + expect(ctx.posture).toBe('PLATFORM_ADMIN'); + expect(ctx.positions).toContain('platform_admin'); + }); + + it('matches case-insensitively, and honours every declared entry of a list', async () => { + declare('First@Corp.Example, second@corp.example'); + for (const email of ['first@corp.example', 'SECOND@CORP.EXAMPLE']) { + const ql = makeQl(configOnlyTables({ id: 'usr_1', email, email_verified: 1 })); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + expect(grants.posture, email).toBe('PLATFORM_ADMIN'); + } + }); +}); + +describe('[#11663 L2] the fail-closed arms — zero config-derived admins', () => { + const verifiedOwner = { id: 'usr_1', email: 'a@b.c', email_verified: true }; + + it('UNSET yields no config-derived standing, and says nothing about it', async () => { + declare(undefined); + const ql = makeQl(configOnlyTables(verifiedOwner)); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + expect(grants.posture).toBe('MEMBER'); + expect(grants.positions).not.toContain('platform_admin'); + expect(grants.permissions).not.toContain(ADMIN_FULL_ACCESS); + expect(sink.errors).toEqual([]); + }); + + it('EMPTY / whitespace-only yields no config-derived standing', async () => { + for (const raw of ['', ' ', ',', ' , , ']) { + declare(raw); + const ql = makeQl(configOnlyTables(verifiedOwner)); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + expect(grants.posture, JSON.stringify(raw)).toBe('MEMBER'); + } + }); + + it('MALFORMED yields no config-derived standing — for EVERY entry — and is LOUD', async () => { + declare('a@b.c,nonsense'); + const ql = makeQl(configOnlyTables(verifiedOwner)); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + // ⛔ The valid entry does not survive its neighbour. Skip-and-continue here + // would hand this deployment a narrower administrator set than its operator + // declared, silently — which is the failure Choice 2B rules out by name. + expect(grants.posture).toBe('MEMBER'); + expect(sink.errors).toHaveLength(1); + expect(sink.errors[0]).toContain('nonsense'); + }); + + it('⛔ an UNVERIFIED account holding the configured address gets NOTHING', async () => { + // The arm the whole leg exists for: an attacker who registers the + // operator's address before the operator does must gain nothing by it. + for (const email_verified of [false, 0, '0', undefined]) { + declare('a@b.c'); + const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'a@b.c', email_verified })); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + expect(grants.posture, `email_verified=${JSON.stringify(email_verified)}`).toBe('MEMBER'); + } + }); + + it('an account whose address is not on the list gets nothing', async () => { + declare('a@b.c'); + const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'other@corp.example', email_verified: true })); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + expect(grants.posture).toBe('MEMBER'); + }); + + it('a principal with no sys_user row at all gets nothing', async () => { + declare('a@b.c'); + const ql = makeQl({ ...configOnlyTables({ id: 'someone_else', email: 'a@b.c', email_verified: true }) }); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + expect(grants.posture).toBe('MEMBER'); + }); +}); + +describe('⭐ [#11663 L2 pin P1] the derivation reads the STORED row, never the seeded email', () => { + it('a session payload carrying a configured address over a row that does NOT resolves non-admin', async () => { + declare('a@b.c'); + // The stored row says `impostor@corp.example`; the caller/session says + // `a@b.c` and wins for `grants.email` (RLS `current_user.email`), exactly as + // it is supposed to. Superuser standing must NOT follow it. + const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'impostor@corp.example', email_verified: true })); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW, seedEmail: 'a@b.c' }); + + expect(grants.email).toBe('a@b.c'); // the seed still wins where it should + expect(grants.posture).toBe('MEMBER'); // …and nowhere else + expect(grants.positions).not.toContain('platform_admin'); + }); + + it('and the same through the request path, where the session supplies the seed', async () => { + declare('a@b.c'); + const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'impostor@corp.example', email_verified: true })); + const ctx = await resolveAuthzContext({ + ql, + headers: new Headers(), + getSession: async () => ({ user: { id: 'usr_1', email: 'a@b.c' }, session: {} }), + nowMs: NOW, + }); + expect(ctx.posture).toBe('MEMBER'); + }); + + it('a VERIFIED stored match still resolves even when the seed disagrees', async () => { + // The control for the test above: the refusal must come from reading the + // stored row, not from the presence of a seed. + declare('a@b.c'); + const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'a@b.c', email_verified: true })); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { + nowMs: NOW, + seedEmail: 'something.else@corp.example', + }); + expect(grants.posture).toBe('PLATFORM_ADMIN'); + }); +}); + +describe('[#11663 L2 / P5] the legacy grant row is still honoured, loudly', () => { + const legacyTables = () => ({ + sys_user: [{ id: 'usr_1', email: 'legacy@corp.example', email_verified: true }], + sys_member: [], + sys_user_position: [], + sys_position: [], + sys_position_permission_set: [], + sys_user_permission_set: [ + { id: 'ups_1', user_id: 'usr_1', permission_set_id: 'pst_1', organization_id: null }, + ], + sys_permission_set: [{ id: 'pst_1', name: ADMIN_FULL_ACCESS, active: true }], + }); + + it('an unscoped admin_full_access grant still confers PLATFORM_ADMIN with no config at all', async () => { + declare(undefined); + const grants = await resolveUserAuthzGrants(makeQl(legacyTables()), 'usr_1', { nowMs: NOW }); + // Nothing is revoked in this leg — that is what makes it safe to land ahead + // of every deployment setting the variable. + expect(grants.posture).toBe('PLATFORM_ADMIN'); + }); + + it('logs the deprecation pointer once, naming the holder and the config line', async () => { + declare(undefined); + const ql = makeQl(legacyTables()); + await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + expect(sink.warns).toHaveLength(1); + expect(sink.warns[0]).toContain('usr_1'); + expect(sink.warns[0]).toContain(`${ENV}=legacy@corp.example`); + }); + + it('does NOT nag when the SAME user also resolves through the config anchor', async () => { + // Their standing no longer rests on the row, so there is nothing to + // re-anchor and nothing to say. + declare('legacy@corp.example'); + await resolveUserAuthzGrants(makeQl(legacyTables()), 'usr_1', { nowMs: NOW }); + expect(sink.warns).toEqual([]); + }); +}); + +describe('[#11663 L2] the sys_user read stays CONDITIONAL on config', () => { + const seeded = { nowMs: NOW, seedEmail: 'seeded@corp.example', seedPermissions: ['ai_seat'] }; + + it('a fully-seeded principal reads NO sys_user row when nothing is declared', async () => { + // This is the property that leaves the pinned batch-equivalence query + // multiset untouched for every deployment that has not adopted the config + // anchor: pin P2's short-circuit answers "not an admin" on an empty list + // BEFORE any row is looked at. + declare(undefined); + const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'a@b.c', email_verified: true })); + await resolveUserAuthzGrants(ql, 'usr_1', seeded); + expect(ql.calls.filter((c) => c.object === 'sys_user')).toHaveLength(0); + }); + + it('…and reads it exactly ONCE when administrators ARE declared', async () => { + declare('a@b.c'); + const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'a@b.c', email_verified: true })); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', seeded); + // Once, not twice: the config branch consumes the SAME memoized row the + // email fallback and the ai_seat synthesis do. + expect(ql.calls.filter((c) => c.object === 'sys_user')).toHaveLength(1); + expect(grants.posture).toBe('PLATFORM_ADMIN'); + }); +}); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 3423c2b235..712b2e9305 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -29,6 +29,7 @@ import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN, ADMIN_FULL_ACCESS, + ADMIN_FULL_ACCESS_CAPABILITIES, ORGANIZATION_ADMIN_GRANTS, } from '@objectstack/spec'; import type { AuthzPosture, TenancyPosture } from '@objectstack/spec/security'; @@ -37,6 +38,11 @@ import { postureEnforcesWall } from '@objectstack/spec/security'; import { resolveApiKeyAdmission } from './api-key.js'; import type { ApiKeyRefusalReason } from './api-key.js'; import { isGrantActive } from './grant-validity.js'; +import { + matchesConfiguredPlatformAdmin, + reportLegacyPlatformAdminGrant, + resolvePlatformAdminEmails, +} from './platform-admin.js'; import { derivePosture } from './posture-ladder.js'; import { isRowActive } from './row-active.js'; @@ -63,7 +69,9 @@ export interface ResolvedAuthzContext { /** * [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` + * `PLATFORM_ADMIN` (an unscoped `admin_full_access` grant, or — since #11663 + * L2 — a VERIFIED `sys_user.email` on the deployment's declared + * administrator list) > `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; @@ -362,7 +370,20 @@ export async function resolveUserAuthzGrants( // email OR did not seed `ai_seat` — a fully-seeded API-key principal never // touched the table, and the batch must not start (equivalence suite pins // the query multiset per fixture). - const needsUserRow = !grants.email || !grants.permissions.includes('ai_seat'); + // + // [#11663 L2] The CONFIG anchor adds a third reason to read `sys_user`: §6b's + // config branch compares the caller's STORED email against the declared + // administrator list, so the row is needed whenever that list is non-empty. + // Resolved here — before the batch — precisely so the read joins the SAME + // wave rather than opening a sixth sequential leg after it. The read stays + // CONDITIONAL ON CONFIG on purpose (pin P2: empty/unset config answers "not a + // platform admin" before touching any row), which is why a deployment that + // has not declared administrators issues byte-identically the same queries it + // issued before this leg — see the batch-equivalence goldens, which are + // unchanged for exactly that reason. + const platformAdminConfig = resolvePlatformAdminEmails(); + const needsUserRow = + !grants.email || !grants.permissions.includes('ai_seat') || platformAdminConfig.emails.length > 0; const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([ needsUserRow ? getUserRow() : Promise.resolve(undefined), tryFind(ql, 'sys_member', { user_id: userId }, 200), @@ -595,6 +616,51 @@ export async function resolveUserAuthzGrants( if (Object.keys(mergedTabs).length > 0) grants.tabPermissions = mergedTabs; } + // 6b-config. [#11663 L2] The DEPLOYMENT-CONFIG anchor for PLATFORM_ADMIN — + // the second route to the same `hasPlatformAdminGrant`, inside the same + // derivation site. Ruled bundle 1A/2B/3A/6A/7A (design comment + // 5394453215; maintainer acceptance 2026-08-25). Read `platform-admin.ts` + // before changing anything here; the reasoning for every branch is there. + // + // ⚠️ The comparison reads `userRow.email` — the caller's OWN STORED row — + // and never `grants.email`. `grants.email` is seeded from `opts.seedEmail` + // (a caller/session-supplied string that deliberately WINS over the stored + // read for RLS purposes), so deriving superuser standing from it would add + // a new escalation channel inside the change meant to close one. This is + // the single most important mechanical pin of this leg and it is pinned by + // test: "a session payload carrying a configured address over a sys_user + // row that does not resolves NON-admin". + // + // ADDITIVE, never subtractive: nothing above is revoked here (design §5 + // step 3). A deployment that has declared no administrators resolves + // exactly as it did — `platformAdminConfig.emails` is empty, the branch + // short-circuits before it looks at any row, and the legacy grant read + // above remains the only anchor. + const configConfersPlatformAdmin = + platformAdminConfig.emails.length > 0 + && matchesConfiguredPlatformAdmin(await getUserRow(), platformAdminConfig); + if (configConfersPlatformAdmin) { + // [Choice 6A] The capability CONTENT comes from the one declaration in + // `@objectstack/spec` — the same object plugin-security spreads into its + // `admin_full_access` permission-set entry — so the derived envelope and + // the declared set cannot drift. The NAME is pushed for the same reason + // §6b pushes `ps.name`: downstream `resolvePermissionSets` resolves object + // grants from the name, and a config-derived admin must carry the identical + // envelope a grant-derived one carries. + hasPlatformAdminGrant = true; + if (!grants.permissions.includes(ADMIN_FULL_ACCESS)) grants.permissions.push(ADMIN_FULL_ACCESS); + for (const p of ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []) { + if (!grants.systemPermissions.includes(p)) grants.systemPermissions.push(p); + } + } else if (hasPlatformAdminGrant) { + // [#11663 P5] Standing rests on the LEGACY grant row alone. Honoured — the + // migration is loud, not breaking — with a once-per-process pointer at the + // config line that re-anchors it. The row is read only if it was already + // loaded, so this notice never adds a query (and so never moves the pinned + // query multiset for a deployment that declared nothing). + reportLegacyPlatformAdminGrant({ userId, email: userRow?.email }); + } + // 6c. Project the derived platform_admin built-in role (leads the list). if (hasPlatformAdminGrant && !grants.positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)) { grants.positions.unshift(BUILTIN_IDENTITY_PLATFORM_ADMIN); @@ -631,9 +697,13 @@ export async function resolveUserAuthzGrants( * hasPlatformAdminStanding — the ID-SHAPED platform-admin question, asked in * exactly one place. * - * ADR-0068 D2 defines PLATFORM standing as one thing: an UNSCOPED + * ADR-0068 D2 defined PLATFORM standing as one thing: an UNSCOPED * (`organization_id = null`) `sys_user_permission_set` grant on the - * `admin_full_access` set, held **now**. A surface that only knows a user id — + * `admin_full_access` set, held **now**. Since #11663 L2 there is a SECOND + * anchor beside it — a `sys_user` row whose VERIFIED email is on the + * deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) — and + * this predicate answers for both, for free, because it is a projection rather + * than a copy (see below). A surface that only knows a user id — * a session-payload derivation, a platform-operator route gate, an * impersonation oracle — asks here, so it never has to re-read the grant tables * itself, which is the prohibition this module's header states. diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index 317331c3b0..e6fe8d31f4 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -4,8 +4,8 @@ * [cloud ADR-0024 D5.2] Break-glass — a write may never leave this environment * with ZERO administrators able to sign in. * - * FOUR write shapes can take the last administrator away, and this guard - * holds on all of them — they are one invariant, not four policies: + * FIVE write shapes can take the last administrator away, and this guard + * holds on all of them — they are one invariant, not five policies: * * 1. **`sys_user.banned = true`** (#5892) — how every *disable* lands: the * better-auth admin plugin's ban endpoint writes it, and @@ -41,6 +41,17 @@ * one click on a Setup row action that carries no visibility or condition * guard — which is why it needs its own two hooks rather than a wider filter * on the three tables above. + * 5. **moving a `sys_user` row off the DECLARED administrator list** (#11663 + * L2) — the first shape that revokes nothing stored. Since the + * platform-admin re-anchor, `resolveAuthzContext` also derives + * `PLATFORM_ADMIN` from a `sys_user` row whose own `email` is on the + * deployment's `OS_PLATFORM_OWNER_EMAIL` list AND whose `email_verified` + * reads verified. So an ordinary change-of-address write, or an + * `email_verified` reset from a re-verification flow, an import or an IdP + * re-assertion, takes that standing away — with no ban, no delete, and no + * grant table touched. `resolveAdminUserIds` counts those administrators + * too, through the resolver's OWN predicate, so the enumeration and the + * derivation cannot disagree about who they are. * * In the case that matters both are driven by an EXTERNAL system: nobody reads * the payload before it commits, so one mis-scoped IdP group or one over-broad @@ -276,7 +287,12 @@ import { MEMBERSHIP_ROLE_OWNER, } from '@objectstack/spec/identity'; import { SystemObjectName, SystemUserId } from '@objectstack/spec/system'; -import { isGrantActive, isRowActive } from '@objectstack/core'; +import { + isGrantActive, + isRowActive, + matchesConfiguredPlatformAdmin, + resolvePlatformAdminEmails, +} from '@objectstack/core'; import { isOrgAdminGrade } from './invitation-role-cap.js'; @@ -345,7 +361,8 @@ type GuardedOp = | 'grant-update' | 'grant-delete' | 'permission-set-update' - | 'permission-set-delete'; + | 'permission-set-delete' + | 'user-standing-update'; interface OpWords { /** Reads after "Refusing this …". */ @@ -425,6 +442,18 @@ const OP_WORDS: Record = { subject: 'permission sets', table: SystemObjectName.PERMISSION_SET, }, + // [#11663 L2] Deliberately generic wording: ONE payload can carry an address + // change and an `email_verified` reset together, and the refusal has to read + // correctly for either alone as well as for both. `standingOrigin` carries + // the sys_user-specific advice. + 'user-standing-update': { + noun: 'account change', + verb: 'change', + gerund: 'changing', + Verb: 'Change', + subject: 'accounts', + table: SystemObjectName.USER, + }, }; /** @@ -446,6 +475,14 @@ function standingOrigin(table: string, noun: string): string { 'admin is derived from is the last thing an environment gives up, not the first.' ); } + if (table === SystemObjectName.USER) { + return ( + `If the ${noun} was a change of address or an email re-verification, the account it moves ` + + 'is one this deployment names as a platform administrator in OS_PLATFORM_OWNER_EMAIL. ' + + 'Declare the new address there first (comma-separated for several) and roll the process, ' + + 'then make the change — the configuration is the anchor, and this row only matches it.' + ); + } return ( `If the ${noun} came from an identity provider, the SCIM group mapping is too broad — fix ` + 'the IdP group, not this guard.' @@ -506,7 +543,11 @@ function toId(value: unknown): string | undefined { * updated and `patch` is the caller's payload, applied over each row. */ interface PendingStandingWrite { - /** `sys_member`, `sys_user_permission_set` or `sys_permission_set` (#6084). */ + /** + * `sys_member`, `sys_user_permission_set`, `sys_permission_set` (#6084) or — + * since #11663 L2 — `sys_user`, whose `email` / `email_verified` pair is the + * config anchor's half of the derivation. + */ table: string; /** Ids of the rows this one write addresses (by-id, or the predicate's matches). */ ids: Set; @@ -613,6 +654,37 @@ export const GRANT_STANDING_KEYS = [ */ export const PERMISSION_SET_STANDING_KEYS = ['name', 'active'] as const; +/** + * [#11663 L2] Same, for `sys_user` — the FIFTH write shape, and the first one + * that does not revoke anything stored. + * + * The platform-admin re-anchor gave `resolveAuthzContext` a second anchor + * beside the unscoped `admin_full_access` grant: a `sys_user` row whose + * `email` is on the deployment's declared administrator list AND whose + * `email_verified` reads verified. Both columns are therefore derivation + * columns now (`ADMIN_STANDING_SURFACE.sys_user`, reclassified from + * `reads-only` in the same landing), and each is reachable through an + * ORDINARY write: + * + * - `email` — a change-of-address write moves the row off the configured + * list, and the standing goes with it. Nothing about the write looks + * administrative. + * - `email_verified` — resetting it to false (a re-verification flow, an + * import, an IdP that re-asserts the claim) takes the standing away while + * leaving the address in place. This is the sharp one: the column exists + * precisely so an UNVERIFIED account holding a configured address confers + * nothing, which means writing `false` to it is a revocation. + * + * Neither is a ban and neither is a delete, so the two `sys_user` halves that + * predate this list (write shapes 1 and 2) never see them — `guardBan` filters + * on `banned` and `guardDelete` only fires on a delete. + * + * `id` and `ai_access` are excluded below rather than listed; `banned` is + * absent from BOTH because the resolver does not read it (the ban half of this + * guard judges it for its own, different reason). + */ +export const USER_STANDING_KEYS = ['email', 'email_verified'] as const; + /** * [#8734] The three lists above, keyed by the table each one judges — the shape * the correspondence gate consumes. @@ -634,6 +706,7 @@ export const STANDING_KEYS_BY_TABLE: Readonly> [SystemObjectName.MEMBER]: MEMBER_STANDING_KEYS, [USER_PERMISSION_SET]: GRANT_STANDING_KEYS, [SystemObjectName.PERMISSION_SET]: PERMISSION_SET_STANDING_KEYS, + [SystemObjectName.USER]: USER_STANDING_KEYS, }; /** @@ -703,6 +776,19 @@ export const STANDING_KEY_EXCLUSIONS: Readonly, keys: readonly string[]): boolean { @@ -838,6 +924,54 @@ export function registerLastAdminGuard( if (uid) ids.add(uid); } + // 3) [#11663 L2] Config-anchored platform admins — a `sys_user` row whose + // own `email` is on the deployment's declared administrator list and + // whose `email_verified` reads verified. This half exists because the + // enumeration must answer the SAME question `resolveAuthzContext` does: + // an administrator this count cannot see is an administrator the guard + // would happily let a write take away, and the whole file is one + // invariant off one enumeration. + // + // The predicate is imported, never re-spelled — `matchesConfiguredPlatformAdmin` + // is the resolver's own, so the normalization, the list parse and the + // fail-closed verified check cannot drift between the two readers. With + // no variable declared, `emails` is empty and this costs no read at all, + // which is why every deployment that has not adopted the config anchor + // sees this guard behave exactly as it did. + // + // ⛔ NOT re-priced here: which of this guard's REFUSALS become obsolete + // once no runtime write can empty the platform-admin population is a + // separate, reviewed step (design §5 step 5). This addition is only the + // half that keeps the count honest — it can make the guard refuse MORE, + // never less. + // The `where` pushes the NORMALIZED addresses down, and the predicate + // re-checks each returned row in JS — the same two-step + // `auth-manager.ts`'s invitation lookup argues for, for the same two + // reasons: better-auth's `internalAdapter.createUser` lowercases + // `user.email` before storing it (and every producer in this repo does + // the same), so the pushed-down filter is exact on a case-SENSITIVE + // store; while a case-FOLDING collation (MySQL's default) returns extra + // rows, which the predicate then drops. + const platformAdminConfig = resolvePlatformAdminEmails(); + if (platformAdminConfig.emails.length > 0) { + const declared = await scan(op, SystemObjectName.USER, { + where: { email: { $in: [...platformAdminConfig.emails] } }, + fields: ['id', 'email', 'email_verified'], + }); + for (const raw of declared) { + // Simulated exactly like the grant rows above: a pending write can + // delete the row, move its `email` off the list, or reset + // `email_verified` — each is RE-TESTED through the resolver's own + // predicate rather than assumed, because the scan's `where` only proved + // what the address was BEFORE the write. + const u = applyPending(raw, pending, SystemObjectName.USER); + if (!u) continue; + if (!matchesConfiguredPlatformAdmin(u, platformAdminConfig)) continue; + const uid = toId(u.id); + if (uid) ids.add(uid); + } + } + // The legacy service account is not loginable — it can never be the escape // hatch, so it must not be counted as one. ids.delete(SystemUserId.SYSTEM); @@ -1345,6 +1479,27 @@ export function registerLastAdminGuard( ); }; + /** + * [#11663 L2] The FIFTH write shape: an ordinary `sys_user` profile write + * that moves the row off the deployment's declared administrator list + * (`email`) or un-verifies it (`email_verified`). + * + * Registered beside `guardBan` on the same event and object rather than + * folded into it, because the two ask different questions of the same table: + * `guardBan` fires on a payload that turns `banned` ON and nothing else, and + * a change-of-address is not a ban. A payload touching neither standing key + * (USER_STANDING_KEYS) provably cannot move the enumeration, so every + * ordinary profile write — name, avatar, locale, `ai_access` — still costs + * this guard no reads at all. + */ + const guardUserStandingUpdate = async (rawCtx: unknown): Promise => { + const ctx = ctxOf(rawCtx); + if (ctx.object !== SystemObjectName.USER) return; + const data = (ctx.input?.data ?? {}) as Record; + if (!touchesAny(data, USER_STANDING_KEYS)) return; + await enforceStanding('user-standing-update', SystemObjectName.USER, ctx.input, data); + }; + const guardPermissionSetDelete = async (rawCtx: unknown): Promise => { const ctx = ctxOf(rawCtx); if (ctx.object !== SystemObjectName.PERMISSION_SET) return; @@ -1391,6 +1546,11 @@ export function registerLastAdminGuard( priority: 20, packageId, }); + engine.registerHook('beforeUpdate', guardUserStandingUpdate, { + object: SystemObjectName.USER, + priority: 20, + packageId, + }); engine.registerHook('beforeUpdate', guardPermissionSetUpdate, { object: SystemObjectName.PERMISSION_SET, priority: 20, @@ -1403,8 +1563,9 @@ export function registerLastAdminGuard( }); logger?.info( - '[LastAdminGuard] last-administrator guard registered on sys_user (ban + delete), ' + - 'sys_member and sys_user_permission_set (standing revocation), and sys_permission_set ' + + '[LastAdminGuard] last-administrator guard registered on sys_user (ban + delete, and the ' + + 'email/email_verified pair the deployment-config anchor derives from), sys_member and ' + + 'sys_user_permission_set (standing revocation), and sys_permission_set ' + '(the admin_full_access row every platform admin is derived from) — ADR-0024 D5.2', ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08ac498d5c..31f4ddcd83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,7 +361,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/setup: dependencies: @@ -383,7 +383,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/studio: dependencies: @@ -819,6 +819,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../spec + '@objectstack/types': + specifier: workspace:* + version: link:../types zod: specifier: ^4.4.3 version: 4.4.3 From 16cae4a13e02e4b0e1574ca4480e20a284117ba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 05:42:30 +0000 Subject: [PATCH 2/5] wip: tests for the config anchor --- ...ve-authz-context.batch-equivalence.test.ts | 36 +- .../last-admin-guard.config-anchor.test.ts | 321 ++++++++++++++++++ 2 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts index ed8f3955d0..5e353a21fb 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts @@ -38,7 +38,8 @@ */ import { readFileSync } from 'node:fs'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { resetPlatformAdminEmailMemo } from './platform-admin.js'; import { resolveUserAuthzGrants } from './resolve-authz-context.js'; import type { ResolveUserAuthzGrantsOptions } from './resolve-authz-context.js'; @@ -403,6 +404,39 @@ const BATCHED_LEGS: Record = { const asMultiset = (calls: RecordedCall[]) => calls.map((c) => JSON.stringify(c)).sort(); describe('[#10825] batched resolveUserAuthzGrants — equivalence with the sequential reads', () => { + /** + * [#11663 L2] These goldens are captured from a deployment that declares NO + * platform administrators, and they must stay that way. + * + * The config anchor added a third reason to read `sys_user` — but a + * CONDITIONAL one: with `OS_PLATFORM_OWNER_EMAIL` unset the derivation + * answers "not an admin" on an empty list before it looks at any row (pin + * P2), so every query below is byte-identical to what the sequential + * implementation issued and NOT ONE golden moved for this leg. That is a real + * property of the change, and it is only worth anything if the suite pins the + * condition it rests on: an ambient value in a CI worker would silently add a + * `sys_user` read to `seeded-permissions-and-email` and turn a green + * differential control into a mystery. So the variable is cleared here rather + * than assumed absent, and the memo — keyed on the raw string — is dropped + * with it on both sides. + * + * ⛔ If a future leg makes the read unconditional, the golden MOVES and the + * move is written down in the PR that makes it. It is never re-captured to + * agree with new output. + */ + const ENV = 'OS_PLATFORM_OWNER_EMAIL'; + let ambientOwnerEmail: string | undefined; + beforeAll(() => { + ambientOwnerEmail = process.env[ENV]; + delete process.env[ENV]; + resetPlatformAdminEmailMemo(); + }); + afterAll(() => { + if (ambientOwnerEmail === undefined) delete process.env[ENV]; + else process.env[ENV] = ambientOwnerEmail; + resetPlatformAdminEmailMemo(); + }); + it('the fixture matrix and the captured goldens have not drifted apart', () => { expect(FIXTURES.map((f) => f.name).sort()).toEqual(Object.keys(GOLDEN).sort()); expect(Object.keys(BATCHED_LEGS).sort()).toEqual(Object.keys(GOLDEN).sort()); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts new file mode 100644 index 0000000000..5a19349465 --- /dev/null +++ b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts @@ -0,0 +1,321 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11663 L2 / cloud ADR-0024 D5.2] The break-glass guard's FIFTH write shape: + * an ordinary `sys_user` profile write that moves the row off the deployment's + * declared administrator list. + * + * The platform-admin re-anchor gave `resolveAuthzContext` a second anchor + * beside the unscoped `admin_full_access` grant — a `sys_user` row whose own + * `email` is on `OS_PLATFORM_OWNER_EMAIL` AND whose `email_verified` reads + * verified. Two consequences this file pins, because they are the two halves of + * one invariant and each is silently wrong without the other: + * + * 1. **The enumeration must SEE those administrators.** `resolveAdminUserIds` + * counts them through the resolver's own `matchesConfiguredPlatformAdmin`, + * so an environment whose only administrator is config-derived is not read + * as an environment with none. + * 2. **The guard must JUDGE the writes that revoke them.** A change of address + * and an `email_verified` reset each take the standing away with no ban, no + * delete and no grant table touched — invisible to all four earlier halves. + * + * Same method as `last-admin-guard.test.ts` next door and for the same reason: + * a REAL {@link ObjectQL} engine over better-sqlite3 `:memory:`, so the engine + * dispatches the hook, the SQL builder compiles the `$in`, and sqlite decides + * how the booleans come back (`email_verified` stores 0/1 here, which is + * exactly the representation `isEmailVerifiedUserRow`'s allow-list exists for). + * A fake engine would put the fixture, not the product, in charge of which rows + * the guard sees. + * + * Reverse verification, recorded because the direction is not obvious: with + * `registerLastAdminGuard` NOT called, every refusal below is a write that + * SUCCEEDS — the row comes back with the new address, or with + * `email_verified = 0`. The `unguarded` cases at the bottom re-run it on the + * same fixtures rather than describing it. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; + +import { registerLastAdminGuard, USER_STANDING_KEYS, type LastAdminGuardEngine } from './last-admin-guard.js'; + +const ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const SYSTEM = { context: { isSystem: true } } as const; +const OWNER = 'owner@corp.example'; +const SECOND = 'second@corp.example'; + +const sysUser = { + name: 'sys_user', + label: 'User', + managedBy: 'better-auth', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + email: { name: 'email', type: 'text' as const }, + // The column the config anchor gates on. Declared `boolean`, so on this + // real sqlite database it stores as 0/1. + email_verified: { name: 'email_verified', type: 'boolean' as const }, + ai_access: { name: 'ai_access', type: 'boolean' as const }, + banned: { name: 'banned', type: 'boolean' as const, readonly: true }, + }, +}; + +const sysMember = { + name: 'sys_member', + label: 'Member', + managedBy: 'better-auth', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + role: { name: 'role', type: 'text' as const }, + }, +}; + +const sysPermissionSet = { + name: 'sys_permission_set', + label: 'Permission Set', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + label: { name: 'label', type: 'text' as const }, + active: { name: 'active', type: 'boolean' as const }, + }, +}; + +const sysUserPermissionSet = { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + permission_set_id: { name: 'permission_set_id', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + valid_from: { name: 'valid_from', type: 'datetime' as const }, + valid_until: { name: 'valid_until', type: 'datetime' as const }, + }, +}; + +let engines: ObjectQL[] = []; +let ambient: string | undefined; + +beforeEach(() => { + ambient = process.env[ENV]; + delete process.env[ENV]; + resetPlatformAdminEmailMemo(); +}); + +afterEach(async () => { + if (ambient === undefined) delete process.env[ENV]; + else process.env[ENV] = ambient; + resetPlatformAdminEmailMemo(); + const open = engines; + engines = []; + for (const e of open) { + try { await e.destroy(); } catch { /* noop */ } + } +}); + +/** Declare the deployment's administrators and drop the memo keyed on the raw value. */ +function declare(value: string | undefined): void { + if (value === undefined) delete process.env[ENV]; + else process.env[ENV] = value; + resetPlatformAdminEmailMemo(); +} + +async function boot(opts: { unguarded?: boolean } = {}): Promise { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const o of [sysUser, sysMember, sysPermissionSet, sysUserPermissionSet]) { + engine.registry.registerObject(o as never); + } + await engine.syncSchemas(); + if (!opts.unguarded) { + registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard' }); + } + return engine; +} + +async function seedUser( + engine: ObjectQL, + id: string, + email: string, + verified: boolean, +): Promise { + await engine.insert( + 'sys_user', + { id, name: id, email, email_verified: verified, banned: false }, + SYSTEM, + ); +} + +async function readUser(engine: ObjectQL, id: string): Promise | undefined> { + return (await engine.findOne( + 'sys_user', + { where: { id }, fields: ['id', 'email', 'email_verified'] }, + SYSTEM, + )) as Record | undefined; +} + +describe('[#11663 L2] the enumeration counts CONFIG-derived administrators', () => { + it('an environment whose only administrator is config-derived is not "empty"', async () => { + declare(OWNER); + const engine = await boot(); + await seedUser(engine, 'usr_owner', OWNER, true); + await seedUser(engine, 'usr_other', 'other@corp.example', true); + + // With the owner counted, deleting an ORDINARY user leaves an + // administrator behind and is allowed. If the enumeration could not see + // the config anchor it would read zero administrators here, and the + // bootstrap exemption would wave every write through — the failure mode + // #6084 already paid for once. + await expect(engine.delete('sys_user', { where: { id: 'usr_other' }, ...SYSTEM })).resolves.toBeDefined(); + expect(await readUser(engine, 'usr_other')).toBeFalsy(); + + // …and deleting the administrator themselves is refused. + await expect( + engine.delete('sys_user', { where: { id: 'usr_owner' }, ...SYSTEM }), + ).rejects.toThrow(/last administrator/i); + expect(await readUser(engine, 'usr_owner')).toBeTruthy(); + }); + + it('an UNVERIFIED account holding the configured address is NOT counted', async () => { + declare(OWNER); + const engine = await boot(); + await seedUser(engine, 'usr_owner', OWNER, false); + await seedUser(engine, 'usr_grant', 'granted@corp.example', true); + await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); + await engine.insert( + 'sys_user_permission_set', + { id: 'ups_1', user_id: 'usr_grant', permission_set_id: 'ps_a' }, + SYSTEM, + ); + + // The grant holder is the ONLY administrator: the unverified owner confers + // nothing, exactly as the resolver reads it. Deleting the grant holder must + // therefore be refused — if the unverified row were miscounted, this write + // would sail through and the environment would be left with nobody. + await expect( + engine.delete('sys_user', { where: { id: 'usr_grant' }, ...SYSTEM }), + ).rejects.toThrow(/last administrator/i); + }); +}); + +describe('[#11663 L2] the FIFTH write shape is judged', () => { + it('refuses a change of address that moves the last administrator off the list', async () => { + declare(OWNER); + const engine = await boot(); + await seedUser(engine, 'usr_owner', OWNER, true); + + await expect( + engine.update('sys_user', { id: 'usr_owner', email: 'personal@example.com' }, SYSTEM), + ).rejects.toThrow(/last administrator/i); + expect((await readUser(engine, 'usr_owner'))?.email).toBe(OWNER); + }); + + it('refuses an email_verified reset on the last administrator', async () => { + declare(OWNER); + const engine = await boot(); + await seedUser(engine, 'usr_owner', OWNER, true); + + await expect( + engine.update('sys_user', { id: 'usr_owner', email_verified: false }, SYSTEM), + ).rejects.toThrow(/last administrator/i); + // Still verified — the refusal has to leave the row as it was. + expect((await readUser(engine, 'usr_owner'))?.email_verified).toBeTruthy(); + }); + + it('names the CONFIGURATION as the remedy, not this guard', async () => { + declare(OWNER); + const engine = await boot(); + await seedUser(engine, 'usr_owner', OWNER, true); + await expect( + engine.update('sys_user', { id: 'usr_owner', email: 'personal@example.com' }, SYSTEM), + ).rejects.toThrow(/OS_PLATFORM_OWNER_EMAIL/); + }); + + it('ALLOWS the same write while a second administrator survives it', async () => { + declare(`${OWNER}, ${SECOND}`); + const engine = await boot(); + await seedUser(engine, 'usr_owner', OWNER, true); + await seedUser(engine, 'usr_second', SECOND, true); + + await expect( + engine.update('sys_user', { id: 'usr_owner', email: 'personal@example.com' }, SYSTEM), + ).resolves.toBeDefined(); + expect((await readUser(engine, 'usr_owner'))?.email).toBe('personal@example.com'); + }); + + it('ALLOWS it when the same user also holds the legacy grant', async () => { + // Standing that survives the write through the OTHER anchor is standing + // that survives — the enumeration is one function over both. + declare(OWNER); + const engine = await boot(); + await seedUser(engine, 'usr_owner', OWNER, true); + await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); + await engine.insert( + 'sys_user_permission_set', + { id: 'ups_1', user_id: 'usr_owner', permission_set_id: 'ps_a' }, + SYSTEM, + ); + + await expect( + engine.update('sys_user', { id: 'usr_owner', email_verified: false }, SYSTEM), + ).resolves.toBeDefined(); + }); + + it('costs NO reads for an ordinary profile write', async () => { + // The cheap-path pin. A payload touching neither standing key provably + // cannot move the enumeration, so `name` / `ai_access` edits — every + // profile save in the product — never pay for one. + declare(OWNER); + const engine = await boot(); + await seedUser(engine, 'usr_owner', OWNER, true); + expect(USER_STANDING_KEYS).toEqual(['email', 'email_verified']); + await expect( + engine.update('sys_user', { id: 'usr_owner', name: 'Renamed', ai_access: true }, SYSTEM), + ).resolves.toBeDefined(); + }); + + it('is inert when the deployment declares no administrators', async () => { + // Every deployment that has not adopted the config anchor sees this guard + // behave exactly as it did: `emails` is empty, the enumeration reads no + // `sys_user` rows for it, and there is no config-derived standing to lose. + declare(undefined); + const engine = await boot(); + await seedUser(engine, 'usr_owner', OWNER, true); + await expect( + engine.update('sys_user', { id: 'usr_owner', email: 'personal@example.com' }, SYSTEM), + ).resolves.toBeDefined(); + }); +}); + +describe('[#11663 L2] reverse verification — the same writes on an UNGUARDED engine', () => { + it('a change of address succeeds and takes the standing with it', async () => { + declare(OWNER); + const engine = await boot({ unguarded: true }); + await seedUser(engine, 'usr_owner', OWNER, true); + await expect( + engine.update('sys_user', { id: 'usr_owner', email: 'personal@example.com' }, SYSTEM), + ).resolves.toBeDefined(); + expect((await readUser(engine, 'usr_owner'))?.email).toBe('personal@example.com'); + }); + + it('an email_verified reset succeeds', async () => { + declare(OWNER); + const engine = await boot({ unguarded: true }); + await seedUser(engine, 'usr_owner', OWNER, true); + await expect( + engine.update('sys_user', { id: 'usr_owner', email_verified: false }, SYSTEM), + ).resolves.toBeDefined(); + expect((await readUser(engine, 'usr_owner'))?.email_verified).toBeFalsy(); + }); +}); From f4016a26314a5f214bc0ce33607446f5861f23c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 05:49:51 +0000 Subject: [PATCH 3/5] wip: changeset, vitest alias, tsconfig paths --- .changeset/platform-admin-config-anchor.md | 16 +++++++++++++ packages/core/tsconfig.json | 26 +++++++++++++++++++--- packages/core/vitest.config.ts | 24 ++++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 .changeset/platform-admin-config-anchor.md diff --git a/.changeset/platform-admin-config-anchor.md b/.changeset/platform-admin-config-anchor.md new file mode 100644 index 0000000000..846cfb9acd --- /dev/null +++ b/.changeset/platform-admin-config-anchor.md @@ -0,0 +1,16 @@ +--- +'@objectstack/core': minor +'@objectstack/plugin-auth': minor +--- + +`PLATFORM_ADMIN` can now be anchored on deployment CONFIGURATION instead of a stored grant row: an account whose `sys_user.email` is on `OS_PLATFORM_OWNER_EMAIL` **and** whose `email_verified` reads verified resolves `PLATFORM_ADMIN` with the declared `admin_full_access` capability set, derived live on each authorization resolution (#11663 leg L2, design accepted 2026-08-25 as bundle 1A/2B/3A/4A/5A/6A/7A). + +**Additive — nothing is revoked.** The legacy unscoped `admin_full_access` grant still confers exactly as it did; a holder whose standing rests on the row alone now gets a once-per-process pointer at the configuration line that re-anchors them. A deployment that has declared no administrators resolves byte-identically to before: the config list is empty, the derivation answers "not an admin" before it reads any row, and the pinned batch-equivalence query multiset is unchanged. + +**The variable takes a list.** `OS_PLATFORM_OWNER_EMAIL` accepts one address or a comma-separated list of them — one normalization (`trim().toLowerCase()`), duplicates collapsed, blank entries dropped. ⛔ Any entry that is not an address **fails the whole variable closed** with a loud refusal naming it, rather than being skipped: silently dropping a typo would leave a narrower administrator set than the operator declared, with nothing anywhere to notice. Unset, blank or refused all mean **zero** config-derived administrators. + +**Verified-email match only.** An unverified account holding a configured address confers nothing, and an ABSENT `email_verified` column reads unverified. The match reads the caller's own **stored** `sys_user` row, never the caller-supplied session email. + +New exports from `@objectstack/core`: `resolvePlatformAdminEmails`, `parsePlatformAdminEmails`, `matchesConfiguredPlatformAdmin`, `normalizePlatformAdminEmail`, `PLATFORM_ADMIN_EMAIL_SEPARATOR`, `ADMIN_STANDING_NON_TABLE_INPUTS` and the test hooks beside them. `@objectstack/core` now depends on `@objectstack/types` (measured acyclic: `types` depends only on `spec`). + +`@objectstack/plugin-auth`'s break-glass guard follows the derivation, as it must: `ADMIN_STANDING_SURFACE.sys_user` is reclassified `derives`, the last-administrator enumeration counts config-derived administrators through the resolver's own predicate, and a fifth write shape is judged — a change of address or an `email_verified` reset that would leave the environment with no administrator is refused, naming the configuration as the remedy. An ordinary profile write still costs the guard no reads. diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 2131d6aa2b..c94397c632 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,9 +2,29 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", - "types": ["node"] + // [#11663 L2] Widened from `./src` as a CONSEQUENCE of the `paths` rule + // below, exactly as `packages/plugins/plugin-security/tsconfig.json` + // documents (#11184, itself following `packages/rest` / #9960): + // redirecting `@objectstack/types` to its source puts + // `packages/types/src/**` into this program, and `rootDir` is enforced over + // every program file even under `--noEmit`. `..` (= `packages/`) is the + // directory that contains every file in the program. Emit is unaffected: + // this package builds with tsup. + "rootDir": "..", + "types": ["node"], + // [#11663 L2] `@objectstack/types` is imported as a VALUE by + // `src/security/platform-admin.ts` (`resolvePlatformOwnerEmail`, + // `isEmailVerifiedUserRow`). Without this rule its TYPES resolve through + // the workspace link to `dist/*.d.ts` — a build artifact — so a stale dist + // would make this package's type verdict a function of build state rather + // than of the source in the checkout (`check:type-source-resolution` + // refuses exactly that). Anchored to the bare specifier only: nothing here + // imports an `@objectstack/types/*` subpath, and a `paths` target matching + // nothing on disk would silently fall back to node resolution. + "paths": { + "@objectstack/types": ["../types/src/index.ts"] + } }, "include": ["src/**/*"], - "exclude": [] + "exclude": [] } diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 782c6a40a4..4a1f07e28a 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,10 +1,34 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ test: { globals: true, environment: 'node', }, + resolve: { + // [#11663 L2] `security/platform-admin.ts` imports `@objectstack/types` as + // a VALUE — `resolvePlatformOwnerEmail` (the live env read) and + // `isEmailVerifiedUserRow` (the fail-closed verified allow-list) — so the + // suites over it must read the producer's SOURCE in this checkout rather + // than the workspace link's `dist/`. The loud failure (a missing export) is + // the mild half; a `dist/` merely BEHIND runs GREEN against the + // dependency's old behaviour and says nothing at all — and the behaviour in + // question here is which stored `email_verified` representations count as + // verified, i.e. exactly the predicate that decides who is a superuser. + // `check:test-source-alias` refuses precisely that shape. + // + // Array form with an anchored pattern, deliberately: the object form + // matches by PREFIX, so a bare key with a FILE replacement would also + // swallow any subpath import and resolve it to `…/src/index.ts/` + // (ENOTDIR) at run time, in a config that looks right. + alias: [ + { + find: /^@objectstack\/types$/, + replacement: path.resolve(__dirname, '../types/src/index.ts'), + }, + ], + }, }); From 2a53b4b2116350233fd8d513f4ce196baaed4148 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 06:38:11 +0000 Subject: [PATCH 4/5] test(core): make the platform-admin-config double refuse top-level combinators check:where-matcher graded the new fixture's matches() as silently wrong: with no combinator branch it read $or as a field name, compared row.$or (undefined) against the array and excluded the row, leaving the suite asserting on an empty result with nothing erroring. Refuses instead of implementing, which is what most of this repo's conforming doubles do and what the sibling batch-equivalence double already spells. $in stays supported: it is a per-field value operator the resolver really issues, not a top-level combinator. --- ...uthz-context.platform-admin-config.test.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts index 2e5289e868..72e47368d0 100644 --- a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts +++ b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts @@ -43,11 +43,34 @@ const NOW = Date.parse('2026-08-29T00:00:00.000Z'); interface Recorded { object: string; where: unknown } -/** A minimal ObjectQL double that records the reads it served. */ +/** + * A minimal ObjectQL double that records the reads it served. + * + * Its `matches` REFUSES every top-level `$` key rather than implementing one. + * The resolver issues no combinator query on this path, so the alternative to + * a throw is not a combinator implementation — it is a matcher that reads + * `$or` as an ordinary FIELD NAME, compares `row.$or` (undefined) against the + * array, matches nothing, and leaves the suite asserting on an empty result + * with nothing erroring. `check:where-matcher` grades exactly that shape, and + * refusing is what most of this repo's conforming doubles do — including the + * sibling double in `resolve-authz-context.batch-equivalence.test.ts`, whose + * spelling this copies verbatim. + * + * ⛔ Do not "fix" a future red here by teaching this double `$or`/`$and`: a + * test fixture that grows query-engine semantics is a second, unreviewed + * implementation of the driver's filter contract. If the resolver ever does + * issue a combinator query on this path, that is a change worth seeing fail + * loudly first. + * + * `$in` is untouched by the refusal and stays supported: it appears in VALUE + * position (`{ email: { $in: [...] } }`), which is a per-field operator the + * resolver really does issue, not a top-level combinator. + */ function makeQl(tables: Record>>) { const calls: Recorded[] = []; const matches = (row: Record, where: any): boolean => Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); return row[k] === v; }); From 9e9d05365ed177670ae527c740a0dd3dc1c29d0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 06:47:47 +0000 Subject: [PATCH 5/5] docs(core): the sys_permission_set standing reason named the position, not the row Two defects in one sentence, both pre-existing on main: - it said the row `platform_admin` is resolved by name; the row is `admin_full_access` and `platform_admin` is the POSITION that row derives (resolve-authz-context.ts:594 matches the row, :665-666 unshifts the position); - 'un-makes every platform admin at once' stopped being true for a configured deployment: the config anchor sets the same standing off the caller's own sys_user row and never reads this table. A flat replacement would only swap which half is wrong, so the reason is now conditional and states the condition -- true whether or not OS_PLATFORM_OWNER_EMAIL is declared. Reason string only; role, columns and every executable path are untouched. --- packages/core/src/security/admin-standing-surface.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index 43101cf7f6..852bd180b2 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -107,9 +107,15 @@ export const ADMIN_STANDING_SURFACE: Readonly sys_permission_set: { role: 'derives', reason: - 'The row `platform_admin` is resolved BY NAME from (§6b). Renaming it, deleting it or ' - + 'switching it off (ADR-0049 `active`, read here since #8613) un-makes every platform ' - + 'admin at once, with no identity table touched.', + 'The row `admin_full_access` is resolved BY NAME from (§6b) — `platform_admin` is the ' + + "POSITION that row derives, not the row's own name. Renaming it, deleting it or switching " + + 'it off (ADR-0049 `active`, read here since #8613) un-makes every GRANT-derived platform ' + + 'admin at once, with no identity table touched. ⚠️ It does NOT un-make a CONFIG-derived ' + + 'one (§6b-config, #11970): that route sets the same standing from ' + + "`ADMIN_FULL_ACCESS_CAPABILITIES` in `@objectstack/spec` and matches the caller's own " + + 'stored `sys_user` row, so it touches an identity table and never reads this one. With ' + + '`OS_PLATFORM_OWNER_EMAIL` unset the first sentence is the whole truth; with it declared, ' + + 'this row stops being the single point that un-makes every administrator.', columns: [ 'id', 'name',