diff --git a/.changeset/18412-platform-admin-standing-audit.md b/.changeset/18412-platform-admin-standing-audit.md new file mode 100644 index 00000000000..e49e6ed14bd --- /dev/null +++ b/.changeset/18412-platform-admin-standing-audit.md @@ -0,0 +1,17 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/plugin-audit": minor +--- + +The walled boot records platform-admin standing on the existing audit ledger, so «who held administrator standing, and from when» survives the move off the stored grant row (#18412). + +Platform-admin standing moved from a **stored grant row** to **config-derived, request-time resolution** (#11663 re-anchor, ADR-0131). The row carried its own history; config carries none. After the migration the only trace of a grant or a revocation was a change to `OS_PLATFORM_OWNER_EMAIL` plus a restart — the product keeps no environment-variable history and an auditor cannot read one. `sys_audit_log` recorded the ACTIONS all along; what had no writer at all was the **basis** of the authority behind them. + +The answer was already being computed and thrown away: `resolvePlatformAdminStanding` builds the per-entry summary at every walled boot and the bootstrap logs it at `info`. + +- **`@objectstack/plugin-audit`** — `sys_audit_log.action` declares one new value, `platform_admin_standing_change`, WRITER-FIRST (the only way a value is allowed onto that enum). Its rows appear on the shipped, unfiltered `recent` and `all_events` views; ⛔ no new list view, ⛔ no new object, ⛔ no new configuration key. +- **`@objectstack/plugin-security`** — the walled bootstrap compares the resolved standing against the last snapshot already on the ledger and writes **one entry per CHANGE of standing**, plus the **first-boot baseline**. A restarted rig writes nothing. Each row carries, per declared entry, the declared spelling, whether an account exists, whether it is verified, and which user id holds standing; `old_value` and `new_value` state both sides of the delta, and `old_value` is null on the baseline row and only there. +- **The `single` posture is untouched.** It still promotes the first registrant and still writes a durable grant row, so the durability this restores is walled-posture-specific. +- ⭐ **`organization_id` is NULL on this row, deliberately and by maintainer ruling** (2026-09-18, director batch #153 item 2). The record is deployment-level by construction: ADR-0131 §1.5 rejects inventing a platform organization in its own words («it is the natural repair and the wrong one … exists only to give NULL a new name»), a tenant id would file a whole-deployment fact behind one tenant's wall, and the first-boot baseline is written before any `sys_organization` row exists at all. This follows the tree's four existing deployment-level audit writers, and is the shape ADR-0131 D7 will later make structural by dropping the column. The exception is recorded beside the write, on the card, and in a pin — ⛔ it is not a gap waiting to be repaired. +- **Nothing here widens who holds standing or what standing permits.** The derivation site is untouched; this adds a RECORD of authority, never a grant of it. +- **Best-effort, and never fatal to boot.** A deployment that never mounted the optional `@objectstack/plugin-audit` skips silently — an unmounted ledger is a composition choice, not a fault. A ledger read that is REFUSED writes nothing and says so: «cannot tell» is not «first boot», and reading it that way would file a fresh baseline on every restart. A mounted ledger whose insert fails reports a durability degradation on the `error` channel. diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts index 762a6233d73..d5f87ae7883 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts @@ -79,6 +79,11 @@ const ACTIONS_WITH_WRITERS: ReadonlyArray = { login: "login", logout: "logout", config_change: "config_change", - import: "import" + import: "import", + platform_admin_standing_change: "platform_admin_standing_change" } }, user_id: { diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index ebb9cffe404..964c47bf678 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -34,7 +34,8 @@ export const esESObjects: NonNullable = { login: "Inicio de sesión", logout: "Cierre de sesión", config_change: "Cambio de configuración", - import: "Importar" + import: "Importar", + platform_admin_standing_change: "Cambio de acceso de administrador de plataforma" } }, user_id: { diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index 3d5e41290c0..4eb57cf6f43 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -34,7 +34,8 @@ export const jaJPObjects: NonNullable = { login: "ログイン", logout: "ログアウト", config_change: "構成変更", - import: "インポート" + import: "インポート", + platform_admin_standing_change: "プラットフォーム管理者権限の変更" } }, user_id: { diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index f33475c9ba6..704987edb7e 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -34,7 +34,8 @@ export const zhCNObjects: NonNullable = { login: "登录", logout: "登出", config_change: "配置变更", - import: "导入" + import: "导入", + platform_admin_standing_change: "平台管理员权限变更" } }, user_id: { diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 886334b89c5..99f9fdc6cb9 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -90,7 +90,151 @@ import { reportSeedWriteRefusals, type SeedWriteRefusals, } from './per-organization-catalog.js'; -import { resolvePlatformAdminStanding } from './platform-admin-service.js'; +import { + resolvePlatformAdminStanding, + type PlatformAdminStandingEntry, +} from './platform-admin-service.js'; +import { + buildPlatformAdminStandingRow, + platformAdminStandingChanged, + platformAdminStandingSnapshot, + PLATFORM_ADMIN_STANDING_ACTION, + PLATFORM_ADMIN_STANDING_LEDGER, + readRecordedStandingSnapshot, + serializePlatformAdminStandingSnapshot, +} from './platform-admin-standing-audit.js'; + +/** + * The order the standing-audit read states TO THE DRIVER. + * + * `created_at` and not `id`: `sys_audit_log` ids carry no ordering, while + * `created_at` is the field every shipped list view on that object already + * sorts by, and it is the column the row's own meaning rests on. The read is + * capped at one row, and a capped read without an order returns whichever row + * that driver produced first — see {@link tryFind}. + */ +const STANDING_AUDIT_SCAN_ORDER: { field: string; order: 'asc' | 'desc' }[] = [ + { field: 'created_at', order: 'desc' }, +]; + +/** + * [#18412] Record a CHANGE of platform-admin standing on the existing audit + * ledger, and write nothing when nothing changed. + * + * ## Three states, and the third is the one a two-valued read would lose + * + * - the ledger is not mounted → SKIP, silently. `sys_audit_log` belongs to the + * OPTIONAL `@objectstack/plugin-audit`; a host that never mounted it + * (`serve --preset minimal`, an EE host that composes no audit) has not + * FAILED to write a row, it declined to have a ledger. Attempting the insert + * would throw and be reported as a degradation on a deployment behaving + * exactly as composed — the #18368 lesson, one file over. + * - the ledger is mounted and the last row is readable → compare, and write + * only on a difference. + * - ⛔ the read was REFUSED → write NOTHING and say so. A refusal is not + * 「no record exists」: reading it that way writes a fresh baseline on every + * boot, which is precisely the per-boot noise the ruling rejected. The + * record stays silent for this boot and the next successful read catches up, + * because the comparison is against the ledger rather than against a memo. + * + * Best-effort throughout: an audit write must never be what stops a deployment + * booting, and this runs after the bootstrap has already done its work. + */ +async function recordPlatformAdminStandingChange( + ql: any, + standing: readonly PlatformAdminStandingEntry[], + logger?: BootstrapOptions['logger'] & { debug?: (message: string) => void }, +): Promise { + // ⛔ Ask before writing, and only an engine that ANSWERS licenses the skip. + // `getSchema` is an ObjectQL member, not an `IDataEngine` one, so an engine + // that does not carry it has told us NOTHING about the ledger — that case + // leaves the write attempted rather than skipped. + const getSchema = ql?.getSchema; + let ledgerSchema: any; + if (typeof getSchema === 'function') { + try { + ledgerSchema = ql.getSchema(PLATFORM_ADMIN_STANDING_LEDGER); + } catch { + ledgerSchema = undefined; + } + if (ledgerSchema == null) return; + } + + const declared: string[] = Array.isArray(ledgerSchema?.fields) + ? ledgerSchema.fields.map((f: any) => f?.name).filter(Boolean) + : ledgerSchema?.fields && typeof ledgerSchema.fields === 'object' + ? Object.keys(ledgerSchema.fields) + : []; + const declaresField = (field: string): boolean => declared.includes(field); + + let refused: unknown; + const previousRows = await tryFind( + ql, + PLATFORM_ADMIN_STANDING_LEDGER, + { action: PLATFORM_ADMIN_STANDING_ACTION }, + 1, + STANDING_AUDIT_SCAN_ORDER, + undefined, + (e) => { + refused = e; + }, + ); + if (refused !== undefined) { + const message = + '[security] platform-admin standing was NOT recorded on this boot: the audit ledger ' + + `(${PLATFORM_ADMIN_STANDING_LEDGER}) refused the read of the last recorded snapshot, so ` + + 'this boot cannot tell whether standing changed. ⛔ Nothing was written — writing a ' + + 'baseline here would file a fresh row on every boot. The next boot whose read succeeds ' + + 'records the current standing. Cause: ' + + String((refused as any)?.message ?? refused); + if (logger?.error) logger.error(message); + else logger?.warn?.(message); + return; + } + + const snapshot = platformAdminStandingSnapshot(standing); + const serialized = serializePlatformAdminStandingSnapshot(snapshot); + const previousSerialized = readRecordedStandingSnapshot(previousRows[0]); + if (!platformAdminStandingChanged(previousSerialized, serialized)) { + // ⛔ The card id stays in this comment and out of the STRING: a runtime + // line reaches operators, who have no tracker to resolve `#NNNN` against + // (#18412; `check:doc-authoring`). + logger?.debug?.( + '[security] platform-admin standing is unchanged since the last recorded entry — no ' + + 'audit row written. One entry per CHANGE of standing is the recorded shape.', + ); + return; + } + + const row = buildPlatformAdminStandingRow({ + snapshot, + previousSerialized, + declaresOrganizationId: declaresField('organization_id'), + declaresActor: declaresField('actor'), + }); + // ⛔ Through this file's ONE write door, not a second `ql.insert` beside it. + // `tryInsert` already threads the system execution context every write here + // needs, and a second door with identical semantics would be a second place + // for that context to be forgotten — it is also a second row on the + // tenant-audit write-call-site census for one write. + let insertRefusal: unknown; + await tryInsert(ql, PLATFORM_ADMIN_STANDING_LEDGER, row, undefined, (e) => { + insertRefusal = e; + }); + if (insertRefusal !== undefined) { + // The ledger IS mounted (or could not be asked) and the insert still + // failed, which is AGENTS.md's durability degradation to the letter: the + // deployment's administrators just changed and the record that was + // supposed to outlive the process is missing. Loud, and never fatal. + const message = + '[security] platform-admin standing CHANGED and the audit row was NOT written — the ' + + 'durable record of who administers this deployment is missing for this change, and ' + + 'nothing retries it. Boot itself is unaffected. Cause: ' + + String((insertRefusal as any)?.message ?? insertRefusal); + if (logger?.error) logger.error(message); + else logger?.warn?.(message); + } +} interface BootstrapOptions { /** Logger from PluginContext. */ @@ -226,6 +370,13 @@ async function tryFind( limit = 100, orderBy?: { field: string; order: 'asc' | 'desc' }[], offset?: number, + // ⛔ "Refused" and "empty" are not the same answer, and on most call sites in + // this file the difference is harmless because `[]` is the conservative + // reading. On the standing-audit read below it is NOT: `[]` would read as + // "no record has ever been written", which writes a duplicate baseline on + // every boot of a rig whose driver refused the query. A caller that cannot + // afford that conflation passes this and is told. + onRefusal?: (error: unknown) => void, ): Promise { try { const query: Record = { where, limit }; @@ -233,7 +384,8 @@ async function tryFind( if (offset !== undefined) query.offset = offset; const rows = await ql.find(object, query, { context: SYSTEM_CTX }); return Array.isArray(rows) ? rows : []; - } catch { + } catch (e) { + onRefusal?.(e); return []; } } @@ -246,11 +398,16 @@ async function tryFind( // boots. See `reportSeedWriteRefusals` in `per-organization-catalog.ts`. async function tryInsert( ql: any, object: string, data: any, refusals?: SeedWriteRefusals, + // Symmetric with {@link tryFind}'s observer, and for the same reason: a + // caller outside the SEED pass needs the refusal itself, not a `null` that + // the seed reporter will later summarize on a channel that is not its own. + onRefusal?: (error: unknown) => void, ): Promise { try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch (e) { refusals?.record(object, e); + onRefusal?.(e); return null; } } @@ -789,6 +946,13 @@ export async function bootstrapPlatformAdmin( `at request time. ${summary}`, { standing: standing.map((s) => ({ ...s })) }, ); + // [#18412] …and the SAME answer, recorded durably. The log line above is + // the operator's first sight of it and nothing else: it is not queryable, + // it does not survive the process, and an auditor asking 「who held + // administrator standing three months ago, and since when」 cannot read it. + // This is the write that makes that question answerable — one entry per + // CHANGE of standing, plus the first-boot baseline. + await recordPlatformAdminStandingChange(ql, standing, logger); return { seeded: seededCount, adminPromoted: false, diff --git a/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts b/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts new file mode 100644 index 00000000000..0795e59e2dc --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts @@ -0,0 +1,448 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18412] The durable record of platform-admin standing. + * + * Two halves, pinned apart on purpose: + * + * - the ROW SHAPE, tested through the writer's own builder rather than a + * hand-written copy of it — including the one property a later author is + * most likely to "repair": `organization_id` is NULL, by maintainer ruling + * (director batch #153 item 2, 2026-09-18) and by ADR-0131 §1.5, which + * rejects inventing a platform organization in its own words. A pin is the + * only thing that can notice that exception being undone, because every + * field on `sys_audit_log` is `readonly: true` and `validateRecord` skips + * readonly fields — nothing else in the stack refuses ANY value here. + * - the BOOT BEHAVIOUR: one entry per CHANGE of standing, plus the first-boot + * baseline, and silence in the two states where the ledger cannot answer. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { + buildPlatformAdminStandingRow, + platformAdminStandingChanged, + platformAdminStandingSnapshot, + readRecordedStandingSnapshot, + serializePlatformAdminStandingSnapshot, + PLATFORM_ADMIN_STANDING_ACTION, + PLATFORM_ADMIN_STANDING_LEDGER, + PLATFORM_ADMIN_STANDING_OBJECT_NAME, +} from './platform-admin-standing-audit.js'; +import type { PlatformAdminStandingEntry } from './platform-admin-service.js'; + +const LEDGER = PLATFORM_ADMIN_STANDING_LEDGER; + +/** The `sys_audit_log` field set a multi-tenant deployment declares. */ +const TENANTED_LEDGER_FIELDS = [ + 'created_at', + 'action', + 'user_id', + 'actor', + 'object_name', + 'record_id', + 'old_value', + 'new_value', + 'tenant_id', + 'metadata', + 'organization_id', + 'id', +]; + +/** + * In-memory ql over the objects the walled bootstrap touches PLUS the ledger. + * + * `getSchema` is modelled because the writer's mount probe is three-valued and + * the third state — an engine that cannot be ASKED — is a case below. Pass + * `ledgerFields: null` for a deployment that never mounted `plugin-audit`, and + * `schema: false` for an engine carrying no `getSchema` at all. + */ +function makeQl( + seed: { + users?: any[]; + audit?: any[]; + ledgerFields?: string[] | null; + schema?: boolean; + findRefusesLedger?: boolean; + insertRefusesLedger?: boolean; + } = {}, +) { + const tables = new Map([ + ['sys_permission_set', []], + ['sys_user', (seed.users ?? []).map((r) => ({ ...r }))], + ['sys_user_permission_set', []], + ['sys_account', []], + [LEDGER, (seed.audit ?? []).map((r) => ({ ...r }))], + ]); + const ledgerFields = seed.ledgerFields === undefined ? TENANTED_LEDGER_FIELDS : seed.ledgerFields; + const ql: any = { + tables, + async find(object: string, q: any) { + if (object === LEDGER && seed.findRefusesLedger) { + throw new Error('driver refused: no index for sys_audit_log.action'); + } + const where = q?.where ?? {}; + const rows = (tables.get(object) ?? []).filter((r) => + Object.entries(where).every(([k, v]) => { + // ⛔ REFUSE what this double does not implement. A matcher that reads + // `$or` as a field name answers `[]` for a query it never ran, and on + // the standing read `[]` means "no record has ever been written" — + // it would pass a suite that cannot see the query it is pinning. + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), + ); + // The read is ordered IN THE QUERY (`orderBy`), so the double honours it + // rather than handing back insertion order — a double that ignored the + // order would make the "last recorded snapshot" read pass on a driver + // where it cannot. + const order = q?.orderBy?.[0]; + if (order) { + rows.sort((a: any, b: any) => { + const av = String(a[order.field] ?? ''); + const bv = String(b[order.field] ?? ''); + return order.order === 'desc' ? (av < bv ? 1 : av > bv ? -1 : 0) : av < bv ? -1 : av > bv ? 1 : 0; + }); + } + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + async insert(object: string, data: any) { + if (object === LEDGER && seed.insertRefusesLedger) { + throw new Error('driver refused: table sys_audit_log is read-only here'); + } + if (!tables.has(object)) tables.set(object, []); + tables.get(object)!.push({ ...data }); + return { id: data.id }; + }, + // Opens with the PRODUCER's own dispatch predicate rather than a + // hand-mirrored guard (`check:engine-double-contract`): a fixture drifting + // to a call shape ObjectQL.update would refuse fails loudly here. Nothing + // in this file updates, and that is exactly why the refusal must be real. + // + // `_object` keeps the door's POSITION — the object name is argument one on + // every `IDataEngine` write — while telling `noUnusedParameters` that not + // reading it is deliberate. Dropping it would shift `data` and `options` + // one place left and hand the dispatch predicate the wrong arguments. + async update(_object: string, data: any, options?: any) { + assertEngineUpdateDispatch(data, options); + return null; + }, + auditRows(): any[] { + return (tables.get(LEDGER) ?? []).filter((r) => r.action === PLATFORM_ADMIN_STANDING_ACTION); + }, + }; + if (seed.schema !== false) { + ql.getSchema = (name: string) => + name === LEDGER + ? ledgerFields === null + ? undefined + : { name: LEDGER, fields: Object.fromEntries(ledgerFields.map((f) => [f, {}])) } + : { name, fields: {} }; + } + return ql; +} + +const adminFullAccess = () => + ({ name: 'admin_full_access', label: 'Admin', objects: {}, systemPermissions: ['setup.access'] }) as any; + +const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }); + +const user = (id: string, email: string, createdAt: string, extra: Record = {}) => ({ + id, + email, + created_at: createdAt, + ...extra, +}); + +const entry = (over: Partial = {}): PlatformAdminStandingEntry => ({ + email: 'operator@corp.example', + declaredSpelling: 'Operator@Corp.example', + registered: true, + verified: true, + userId: 'u_owner', + ...over, +}); + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_OWNER = process.env.OS_PLATFORM_OWNER_EMAIL; + +beforeEach(() => { + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_PLATFORM_OWNER_EMAIL; +}); +afterEach(() => { + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; + if (OLD_OWNER === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = OLD_OWNER; +}); + +// ─────────────────────────────────────────────────────────────────────────── +// The row shape +// ─────────────────────────────────────────────────────────────────────────── +describe('the platform-admin standing row (#18412)', () => { + it('carries the declared entry, the holder and the delta — as a BASELINE when nothing precedes it', () => { + const snapshot = platformAdminStandingSnapshot([entry()]); + const row = buildPlatformAdminStandingRow({ + snapshot, + previousSerialized: null, + declaresOrganizationId: true, + declaresActor: true, + }); + expect(row.action).toBe('platform_admin_standing_change'); + expect(row.object_name).toBe(PLATFORM_ADMIN_STANDING_OBJECT_NAME); + expect(row.record_id).toBeNull(); + expect(row.user_id).toBeNull(); + // `old_value` is null on the baseline row AND ONLY THERE, so "is this the + // first record on this deployment?" is answerable from the row itself. + expect(row.old_value).toBeNull(); + expect(JSON.parse(String(row.new_value))).toEqual([ + { + email: 'operator@corp.example', + declaredSpelling: 'Operator@Corp.example', + registered: true, + verified: true, + userId: 'u_owner', + }, + ]); + expect(JSON.parse(String(row.metadata))).toEqual({ + event: 'platform_admin_standing.baseline', + declared: 1, + holders: 1, + }); + }); + + it('a later change carries BOTH sides, and says `changed` rather than `baseline`', () => { + const previous = serializePlatformAdminStandingSnapshot( + platformAdminStandingSnapshot([entry({ verified: false, userId: undefined })]), + ); + const row = buildPlatformAdminStandingRow({ + snapshot: platformAdminStandingSnapshot([entry()]), + previousSerialized: previous, + declaresOrganizationId: true, + declaresActor: true, + }); + expect(row.old_value).toBe(previous); + expect(JSON.parse(String(row.metadata)).event).toBe('platform_admin_standing.changed'); + expect(JSON.parse(String(row.old_value))[0].userId).toBeNull(); + expect(JSON.parse(String(row.new_value))[0].userId).toBe('u_owner'); + }); + + /** + * ⭐ The DECLARED EXCEPTION, pinned so undoing it cannot be silent. + * + * Nothing else can see this. Every `sys_audit_log` field is `readonly: true` + * and `validateRecord` skips readonly fields, so a tenant id stamped here + * would be accepted by the whole stack — and it would file a + * deployment-level fact behind one tenant's wall. ADR-0131 §1.5 rejects the + * alternative in its own words (「it is the natural repair and the wrong one + * … exists only to give NULL a new name」); the maintainer ruled this exact + * shape on 2026-09-18. + */ + it('⛔ organization_id and tenant_id are NULL — the ruled deployment-level shape, not an oversight', () => { + const row = buildPlatformAdminStandingRow({ + snapshot: platformAdminStandingSnapshot([entry()]), + previousSerialized: null, + declaresOrganizationId: true, + declaresActor: true, + }); + expect( + row.organization_id, + 'the boot-time platform-admin standing row is DEPLOYMENT-LEVEL: there is no platform ' + + 'organization on this tree (ADR-0131 §1.5 rejects inventing one), a tenant id would ' + + 'file a whole-deployment fact behind one tenant, and the first-boot baseline is ' + + 'written before any sys_organization row exists at all. NULL is the ruled shape ' + + '(#18412, director batch #153 item 2) and the shape ADR-0131 D7 will later make ' + + 'structural by dropping the column.', + ).toBeNull(); + expect(row.tenant_id).toBeNull(); + // ADR-0118 D1/D5 keeps `actor` two-valued; the boot is the system. + expect(row.actor).toBeNull(); + }); + + it('omits both conditional columns on a deployment whose ledger does not declare them', () => { + const row = buildPlatformAdminStandingRow({ + snapshot: platformAdminStandingSnapshot([entry()]), + previousSerialized: null, + declaresOrganizationId: false, + declaresActor: false, + }); + // Stamping a column the table lacks fails the INSERT outright — the + // reason every other sys_audit_log writer probes first. + expect(Object.keys(row)).not.toContain('organization_id'); + expect(Object.keys(row)).not.toContain('actor'); + }); + + it('an absent holder is stored as an explicit null, so present-vs-absent never reads as a change', () => { + const a = serializePlatformAdminStandingSnapshot( + platformAdminStandingSnapshot([entry({ userId: undefined })]), + ); + const b = serializePlatformAdminStandingSnapshot( + platformAdminStandingSnapshot([entry({ userId: undefined })]), + ); + expect(a).toBe(b); + expect(platformAdminStandingChanged(a, b)).toBe(false); + expect(JSON.parse(a)[0]).toHaveProperty('userId', null); + }); + + it('a ledger row with no readable snapshot answers null, which writes a baseline rather than silence', () => { + expect(readRecordedStandingSnapshot(undefined)).toBeNull(); + expect(readRecordedStandingSnapshot({ new_value: null })).toBeNull(); + expect(readRecordedStandingSnapshot({ new_value: '[]' })).toBe('[]'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// The boot behaviour +// ─────────────────────────────────────────────────────────────────────────── +describe('boot records a CHANGE of standing, and only a change (#18412)', () => { + const walled = (owner: string) => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = owner; + }; + + it('first boot writes the BASELINE once — the record the config-derived migration dropped', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.reason).toBe('walled_config_derived'); + const rows = ql.auditRows(); + expect(rows).toHaveLength(1); + expect(rows[0].old_value).toBeNull(); + expect(rows[0].organization_id).toBeNull(); + expect(JSON.parse(rows[0].new_value)[0]).toMatchObject({ + email: 'operator@corp.example', + verified: true, + userId: 'u_owner', + }); + }); + + it('a second boot with the SAME standing writes NOTHING — a restarted rig is not a change of standing', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(ql.auditRows()).toHaveLength(1); + }); + + it('standing CHANGING writes a second row whose old_value is the first row\'s new_value', async () => { + walled('operator@corp.example'); + const log = logger(); + // Boot 1: declared, registered, NOT verified — nobody holds standing. + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z')], + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(ql.auditRows()).toHaveLength(1); + + // Boot 2: the address verified. That IS a change of standing. + ql.tables.get('sys_user')![0].email_verified = true; + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + const rows = ql.auditRows(); + expect(rows).toHaveLength(2); + expect(rows[1].old_value).toBe(rows[0].new_value); + expect(JSON.parse(rows[1].old_value)[0].verified).toBe(false); + expect(JSON.parse(rows[1].new_value)[0].verified).toBe(true); + expect(JSON.parse(rows[1].metadata).holders).toBe(1); + }); + + it('a REVOKED administrator is recorded — the revocation that used to leave no trace at all', async () => { + walled('operator@corp.example, second@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [ + user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true }), + user('u_two', 'second@corp.example', '2026-09-01T03:00:00Z', { email_verified: true }), + ], + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + // The operator drops one address from the config and restarts — the exact + // act that, before this record, left nothing on the data side at all. + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + const rows = ql.auditRows(); + expect(rows).toHaveLength(2); + expect(JSON.parse(rows[1].old_value).map((e: any) => e.email)).toEqual([ + 'operator@corp.example', + 'second@corp.example', + ]); + expect(JSON.parse(rows[1].new_value).map((e: any) => e.email)).toEqual(['operator@corp.example']); + }); + + it('a deployment that never mounted the audit plugin writes nothing and complains about nothing', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + ledgerFields: null, + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.reason).toBe('walled_config_derived'); + expect(ql.auditRows()).toHaveLength(0); + // An unmounted ledger is a COMPOSITION choice, not a degradation. + expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('⛔ a REFUSED read of the last snapshot writes nothing — "cannot tell" is not "first boot"', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + findRefusesLedger: true, + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + // Reading the refusal as "no record exists" would file a fresh baseline on + // every boot of this rig — the per-boot noise the ruling rejected. + expect(ql.auditRows()).toHaveLength(0); + expect(String(log.error.mock.calls[0]?.[0])).toContain('refused the read'); + }); + + it('a mounted ledger whose INSERT fails reports a durability degradation and never fails the boot', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + insertRefusesLedger: true, + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.reason).toBe('walled_config_derived'); + expect(ql.auditRows()).toHaveLength(0); + expect(String(log.error.mock.calls[0]?.[0])).toContain('audit row was NOT written'); + }); + + it('an engine that carries no getSchema is not read as "no ledger" — the write is still attempted', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + schema: false, + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + const rows = ql.auditRows(); + expect(rows).toHaveLength(1); + // Nothing could be probed, so neither conditional column is stamped. + expect(rows[0]).not.toHaveProperty('organization_id'); + expect(rows[0]).not.toHaveProperty('actor'); + }); + + it('the `single` posture is untouched — it still writes a grant row and records no standing entry', async () => { + process.env.OS_TENANCY_POSTURE = 'single'; + const log = logger(); + const ql = makeQl({ + users: [user('u_first', 'first@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + // Standing under `single` still has a durable grant row of its own, which + // is the property this card exists to restore for WALLED rigs only. + expect(ql.auditRows()).toHaveLength(0); + }); +}); diff --git a/packages/plugins/plugin-security/src/platform-admin-standing-audit.ts b/packages/plugins/plugin-security/src/platform-admin-standing-audit.ts new file mode 100644 index 00000000000..38bd8cf5adc --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-admin-standing-audit.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18412] `platform_admin_standing_change` — the durable record of WHO held + * platform-administrator standing on this deployment, and from when. + * + * ## What this exists for + * + * Platform-admin standing moved from a STORED GRANT ROW to CONFIG-DERIVED, + * request-time resolution (#11663 re-anchor, ADR-0131). The migration is right + * and it silently dropped a property the row had: the row carried its own + * history — who held standing, since when, and what a revocation looked like. + * Config carries none of that. After the migration, 「three months ago this + * account performed an admin action; what made them an administrator at that + * moment?」 is answerable only from the deployment's environment-variable + * history, which the product does not keep and an auditor cannot read. + * + * `sys_audit_log` already records the ACTIONS. What had no writer is the BASIS + * of the authority behind them. This module is that writer's row shape. + * + * ## One entry per CHANGE of standing, plus the first-boot baseline + * + * The boot already computes the answer — `resolvePlatformAdminStanding` builds + * the per-entry summary and the walled bootstrap logs it at `info`. A row per + * boot would be noise on a frequently restarted rig, so the boot compares the + * resolved standing against the last snapshot ALREADY on the ledger and writes + * only when the two differ. The first boot after this lands finds no prior row + * and writes the baseline once, which is what makes every later row a readable + * DELTA rather than an isolated assertion. + * + * The comparison is over the serialized snapshot rather than over a hand-picked + * subset of fields: a change in the declared spelling, in whether the account + * exists, in whether it is verified, or in WHICH account holds standing is a + * change of standing, and the snapshot is the only place all four are stated + * together. + * + * ## ⛔ Why this module is PURE, and the engine calls are not in it + * + * Every function here takes data and returns data. The ledger read and the + * ledger insert stay in `bootstrap-platform-admin.ts`, on that file's existing + * `tryFind` / `tryInsert` doors, for two reasons that are not style: + * + * - that file already states the ordering discipline a `limit`-ed read needs + * (an unordered read with a cap returns whichever rows the driver produced + * first, not the first N), and a second read door here would be a second + * copy of that lesson; + * - the row shape is the contract this card makes true, so it is built in one + * place a test can call directly, instead of being assembled inside the boot + * where a pin would have to reconstruct it by hand. + */ + +import type { PlatformAdminStandingEntry } from './platform-admin-service.js'; + +/** + * The `sys_audit_log.action` value this record carries. + * + * ⛔ Spelled `snake_case` and dotless, matching Prime Directive #3 (machine + * names) and every incumbent value on the enum (`config_change`, `import`) — + * the ruling's illustrative `platform_admin_standing.changed` carried a dot and + * was written 「e.g.」. + * + * Named here rather than inline at the insert, for the reason `READ_AUDIT_ACTION` + * states in `plugin-audit/src/read-audit.ts`: every `sys_audit_log` field is + * `readonly: true` and `validateRecord` skips readonly fields, so the `action` + * enum validates NOTHING in either direction. A misspelled action is accepted + * silently and no test watching for a throw can see it; a closed literal is the + * only structural protection available. + * + * ⚠️ The enum member itself lives in `@objectstack/plugin-audit` + * (`objects/sys-audit-log.object.ts`), which this package does NOT depend on — + * `plugin-audit` is OPTIONAL and neither package depends on the other. The two + * spellings are tied together by that package's + * `sys-audit-log-retired-actions.test.ts`, whose `ACTIONS_WITH_WRITERS` + * inventory names THIS file as the writer and fails if the enum and the + * inventory disagree. This is the same two-package shape `config_change` + * already has (`service-settings/src/config-change-audit.ts`). + */ +export const PLATFORM_ADMIN_STANDING_ACTION = 'platform_admin_standing_change'; + +/** + * The object this record is ABOUT. + * + * Standing is held by `sys_user` rows — a declared address confers it only once + * an account exists AND verifies — so `sys_user` is the object an auditor + * filters on. `record_id` is null: the row is RUN-LEVEL, covering every declared + * entry at once, the same shape `plugin-auth`'s `import` row and + * `service-settings`' `config_change` row already use. + */ +export const PLATFORM_ADMIN_STANDING_OBJECT_NAME = 'sys_user'; + +/** The platform audit ledger this record lands on. */ +export const PLATFORM_ADMIN_STANDING_LEDGER = 'sys_audit_log'; + +/** One entry of a recorded snapshot — the serialized form of one declared address. */ +export interface PlatformAdminStandingSnapshotEntry { + email: string; + declaredSpelling: string; + registered: boolean; + verified: boolean; + /** The account that holds standing, or `null` when none does. */ + userId: string | null; +} + +/** + * Project the resolved standing onto the shape that is STORED. + * + * Key order is fixed and `userId` is normalized to an explicit `null` rather + * than left absent: the change detector compares serialized snapshots, so a + * present-vs-absent key would read as a change of standing when nothing about + * standing moved. Declaration order is preserved — it is the operator's own + * order and it is what the log line already shows. + */ +export function platformAdminStandingSnapshot( + standing: readonly PlatformAdminStandingEntry[], +): PlatformAdminStandingSnapshotEntry[] { + return standing.map((s) => ({ + email: s.email, + declaredSpelling: s.declaredSpelling, + registered: s.registered, + verified: s.verified, + userId: s.userId ?? null, + })); +} + +/** Serialize a snapshot for storage and for comparison. One spelling for both. */ +export function serializePlatformAdminStandingSnapshot( + snapshot: readonly PlatformAdminStandingSnapshotEntry[], +): string { + try { + return JSON.stringify(snapshot); + } catch { + return String(snapshot); + } +} + +/** + * Did standing CHANGE against the last recorded snapshot? + * + * `previousSerialized` is the `new_value` of the most recent row already on the + * ledger, or `null` when there is none — the first-boot baseline case, which + * always writes. + * + * ⛔ Compared as the stored STRING, not by re-parsing: what the next boot must + * agree with is the bytes a previous boot wrote. Re-parsing would let a change + * in how this module serializes read as a change in who administers the + * deployment, which is the one direction this record must not be wrong in. + */ +export function platformAdminStandingChanged( + previousSerialized: string | null, + nextSerialized: string, +): boolean { + return previousSerialized !== nextSerialized; +} + +/** Inputs the row builder cannot derive on its own. */ +export interface PlatformAdminStandingRowInput { + /** The standing just resolved at boot. */ + snapshot: readonly PlatformAdminStandingSnapshotEntry[]; + /** The last recorded snapshot's stored string, or `null` on the baseline row. */ + previousSerialized: string | null; + /** Does the registered `sys_audit_log` schema declare `organization_id`? */ + declaresOrganizationId: boolean; + /** Does it declare `actor`? */ + declaresActor: boolean; +} + +/** + * Build the `sys_audit_log` row for one change of platform-admin standing. + * + * Exported rather than assembled inside the boot so a pin tests the WRITER's + * row instead of a hand-written copy of it — the same reason + * `buildConfigChangeAuditSink` is exported from `service-settings`. + */ +export function buildPlatformAdminStandingRow( + input: PlatformAdminStandingRowInput, +): Record { + const serialized = serializePlatformAdminStandingSnapshot(input.snapshot); + const baseline = input.previousSerialized === null; + const holders = input.snapshot.filter((e) => e.userId !== null).length; + + const row: Record = { + action: PLATFORM_ADMIN_STANDING_ACTION, + // ⛔ Both null, and neither is a placeholder. This row is written by the + // BOOT, before any request exists: no `sys_user` performed it and no + // service principal did either. `actor` is two-valued under ADR-0118 D1/D5 + // — a `sys_user` id, or null for the system — so null IS the system here. + user_id: null, + object_name: PLATFORM_ADMIN_STANDING_OBJECT_NAME, + // Run-level: the row covers every declared entry at once. See + // `PLATFORM_ADMIN_STANDING_OBJECT_NAME`. + record_id: null, + // The DELTA, stated as both sides. `old_value` is null on the baseline row + // and only there, so 「is this the first record on this deployment?」 is + // answerable from the row itself rather than from a scan of the ledger. + old_value: input.previousSerialized, + new_value: serialized, + // ⛔ NULL, deliberately — see the block on `organization_id` below. This is + // the schema-declared "tenant context" lookup and this fact has no tenant. + tenant_id: null, + metadata: serializePlatformAdminStandingMetadata({ + event: baseline + ? 'platform_admin_standing.baseline' + : 'platform_admin_standing.changed', + declared: input.snapshot.length, + holders, + }), + }; + + // ⭐ THE DECLARED EXCEPTION — ⛔ do not "repair" this to a non-NULL value. + // + // This row carries `organization_id: null`, and that is the RULED shape, not + // an oversight and not a gap waiting for an owner. + // + // ADR-0131 §1.5 「The rejected middle: a platform organization」 considered + // inventing an organization to own deployment-level rows and rejected it in + // its own words: 「it is the natural repair and the wrong one … exists only to + // give NULL a new name」. There is no platform organization on this tree, by + // that decision. Stamping some tenant's id instead would be a lie — this is a + // fact about the whole deployment, filed behind one tenant's wall — and one + // row per organization is the fan-out §1.5 names as wrong. The first-boot + // BASELINE settles it structurally: it is written before any `sys_organization` + // row exists, so at that instant there is no id in the world to stamp. + // + // The maintainer ruled this directly (#18412, director batch #153 item 2, + // 「其他同意」 2026-09-18): the earlier clause requiring a non-NULL + // organization was WITHDRAWN as an error, and this entry 「follows the tree's + // existing deployment-level writing (`organization` NULL), exactly as the four + // writers above do」 — `plugin-audit`'s `audit-writers.ts` and `read-audit.ts`, + // its `auth-event-audit.ts`, and `service-settings`' `config-change-audit.ts`, + // every one of which stamps `tenantId ?? null`. ADR-0131 D7 will later drop + // this column from `sys_audit_log` outright; NULL is how that shape is + // expressed until it does. + // + // Conditionally stamped for the same mechanical reason every other writer + // states: the SchemaRegistry injects `organization_id` only where the object + // and the posture admit it, and stamping a column the table lacks makes the + // INSERT fail outright. + if (input.declaresOrganizationId) row.organization_id = null; + if (input.declaresActor) row.actor = null; + + return row; +} + +/** `metadata` is a stored string on this object — one serializer, guarded. */ +function serializePlatformAdminStandingMetadata(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +/** + * Read the stored snapshot off a ledger row, or `null` when there is none. + * + * Tolerates a row whose `new_value` is absent or not a string — a ledger row is + * stored data and this code must not throw on a shape it did not write. A row + * that cannot be read answers `null`, which means the next boot writes a + * BASELINE rather than skipping: a missing comparison must not silence the + * record. + */ +export function readRecordedStandingSnapshot(row: unknown): string | null { + if (!row || typeof row !== 'object') return null; + const value = (row as { new_value?: unknown }).new_value; + return typeof value === 'string' ? value : null; +} diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 12320bb28ec..dc0147a4bcb 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2946,6 +2946,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/record-share-tenant-wall.test.ts", "verb": "update",