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/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..852bd180b2 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,17 +97,25 @@ 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: { 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', @@ -146,12 +168,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 +215,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.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/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..72e47368d0 --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts @@ -0,0 +1,345 @@ +// 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. + * + * 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; + }); + 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/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'), + }, + ], + }, }); 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(); + }); +}); 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 a3aa6f2707..47ebeee4f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -828,6 +828,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