diff --git a/.changeset/platform-admin-promotion-selection.md b/.changeset/platform-admin-promotion-selection.md new file mode 100644 index 0000000000..8095c9e08a --- /dev/null +++ b/.changeset/platform-admin-promotion-selection.md @@ -0,0 +1,16 @@ +--- +"@objectstack/plugin-security": minor +--- + +First-boot platform-admin promotion under the `single` posture now CHOOSES its target instead of sampling one: the candidate read is ordered by the database, and an operator who declared an owner gets that owner — and only once that owner has verified the address. + +Before this change the selection read `sys_user` with **no `orderBy` and a cap of 50** and then sorted that array client-side, so "the oldest authenticable user" actually meant *the oldest authenticable user among whatever 50 rows the driver produced first*. Measured on 113 seeded users with the intended owner inserted first, holding the oldest `created_at` and an id that collates last: the in-memory driver returned it in row 1 and promoted it, while the default sqlite driver returned rows in id order, never saw it at all, and handed the unscoped `admin_full_access` grant — plus, through `claimSeedOwnership`, ownership of every seeded business record — to a seeded job-seeker persona. Same code, same config, same data; the answer changed with the storage driver. + +- **The read is ordered where the driver can see it.** `created_at` ascending with `id` as the tie-breaker (seeded populations routinely share one timestamp). There is deliberately no client-side re-sort left behind: one would re-rank the returned page and keep the guard passing if the ordering were ever lost again. +- **The declared owner is asked first, and must be a VERIFIED holder.** `OS_PLATFORM_OWNER_EMAIL` was imported into this file and read only on the walled branch, so a deployment that had said who its owner is could still have someone else promoted. Under `single` the target is now a row that holds a declared address, is human, can authenticate, and has `email_verified === true` — all four. Requiring verification rather than merely preferring it answers the one direction in which honouring the declaration would otherwise have been a widening: because `sys_user.email` is UNIQUE on the SQL family, an attacker who registers the declared address before the operator does would have been promoted with no way for the real owner to coexist, so an unverified holder is refused instead. +- **A declared owner who cannot sign in, or has not verified, REFUSES.** No silent fall-back to whoever happens to be oldest — that is the outcome this fixes. The pass warns, naming the variable, the address and which of the two is missing (`declared_owner_not_authenticable` / `declared_owner_not_verified`), and promotes nobody. **Accepted cost, stated rather than discovered:** a `single` deployment whose declared owner has not verified their email gets no platform admin at first boot until they do, loudly. Because the pass replays per sign-up while no admin exists, that warning re-emits on each replay until the owner is promotable; it is deliberately not latched, so the condition stays visible in the log a fresh operator is actually reading. +- **Verification landing is a replay trigger again.** `shouldReplayBootstrapFor` admits a `sys_user` update touching `email` / `email_verified` under `single` — but only while an owner is declared, which is the only configuration where such a write can change the answer. With none declared, the trigger set stays exactly as narrow as it was. +- **The cap is replaced, and never silent again.** A 200-row page with a 5000-row scan ceiling, walked oldest-first. Because the page is ordered it holds the rows the age rule actually wants, so truncation can only bite when every one of the oldest 5000 humans is non-authenticable — and reaching the ceiling now WARNS, naming the number examined. +- **The grant's log line records WHY and FROM HOW MANY.** `[security] first user promoted to platform admin: ` keeps its prefix and gains the basis (`declared-owner` / `oldest-authenticable`) and the candidate-pool size, repeated as `basis` / `candidatePoolSize` fields for structured sinks. The returned report carries `basis` too. + +Unchanged: no declaration still means first-user promotion by age (`single` keeps Choice 4A), and that leg has no verification requirement; a user nobody can authenticate as is still never promoted; an existing unscoped grant still short-circuits before any selection runs, so no deployment that already has an administrator can be re-pointed by this. diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts new file mode 100644 index 0000000000..a64c25c38c --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts @@ -0,0 +1,914 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16682 — WHICH user the `single`-posture bootstrap promotes, and why. + * + * ## The defect, re-measured on this branch's base before anything changed + * + * `bootstrapPlatformAdmin` read `tryFind(ql, 'sys_user', {}, 50)` — no + * `orderBy`, cap 50 — and then sorted THAT ARRAY by `created_at`. So "the + * oldest authenticable user" actually meant *the oldest authenticable user + * among whatever 50 rows this driver produced first*, and a client-side sort + * cannot notice the difference: it sorts a sample and reports a global answer. + * + * Measured here on 113 seeded `sys_user` rows (7 holding credentials), the + * intended owner inserted FIRST with a `created_at` a year older than everyone + * and an id that collates LAST, `OS_PLATFORM_OWNER_EMAIL=admin@objectos.ai`: + * + * driver the 50-row window promoted + * memory window[0] = usr_zzz_owner admin@objectos.ai + * sqlite window[0] = usr_ats_c001, owner ABSENT candidate001@mail.example + * + * Same code, same config, same data; the answer changed with the storage + * driver. A job-seeker persona took the unscoped `admin_full_access` grant and + * — through `claimSeedOwnership` — ownership of every seeded business row. + * + * And `PLATFORM_OWNER_EMAIL_ENV`, imported into that very file, was read only + * on the WALLED branch. A deployment that had SAID who its owner is could + * still have somebody else promoted. That is what makes this a security defect + * rather than a nondeterminism one, and it is why both halves land together: + * ordering alone still promotes someone the operator never chose, and honouring + * the declaration alone leaves the no-declaration path sorting a truncated + * unordered sample. + * + * ## Why the second arm is a row ORDER and not a second driver package + * + * The card's regression shape asks for the same assertion on the memory driver + * and on sqlite. `@objectstack/driver-memory` cannot be imported here: + * declaring it needs `plugin-security/package.json`, and every declaration of + * that package in this repo must additionally be disposed of in + * `scripts/driver-memory-census.ledger.json`, whose `ruled-permanent` axis is + * a maintainer ruling ("nothing else may claim it"). Both are outside a + * repair's authority, so the real memory-driver readings above were taken + * out-of-tree and reported on the PR rather than pinned here. + * + * What IS pinned is the property those two drivers were standing in for, and + * it is pinned over MORE orders than they could produce between them. Each + * case runs the REAL engine over the REAL better-sqlite3 driver, wrapped in a + * facade that permutes a result ONLY when the query carried no `orderBy` — + * which is precisely the freedom a driver has there, and precisely what the + * two families were doing differently. When the query DOES carry `orderBy` the + * facade passes it straight through and the real SQL `ORDER BY` decides; the + * facade never sorts anything itself. So a fix that sent `orderBy` and a driver + * that ignored it would still be caught, and `AS_RETURNED` is an unwrapped, + * ordinary real-driver run. + * + * ⛔ There is deliberately no client-side re-sort left in the selection. A + * defensive `.sort()` after the read would re-rank the returned page and hide + * a lost `orderBy` — the guard would keep passing while the selection went + * back to being a function of the driver. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; +import { + bootstrapPlatformAdmin, + PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE, + PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING, +} from './bootstrap-platform-admin.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYSTEM_CTX = { isSystem: true }; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; + +const engines: ObjectQL[] = []; + +afterEach(async () => { + while (engines.length) { + try { + await engines.pop()?.destroy(); + } catch { + /* noop */ + } + } +}); + +beforeEach(() => { + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); +}); + +afterEach(() => { + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); +}); + +function declareOwner(value: string): void { + process.env[OWNER_ENV] = value; + // The parser memoizes on the RAW string per process; several values travel + // through one worker here. + resetPlatformAdminEmailMemo(); +} + +/** A fresh engine on its own `:memory:` sqlite database with the REAL declarations. */ +async function boot(): Promise { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.security-objects', + name: 'Security Objects', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysPermissionSet, SysUserPermissionSet, SysUser, SysAccount], + } as any); + await engine.syncSchemas(); + engines.push(engine); + return engine; +} + +// ─────────────────────────────────────────────────────────────────────────── +// The driver-order facade +// ─────────────────────────────────────────────────────────────────────────── + +/** + * The row orders a driver is free to return for an UNORDERED read. `AS_RETURNED` + * is the real better-sqlite3 answer with nothing done to it (measured: id + * order). `INSERTION` is the order `@objectstack/driver-memory` returns, which + * is what put the owner in row 1 there and made the two families disagree. + * `REVERSED` is neither — it is here because "some other order" is the actual + * contract, not "one of the two orders we happened to measure". + */ +const NATURAL_ORDERS = ['AS_RETURNED', 'INSERTION', 'REVERSED'] as const; +type NaturalOrder = (typeof NATURAL_ORDERS)[number]; + +/** + * Wrap a real engine so an UNORDERED read comes back in `order`. + * + * ⚠️ The one rule that keeps this honest: when the query carries `orderBy`, + * the query is forwarded verbatim and the RESULT is returned untouched. The + * facade never sorts. Everything the fix relies on is therefore done by the + * real SQL engine. + */ +function withNaturalOrder(engine: ObjectQL, order: NaturalOrder): any { + const insertionRank = new Map(); + let nextRank = 0; + const rankKey = (object: string, id: unknown) => `${object}:${String(id)}`; + return { + async find(object: string, query: any, options: any) { + const rows = await (engine as any).find(object, query, options); + if (!Array.isArray(rows)) return rows; + if (order === 'AS_RETURNED') return rows; + // An ordered read is honoured by the driver; nothing here may touch it. + if (query?.orderBy) return rows; + if (order === 'REVERSED') return [...rows].reverse(); + return [...rows].sort( + (a, b) => + (insertionRank.get(rankKey(object, a?.id)) ?? 0) - + (insertionRank.get(rankKey(object, b?.id)) ?? 0), + ); + }, + async insert(object: string, data: any, options: any) { + const result = await (engine as any).insert(object, data, options); + const id = data?.id ?? result?.id; + if (id !== undefined) insertionRank.set(rankKey(object, id), nextRank++); + return result; + }, + // The shared engine-double contract (`check:engine-double-contract`): a + // facade whose update() is looser than ObjectQL.update is how a dead code + // path ships with its suite green. Asserted BEFORE delegating, so this + // wrapper can never be the loose link. + async update(object: string, data: any, options: any) { + assertEngineUpdateDispatch(data, options); + return (engine as any).update(object, data, options); + }, + }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Fixtures +// ─────────────────────────────────────────────────────────────────────────── + +async function seedUser( + ql: any, + id: string, + email: string, + createdAt: string, + withAccount: boolean, + // [#16682, maintainer ruling batch #100] Absent means UNVERIFIED, which is + // what `isEmailVerifiedUserRow` reads an absent column as — so every fixture + // that does not say otherwise is a row the declared-owner leg must REFUSE. + emailVerified = false, +): Promise { + await ql.insert( + 'sys_user', + { id, email, name: email.split('@')[0], created_at: createdAt, email_verified: emailVerified }, + { context: SYSTEM_CTX }, + ); + if (withAccount) { + await ql.insert( + 'sys_account', + { id: `acc_${id}`, user_id: id, account_id: email, provider_id: 'credential' }, + { context: SYSTEM_CTX }, + ); + } +} + +/** + * The card's population, verbatim: 113 humans, 7 of them able to sign in, and + * the intended owner holding an id that collates AFTER every other row while + * carrying the oldest `created_at`. Inserted first, so the insertion-order + * family puts it in row 1 and the id-order family does not see it at all. + */ +async function seedCardPopulation(ql: any): Promise { + await seedUser(ql, 'usr_zzz_owner', 'admin@objectos.ai', '2025-01-01T00:00:00.000Z', true); + for (let i = 1; i <= 112; i++) { + const n = String(i).padStart(3, '0'); + await seedUser( + ql, + `usr_ats_c${n}`, + `candidate${n}@mail.example`, + `2026-01-01T00:00:${String(i % 60).padStart(2, '0')}.000Z`, + i <= 6, + ); + } +} + +async function findRows(engine: ObjectQL, object: string, where: any = {}): Promise { + const rows = await (engine as any).find(object, { where, limit: 1000 }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : []; +} + +/** The emails holding an unscoped `admin_full_access` grant after a pass. */ +async function adminGrantEmails(engine: ObjectQL): Promise { + const sets = await findRows(engine, 'sys_permission_set', { name: 'admin_full_access' }); + const adminPsId = sets[0]?.id; + expect(adminPsId, 'ANTI-VACUITY: admin_full_access must have been seeded').toBeTruthy(); + const links = await findRows(engine, 'sys_user_permission_set', { permission_set_id: adminPsId }); + const emails: string[] = []; + for (const link of links) { + const rows = await findRows(engine, 'sys_user', { id: link.user_id }); + emails.push(rows[0]?.email ?? String(link.user_id)); + } + return emails; +} + +function collectingLogger() { + const info: string[] = []; + const warn: string[] = []; + const error: string[] = []; + const meta: any[] = []; + return { + info, + warn, + error, + meta, + logger: { + info: (m: string, x?: any) => { + info.push(m); + if (x) meta.push(x); + }, + warn: (m: string) => warn.push(m), + error: (m: string) => error.push(m), + }, + }; +} + +describe('#16682 — the promotion target is chosen, not sampled', () => { + // ───────────────────────────────────────────────────────────────────────── + // ANTI-VACUITY: the truncated window really is driver-shaped + // ───────────────────────────────────────────────────────────────────────── + + it('ANTI-VACUITY: an unordered 50-row read really does hide the intended owner', async () => { + // Without this the whole file could be green because the fixture happens to + // put the owner in reach, rather than because the selection was repaired. + const engine = await boot(); + await seedCardPopulation(engine as any); + + const all = await findRows(engine, 'sys_user'); + expect(all).toHaveLength(113); + + const window50 = await (engine as any).find( + 'sys_user', + { where: {}, limit: 50 }, + { context: SYSTEM_CTX }, + ); + expect(window50).toHaveLength(50); + // The real driver's own unordered answer: the owner is not in it, and the + // oldest row by `created_at` is therefore unreachable to a client-side sort. + expect(window50.some((r: any) => r.id === 'usr_zzz_owner')).toBe(false); + const oldestOfAll = [...all].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + )[0]; + expect(oldestOfAll.id).toBe('usr_zzz_owner'); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 1. The card's regression shape — same answer under every row order + // ───────────────────────────────────────────────────────────────────────── + + describe('the card\'s shape: >50 users, owner last by id and first by created_at', () => { + for (const order of NATURAL_ORDERS) { + it(`promotes the oldest authenticable human under natural order ${order}`, async () => { + const engine = await boot(); + const ql = withNaturalOrder(engine, order); + await seedCardPopulation(ql); + + const { info, logger } = collectingLogger(); + const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets, { logger }); + + expect(report.adminPromoted).toBe(true); + expect(report.basis).toBe('oldest-authenticable'); + expect(await adminGrantEmails(engine)).toEqual(['admin@objectos.ai']); + expect(info.join('\n')).toContain( + 'first user promoted to platform admin: admin@objectos.ai', + ); + }); + } + + it('gives the SAME answer under all three orders (the driver may not decide this)', async () => { + const answers: string[] = []; + for (const order of NATURAL_ORDERS) { + const engine = await boot(); + const ql = withNaturalOrder(engine, order); + await seedCardPopulation(ql); + await bootstrapPlatformAdmin(ql, defaultPermissionSets, {}); + answers.push((await adminGrantEmails(engine)).join(',')); + } + expect(answers).toEqual(['admin@objectos.ai', 'admin@objectos.ai', 'admin@objectos.ai']); + }); + + /** + * [F5] The card's 113-row fixture fits inside ONE + * `PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE` page, so it catches a lost + * `orderBy` only because there is no client-side re-sort left in the + * selection. Add a defensive `.sort(byCreatedAtAsc)` over the returned page + * — the exact thing the source comment warns the next author away from — + * and every case above this one would go GREEN with the ordering gone, + * because the whole population is in the page it re-sorts. + * + * This case is the one that does not: at `PAGE_SIZE + 1` rows with the + * owner's id collating LAST, an unordered read puts the owner on page TWO, + * outside anything a page-local re-sort can reach, while page one already + * holds an authenticable row for the loop to stop on. So it fails on a lost + * `orderBy` whether or not a re-sort is reintroduced. + * + * Run on the plain real driver (`AS_RETURNED`, id order — measured), which + * is the family that produced the card's defect. + */ + it(`survives a future page-local re-sort: ${PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE + 1} rows, owner id-last`, async () => { + const engine = await boot(); + // Page one under id order is `usr_ats_c001..c200`, and `c001` can sign + // in — so a page-one answer is available and WRONG. + for (let i = 1; i <= PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE; i++) { + const n = String(i).padStart(3, '0'); + await seedUser( + engine as any, + `usr_ats_c${n}`, + `candidate${n}@mail.example`, + `2026-01-01T00:00:${String(i % 60).padStart(2, '0')}.000Z`, + i <= 3, + ); + } + // The intended target: oldest by `created_at`, last by id, authenticable. + await seedUser(engine as any, 'usr_zzz_owner', 'owner@objectos.ai', '2025-01-01T00:00:00.000Z', true); + + const report = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, {}); + + expect(report.adminPromoted).toBe(true); + expect(report.basis).toBe('oldest-authenticable'); + expect(await adminGrantEmails(engine)).toEqual(['owner@objectos.ai']); + // ANTI-VACUITY: the population really does exceed one page, so the row + // above really is unreachable from a page-one re-sort. + expect((await findRows(engine, 'sys_user')).length).toBe(PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE + 1); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 2. The declared owner wins, wherever they sort + // ───────────────────────────────────────────────────────────────────────── + + describe(`${OWNER_ENV} names the owner: neither oldest nor in the first 50 rows`, () => { + /** + * The declared owner is `usr_zzz_declared`: id collates last (so an + * id-ordered 50-row window misses it), and its `created_at` is the NEWEST + * of all 113 rows (so the age rule would not pick it either). Only reading + * the declaration can produce it. It is the one VERIFIED row in the + * population, which under the batch-#100 ruling is a REQUIREMENT of this + * leg and not a tie-break. + */ + async function seedDeclaredOwnerPopulation(ql: any): Promise { + await seedUser(ql, 'usr_ats_a000', 'oldest@mail.example', '2025-01-01T00:00:00.000Z', true); + for (let i = 1; i <= 111; i++) { + const n = String(i).padStart(3, '0'); + await seedUser( + ql, + `usr_ats_c${n}`, + `candidate${n}@mail.example`, + `2026-01-01T00:00:${String(i % 60).padStart(2, '0')}.000Z`, + i <= 6, + ); + } + await seedUser(ql, 'usr_zzz_declared', 'owner@objectos.ai', '2027-12-31T00:00:00.000Z', true, true); + } + + for (const order of NATURAL_ORDERS) { + it(`promotes the declared owner under natural order ${order}`, async () => { + declareOwner('owner@objectos.ai'); + const engine = await boot(); + const ql = withNaturalOrder(engine, order); + await seedDeclaredOwnerPopulation(ql); + + const { info, meta, logger } = collectingLogger(); + const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets, { logger }); + + expect(report.adminPromoted).toBe(true); + expect(report.basis).toBe('declared-owner'); + expect(await adminGrantEmails(engine)).toEqual(['owner@objectos.ai']); + // ...and specifically NOT the oldest authenticable human, which is what + // the age rule alone would have answered. + expect(await adminGrantEmails(engine)).not.toContain('oldest@mail.example'); + expect(info.join('\n')).toContain('basis: declared-owner'); + expect(meta.some((m) => m?.basis === 'declared-owner')).toBe(true); + }); + } + + it('matches the declared address case-insensitively, as the config parser normalizes it', async () => { + declareOwner(' Owner@ObjectOS.ai '); + const engine = await boot(); + await seedUser(engine as any, 'usr_a', 'first@mail.example', '2025-01-01T00:00:00.000Z', true); + await seedUser(engine as any, 'usr_b', 'owner@objectos.ai', '2026-01-01T00:00:00.000Z', true, true); + + const report = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, {}); + expect(report.basis).toBe('declared-owner'); + expect(await adminGrantEmails(engine)).toEqual(['owner@objectos.ai']); + }); + + /** + * ⚠️ These two cases run on a DOUBLE, and the reason is a real reading: + * `sys_user.email` carries a UNIQUE index, so the better-sqlite3 driver + * REFUSES a second row holding one address — + * `SQLITE_CONSTRAINT_UNIQUE: sys_user.email`, measured while writing this + * file. The population below is therefore unrepresentable on the SQL + * family and expressible only where that index is not enforced, which is + * the schemaless family this tie-break exists for. Every other case in + * this file drives the real driver. + */ + function makeDuplicateAddressQl(users: any[], accounts: any[]) { + const tables = new Map([ + ['sys_permission_set', []], + ['sys_user', users.map((r) => ({ ...r }))], + ['sys_user_permission_set', []], + ['sys_account', accounts.map((r) => ({ ...r }))], + ]); + return { + grants: () => tables.get('sys_user_permission_set')!, + async find(object: string, q: any) { + const where = q?.where ?? {}; + const matched = (tables.get(object) ?? []).filter((r) => + Object.entries(where).every(([k, v]) => { + // Refuse loudly rather than reading a combinator as a field name: + // a matcher that silently answers `false` for `$or` is how a + // double reports a filtered-out row as absent. + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), + ); + // The caller's bound, applied AFTER the filter and by PRESENCE. + return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; + }, + async insert(object: string, data: any) { + (tables.get(object) ?? []).push({ ...data }); + return { id: data.id }; + }, + async update(object: string, data: any, options?: any) { + const dispatch = assertEngineUpdateDispatch(data, options); + if (dispatch.kind !== 'by-id') return 0; + const row = (tables.get(object) ?? []).find((r) => r.id === dispatch.id); + if (row) Object.assign(row, data); + return row ?? null; + }, + }; + } + + it('among rows holding the declared address, only the VERIFIED one is eligible', async () => { + // ⚠️ RE-AUTHORED by the maintainer ruling of 2026-09-08 (batch #100). + // This case used to pin an ORDERING — "a verified match outranks an older + // unverified squat" — and the ruling struck that ordering as moot: a + // preference only helps where a verified holder EXISTS. What is pinned + // now is the requirement: the older unverified squat is not a candidate + // at all, so the answer does not depend on the operator having managed to + // register alongside it. + declareOwner('owner@objectos.ai'); + const ql = makeDuplicateAddressQl( + [ + { + id: 'usr_squat', + email: 'owner@objectos.ai', + created_at: '2025-01-01T00:00:00.000Z', + email_verified: false, + }, + { + id: 'usr_owner', + email: 'owner@objectos.ai', + created_at: '2026-06-01T00:00:00.000Z', + email_verified: true, + }, + ], + [ + { id: 'acc_squat', user_id: 'usr_squat', provider_id: 'credential' }, + { id: 'acc_owner', user_id: 'usr_owner', provider_id: 'credential' }, + ], + ); + + const report = await bootstrapPlatformAdmin(ql as any, defaultPermissionSets, {}); + expect(report.basis).toBe('declared-owner'); + // The OLDER row loses, and not on age: it never entered the candidate set. + expect(ql.grants().map((g) => String(g.user_id))).toEqual(['usr_owner']); + }); + + it('with NOBODY verified, the declared-owner leg REFUSES — zero grant rows, no fall-back', async () => { + // ⚠️ RE-AUTHORED by the maintainer ruling of 2026-09-08 (batch #100). + // The predecessor of this case asserted the opposite ("the oldest holder + // of the declared address wins"), which was the preference reading. The + // ruling's accepted cost is exactly this outcome, loudly: + // + // > a `single` deployment whose declared owner has not verified their + // > email gets no platform admin at first boot until they do, with a + // > loud warning saying exactly that. + // + // Note what is NOT promoted: `usr_early` can sign in and holds the + // declared address, and under the previous reading it took the unscoped + // `admin_full_access` grant. + declareOwner('owner@objectos.ai'); + const ql = makeDuplicateAddressQl( + [ + { id: 'usr_early', email: 'owner@objectos.ai', created_at: '2025-01-01T00:00:00.000Z' }, + { id: 'usr_late', email: 'owner@objectos.ai', created_at: '2026-06-01T00:00:00.000Z' }, + ], + [ + { id: 'acc_early', user_id: 'usr_early', provider_id: 'credential' }, + { id: 'acc_late', user_id: 'usr_late', provider_id: 'credential' }, + ], + ); + + const { warn, logger } = collectingLogger(); + const report = await bootstrapPlatformAdmin(ql as any, defaultPermissionSets, { logger }); + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('declared_owner_not_verified'); + expect(ql.grants()).toHaveLength(0); + const said = warn.join('\n'); + expect(said).toContain(OWNER_ENV); + expect(said).toContain('owner@objectos.ai'); + expect(said).toContain('VERIFIED'); + expect(said).toContain('NOT falling back to the oldest'); + }); + + it('the refusal LIFTS the moment verification lands — the replay promotes the owner', async () => { + // The ruling's own sentence: "the replay predicate promotes as soon as + // verification lands." This case drives the two halves of that in order — + // the first pass refuses with zero grant rows, the verifying update + // lands, and the replayed pass promotes the same row. `single`'s replay + // trigger is pinned next to its producer + // (`bootstrap-platform-admin-walled-owner.test.ts`); what is pinned here + // is that the SELECTION really does change its answer. + declareOwner('owner@objectos.ai'); + const engine = await boot(); + await seedUser(engine as any, 'usr_a', 'first@mail.example', '2025-01-01T00:00:00.000Z', true); + await seedUser(engine as any, 'usr_owner', 'owner@objectos.ai', '2026-01-01T00:00:00.000Z', true, false); + + const before = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, {}); + expect(before.adminPromoted).toBe(false); + expect(before.reason).toBe('declared_owner_not_verified'); + expect(await adminGrantEmails(engine)).toEqual([]); + + await (engine as any).update( + 'sys_user', + { id: 'usr_owner', email_verified: true }, + { context: SYSTEM_CTX }, + ); + + const after = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, {}); + expect(after.adminPromoted).toBe(true); + expect(after.basis).toBe('declared-owner'); + expect(await adminGrantEmails(engine)).toEqual(['owner@objectos.ai']); + }); + + it('a declared address on a NON-human row is not a route to the grant', async () => { + // `isHumanUser` still applies on this leg: declaring the system account's + // address must not hand it the unscoped grant the guard exists to keep + // away from it. + declareOwner('system@objectos.ai'); + const engine = await boot(); + await (engine as any).insert( + 'sys_user', + { + id: 'usr_system', + email: 'system@objectos.ai', + name: 'system', + role: 'system', + created_at: '2024-01-01T00:00:00.000Z', + }, + { context: SYSTEM_CTX }, + ); + await (engine as any).insert( + 'sys_account', + { id: 'acc_sys', user_id: 'usr_system', account_id: 'system@objectos.ai', provider_id: 'credential' }, + { context: SYSTEM_CTX }, + ); + await seedUser(engine as any, 'usr_real', 'real@mail.example', '2026-01-01T00:00:00.000Z', true); + + const { warn, logger } = collectingLogger(); + const report = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { logger }); + + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('declared_owner_not_authenticable'); + expect(await adminGrantEmails(engine)).toEqual([]); + expect(warn.join('\n')).toContain(OWNER_ENV); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 3. Negative controls + // ───────────────────────────────────────────────────────────────────────── + + describe('negative controls', () => { + it(`with ${OWNER_ENV} unset the fallback holds, identically under every order`, async () => { + // Deliberately a different fixture from case 1: here the oldest + // authenticable row is NOT the id-last row, so "oldest wins" and "the + // owner happens to sort last" are separated. + const answers: string[] = []; + for (const order of NATURAL_ORDERS) { + const engine = await boot(); + const ql = withNaturalOrder(engine, order); + await seedUser(ql, 'usr_mid_oldest', 'oldest@mail.example', '2025-01-01T00:00:00.000Z', true); + for (let i = 1; i <= 80; i++) { + const n = String(i).padStart(3, '0'); + await seedUser(ql, `usr_p${n}`, `p${n}@mail.example`, `2026-02-01T00:00:${String(i % 60).padStart(2, '0')}.000Z`, i <= 4); + } + await seedUser(ql, 'usr_zzz_last', 'last@mail.example', '2026-06-01T00:00:00.000Z', true); + + const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets, {}); + expect(report.basis).toBe('oldest-authenticable'); + answers.push((await adminGrantEmails(engine)).join(',')); + } + expect(answers).toEqual([ + 'oldest@mail.example', + 'oldest@mail.example', + 'oldest@mail.example', + ]); + }); + + it('#14348 SURVIVES: the lowest created_at is skipped when nobody can sign in as it', async () => { + // The failure this guards: an implementation that simply takes + // `min(created_at)` makes case 1 green while deleting #14348's repair. + // Here the two oldest rows are credential-less directory rows — exactly + // what `defineStack({ data })` leaves behind — and the ONLY login is + // newer than both. + for (const order of NATURAL_ORDERS) { + const engine = await boot(); + const ql = withNaturalOrder(engine, order); + await seedUser(ql, 'usr_person0', 'person0@demo.example', '2025-01-01T00:00:00.000Z', false); + await seedUser(ql, 'usr_person1', 'person1@demo.example', '2025-01-02T00:00:00.000Z', false); + await seedUser(ql, 'usr_login', 'admin@demo.example', '2026-02-01T00:00:00.000Z', true); + + const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets, {}); + expect(report.adminPromoted).toBe(true); + expect(report.basis).toBe('oldest-authenticable'); + expect(await adminGrantEmails(engine)).toEqual(['admin@demo.example']); + } + }); + + it('#14348 SURVIVES: a population nobody can authenticate as promotes NOBODY', async () => { + const engine = await boot(); + const ql = withNaturalOrder(engine, 'REVERSED'); + for (let i = 0; i < 60; i++) { + await seedUser(ql, `usr_person${i}`, `person${i}@demo.example`, `2025-01-01T00:00:${String(i).padStart(2, '0')}.000Z`, false); + } + + const { info, warn, error, logger } = collectingLogger(); + const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets, { logger }); + + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('no_authenticable_user'); + expect(await adminGrantEmails(engine)).toEqual([]); + expect(info.join('\n')).toContain('none can authenticate'); + // The population is far under the ceiling, so nothing was truncated and + // there is nothing to warn about. + expect(warn).toEqual([]); + expect(error).toEqual([]); + }); + + it(`${OWNER_ENV} pointing at an address with NO sys_user row refuses, loudly`, async () => { + declareOwner('ghost@objectos.ai'); + const engine = await boot(); + await seedUser(engine as any, 'usr_a', 'first@mail.example', '2025-01-01T00:00:00.000Z', true); + await seedUser(engine as any, 'usr_b', 'second@mail.example', '2026-01-01T00:00:00.000Z', true); + + const { warn, logger } = collectingLogger(); + const report = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { logger }); + + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('declared_owner_not_authenticable'); + // ⛔ The whole point: NO silent fall-back to whoever happens to be oldest. + expect(await adminGrantEmails(engine)).toEqual([]); + const said = warn.join('\n'); + expect(said).toContain(OWNER_ENV); + expect(said).toContain('ghost@objectos.ai'); + expect(said).toContain('NOT falling back to the oldest'); + }); + + it(`${OWNER_ENV} pointing at a row with no sys_account refuses, loudly`, async () => { + declareOwner('directory@objectos.ai'); + const engine = await boot(); + await seedUser(engine as any, 'usr_a', 'first@mail.example', '2025-01-01T00:00:00.000Z', true); + // VERIFIED, so this case measures the authenticability axis alone: the + // only thing missing is a `sys_account`, and #14348's reason code is what + // must come back. + await seedUser(engine as any, 'usr_dir', 'directory@objectos.ai', '2026-01-01T00:00:00.000Z', false, true); + + const { warn, logger } = collectingLogger(); + const report = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { logger }); + + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('declared_owner_not_authenticable'); + expect(await adminGrantEmails(engine)).toEqual([]); + expect(warn.join('\n')).toContain('can authenticate (no sys_account)'); + }); + + it('the reserved fork is untouched: an existing grant still short-circuits first', async () => { + // #14348 case D, re-asserted against the NEW selector: re-pointing an + // already-granted platform admin is a permission-boundary act and is not + // this change's to make. A declared owner must not move an existing grant. + const engine = await boot(); + await seedUser(engine as any, 'usr_person0', 'person0@demo.example', '2025-01-01T00:00:00.000Z', false); + const first = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, {}); + expect(first.reason).toBe('no_authenticable_user'); + const sets = await findRows(engine, 'sys_permission_set', { name: 'admin_full_access' }); + await (engine as any).insert( + 'sys_user_permission_set', + { + id: 'ups_legacy', + user_id: 'usr_person0', + permission_set_id: sets[0].id, + organization_id: null, + }, + { context: SYSTEM_CTX }, + ); + + declareOwner('owner@objectos.ai'); + await seedUser(engine as any, 'usr_owner', 'owner@objectos.ai', '2026-01-01T00:00:00.000Z', true); + + const report = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, {}); + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('already_have_admin'); + expect(await adminGrantEmails(engine)).toEqual(['person0@demo.example']); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 4. The cap's disposition: a scan that stops short must SAY so + // ───────────────────────────────────────────────────────────────────────── + + describe('the scan ceiling is bounded but never silent', () => { + /** + * A synthetic `sys_user` population — the one case whose subject is a row + * COUNT larger than any fixture worth storing. Rows are generated already + * in `created_at` order, so `orderBy` is a no-op over them and the case + * isolates exactly what it is about: what the pass does when it runs out of + * scan budget. The other cases in this file all drive the real driver. + */ + function makeSyntheticQl(userCount: number) { + const permissionSets: any[] = []; + const warns: string[] = []; + return { + warns, + async find(object: string, q: any) { + // The caller's bound, applied AFTER the filter and by PRESENCE. + const bound = (rows: any[]) => + typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + if (object === 'sys_permission_set') { + const where = q?.where ?? {}; + return bound( + permissionSets.filter((r) => + Object.entries(where).every(([k, v]) => { + // Refuse loudly rather than reading a combinator as a field + // name — a matcher that answers `false` for `$or` reports a + // row it never understood as absent. + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), + ), + ); + } + if (object === 'sys_user') { + const where = q?.where ?? {}; + for (const k of Object.keys(where)) { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + } + if (Object.keys(where).length > 0) return []; + const offset = q?.offset ?? 0; + const limit = q?.limit ?? 100; + const out: any[] = []; + for (let i = offset; i < Math.min(offset + limit, userCount); i++) { + out.push({ + id: `usr_${String(i).padStart(6, '0')}`, + email: `person${i}@demo.example`, + created_at: new Date(Date.UTC(2026, 0, 1) + i * 1000).toISOString(), + }); + } + return out; + } + // No logins anywhere, and no existing grants. + return []; + }, + async insert(object: string, data: any) { + if (object === 'sys_permission_set') permissionSets.push({ ...data }); + return { id: data.id }; + }, + async update(_object: string, data: any, options?: any) { + assertEngineUpdateDispatch(data, options); + return 0; + }, + }; + } + + it('warns, naming the ceiling, when the scan stops short of the population', async () => { + const ql = makeSyntheticQl(PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING + PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE); + const { info, warn, logger } = collectingLogger(); + + const report = await bootstrapPlatformAdmin(ql as any, defaultPermissionSets, { logger }); + + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('no_authenticable_user'); + expect(info.join('\n')).toContain('none can authenticate'); + // ⛔ "I only looked at N rows" is never silent again. + const said = warn.join('\n'); + expect(said).toContain('stopped at its ceiling'); + expect(said).toContain(String(PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING)); + expect(said).toContain('were NOT examined'); + }); + + it('CONTROL: a population inside the ceiling produces no truncation warning', async () => { + const ql = makeSyntheticQl(PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING - PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE); + const { warn, logger } = collectingLogger(); + + const report = await bootstrapPlatformAdmin(ql as any, defaultPermissionSets, { logger }); + + expect(report.reason).toBe('no_authenticable_user'); + expect(warn.join('\n')).not.toContain('stopped at its ceiling'); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 5. The grant's only record has to say WHY and FROM HOW MANY + // ───────────────────────────────────────────────────────────────────────── + + describe('the promotion log line records the basis and the candidate pool', () => { + it('oldest-authenticable: names the basis and the number of rows examined', async () => { + const engine = await boot(); + await seedCardPopulation(engine as any); + + const { info, meta, logger } = collectingLogger(); + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { logger }); + + const line = info.find((l) => l.includes('first user promoted to platform admin')); + expect(line).toBeDefined(); + expect(line).toContain('basis: oldest-authenticable'); + expect(line).toContain('113 human user row(s) examined oldest-first by created_at'); + expect(meta.some((m) => m?.basis === 'oldest-authenticable' && m?.candidatePoolSize === 113)).toBe(true); + }); + + it('declared-owner: names the basis, the variable and the matching rows', async () => { + declareOwner('owner@objectos.ai'); + const engine = await boot(); + await seedUser(engine as any, 'usr_a', 'first@mail.example', '2025-01-01T00:00:00.000Z', true); + await seedUser(engine as any, 'usr_owner', 'owner@objectos.ai', '2026-01-01T00:00:00.000Z', true, true); + + const { info, logger } = collectingLogger(); + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { logger }); + + const line = info.find((l) => l.includes('first user promoted to platform admin')); + expect(line).toContain('owner@objectos.ai'); + expect(line).toContain('basis: declared-owner'); + expect(line).toContain(`1 address(es) declared in ${OWNER_ENV}`); + expect(line).toContain('1 matching human user row(s)'); + }); + + it('the published prefix is unchanged, so existing readers still match', async () => { + const engine = await boot(); + await seedUser(engine as any, 'usr_a', 'first@mail.example', '2025-01-01T00:00:00.000Z', true); + + const { info, logger } = collectingLogger(); + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { logger }); + + expect(info.join('\n')).toContain( + '[security] first user promoted to platform admin: first@mail.example', + ); + }); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts index ae1b7b997f..b0b124cd26 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts @@ -21,9 +21,22 @@ * (pin #5 — loud migration, never a silent dual-track). * * Both directions stay pinned: walled writes NOTHING whatever the account - * state, and `single` keeps first-user promotion byte-for-byte (Choice 4A — - * the over-denial guard: retiring the walled write must not retire the - * `single` one). + * state, and `single` still PROMOTES (Choice 4A — the over-denial guard: + * retiring the walled write must not retire the `single` one). + * + * - **#16682: the `single` SELECTION is repaired.** That guard used to be + * written as "byte-for-byte", and one case snapshotted the incumbent's + * refusal to read `OS_PLATFORM_OWNER_EMAIL` on this branch. The incumbent + * was the defect: an unordered, cap-50 `sys_user` read sorted client-side, + * so who got the unscoped `admin_full_access` grant changed with the + * storage driver, and a deployment that had DECLARED its owner could still + * have somebody else promoted. The guard's subject survives — `single` + * still writes a grant — but "byte-for-byte" does not, and the case that + * claimed it is re-authored below with the ruling that replaced it — + * a MAINTAINER ruling of 2026-09-08 (decision batch #100), not a triage + * seat's, which also makes verification a REQUIREMENT of the declared-owner + * leg and retires the trigger-set narrowing for the one write that can now + * change the answer. * * The outcomes here are bootstrap returns, not HTTP answers, so there is no * ADR-0112 envelope to assert; the machine-checkable surface is the exact @@ -418,21 +431,69 @@ describe('single posture — "first user is owner" is ruled reasonable and UNCHA expect(ql.grants()[0]?.user_id).toBe('u_first'); }); - it('never consults the owner-email variable: a declared owner does NOT redirect the single-org promotion', async () => { - // Over-denial guard for the ruling's direction: setting the variable under - // `single` must not change who is promoted. + /** + * ⚠️ RE-AUTHORED by #16682. This case used to assert the opposite — + * "never consults the owner-email variable: a declared owner does NOT + * redirect the single-org promotion" — and it is worth being explicit about + * what changed and what did NOT, because the two are easy to confuse. + * + * What this case is FOR is unchanged: it is #11974's over-denial guard, and + * the invariant it guards is that retiring the WALLED write did not retire + * the `single` one. That invariant is `adminPromoted === true` with a grant + * row actually minted, and it is asserted below exactly as before. + * + * What changed is the incumbent it happened to snapshot alongside that + * invariant. `single` read `sys_user` with no `orderBy` and a cap of 50 and + * then sorted the returned array, so the promotion was decided by whichever + * rows the driver produced first — measured on 113 rows, memory promoted the + * intended owner and sqlite promoted a job-seeker persona — while + * `PLATFORM_OWNER_EMAIL_ENV`, imported into that same file, was consulted + * only on the walled branch. + * + * The authority for the reversal is a MAINTAINER ruling — 2026-09-08, + * decision batch #100, recorded on #16682 (comment 5587754690), which + * supersedes the Choice 4A sentence for this one point and states what + * survives it, verbatim: + * + * > F3 — the Choice 4A sentence is superseded for this one point. Under + * > `single` posture the first-boot promotion consults + * > `OS_PLATFORM_OWNER_EMAIL` first. The rest of Choice 4A (#11974, + * > 2026-08-25) stands: retiring the walled write must not retire the + * > `single` one, and the over-denial invariant (`adminPromoted === true` + * > with a grant row minted) stays pinned. + * + * ⛔ An earlier revision of this comment quoted the #16682 TRIAGE seat's + * ruling instead. That quotation was the reviewer's F3 finding: a pin + * recorded under a maintainer ruling cannot be rewritten under a seat's. + * The quotation above is the record that resolved it. + * + * So a declared owner now DOES decide this promotion. `single` keeping + * first-user promotion (Choice 4A) is untouched: with no declaration, the + * age rule still answers — the case above this one pins that, and + * `bootstrap-platform-admin-promotion-selection.test.ts` pins the whole + * selection including every negative control. + */ + it('a declared owner DOES redirect the single-org promotion (#16682), and `single` still promotes', async () => { process.env.OS_TENANCY_POSTURE = 'single'; process.env.OS_PLATFORM_OWNER_EMAIL = 'second@corp.example'; const ql = makeQl({ users: [ user('u_first', 'first@corp.example', '2026-08-23T01:00:00Z'), - user('u_second', 'second@corp.example', '2026-08-23T02:00:00Z'), + // VERIFIED: the same ruling makes that a REQUIREMENT of this leg, not + // a tie-break — an unverified holder is refused + // (`declared_owner_not_verified`, pinned in the selection suite). + user('u_second', 'second@corp.example', '2026-08-23T02:00:00Z', { email_verified: true }), ], accounts: [account('u_first'), account('u_second')], }); const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + // #11974's over-denial guard, unchanged: the `single` write still happens. expect(r.adminPromoted).toBe(true); - expect(ql.grants()[0]?.user_id).toBe('u_first'); + expect(ql.grants()).toHaveLength(1); + // #16682: and it goes to the address the operator declared, not to + // whichever row the driver handed back first. + expect(ql.grants()[0]?.user_id).toBe('u_second'); + expect(r.basis).toBe('declared-owner'); }); it('an UNVERIFIED first user is still promoted under `single` — the verified invariant was walled-only', async () => { @@ -461,20 +522,32 @@ describe('single posture — "first user is owner" is ruled reasonable and UNCHA }); // ─────────────────────────────────────────────────────────────────────────── -// [#11974] The bootstrap-replay trigger set, NARROWED with the walled -// elevation's retirement: `single` + create/insert only. The #11343 update -// arm (email / email_verified) fired for the walled verify-then-elevate -// sequence, which no longer exists — and under walled postures NO sys_user -// write can change the bootstrap's answer, so nothing replays at all. +// [#11974, amended by #16682] The bootstrap-replay trigger set. #11974 +// narrowed it to `single` + create/insert: the #11343 update arm (email / +// email_verified) fired for the walled verify-then-elevate sequence, which no +// longer exists, and its own rationale was that "`single` promotes the oldest +// authenticable human and never reads `email`/`email_verified`". +// +// The maintainer ruling of 2026-09-08 (batch #100) made that last clause +// false FOR ONE CONFIGURATION: with an owner declared, `single` promotes only +// a VERIFIED holder of the declared address, and the ruling says the +// consequence out loud — "the replay predicate promotes as soon as +// verification lands". So the update arm returns exactly there and nowhere +// else: `single`, `sys_user`, a payload touching `email`/`email_verified`, +// and an owner actually declared. With none declared the narrowing is intact, +// which is every deployment #11974 measured. // security-plugin.ts consumes this same predicate. // ─────────────────────────────────────────────────────────────────────────── -describe('shouldReplayBootstrapFor — narrowed trigger set (#11974)', () => { +describe('shouldReplayBootstrapFor — the trigger set equals the selection inputs (#11974, #16682)', () => { it('fires on sys_user insert/create under `single` (first-user promotion, unchanged)', () => { expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'insert', data: { email: 'a@b.c' } })).toBe(true); expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'create', data: { email: 'a@b.c' } })).toBe(true); }); - it('⛔ no longer fires on updates touching email_verified / email — the walled elevation they re-attempted is retired', () => { + it('⛔ with NO owner declared, an update touching email_verified / email still does not fire (#11974 narrowing intact)', () => { + // `single`'s no-declaration leg ranks by age over authenticable humans and + // reads neither column, so replaying here would be the pure re-run tax + // #11974 removed. expect( shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', email_verified: true } }), ).toBe(false); @@ -483,6 +556,26 @@ describe('shouldReplayBootstrapFor — narrowed trigger set (#11974)', () => { ).toBe(false); }); + it('fires on the VERIFYING update once an owner IS declared (#16682 — "promotes as soon as verification lands")', () => { + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + expect( + shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', email_verified: true } }), + ).toBe(true); + // The address itself is an input too: a row moving ONTO the declared + // address is the other write that can change leg 1's answer. + expect( + shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', email: 'x@y.z' } }), + ).toBe(true); + // ⛔ Still narrow: an update that touches neither column, and any + // `sys_account` update, decide nothing. + expect( + shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', name: 'New Name' } }), + ).toBe(false); + expect( + shouldReplayBootstrapFor({ object: 'sys_account', operation: 'update', data: { id: 'a1', email_verified: true } }), + ).toBe(false); + }); + it('⛔ NEVER fires under a walled posture — no write can change a config-derived answer', () => { for (const posture of ['isolated', 'group']) { process.env.OS_TENANCY_POSTURE = posture; @@ -491,6 +584,13 @@ describe('shouldReplayBootstrapFor — narrowed trigger set (#11974)', () => { expect( shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', email_verified: true } }), ).toBe(false); + // ...including with an owner declared: a walled posture derives standing + // from config at request time and writes nothing to re-attempt. + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + expect( + shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', email_verified: true } }), + ).toBe(false); + delete process.env.OS_PLATFORM_OWNER_EMAIL; } }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index e4be93e554..73543200ba 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -70,8 +70,13 @@ import { postureEnforcesWall, type PermissionSet } from '@objectstack/spec/security'; import { SystemUserId } from '@objectstack/spec/system'; -import { PLATFORM_OWNER_EMAIL_ENV, resolveTenancyPosture } from '@objectstack/types'; import { + isEmailVerifiedUserRow, + PLATFORM_OWNER_EMAIL_ENV, + resolveTenancyPosture, +} from '@objectstack/types'; +import { + normalizePlatformAdminEmail, reportLegacyPlatformAdminGrant, resolvePlatformAdminEmails, } from '@objectstack/core'; @@ -119,9 +124,62 @@ interface BootstrapOptions { const SYSTEM_CTX = { isSystem: true }; -async function tryFind(ql: any, object: string, where: any, limit = 100): Promise { +/** + * [#16682] The `single`-posture candidate scan's page size and hard ceiling — + * what replaced the bare `50` at the promotion read. + * + * ## The cap's disposition + * + * The bare `50` is gone. What replaces it is a PAGE size and a scan CEILING, + * and the difference from the old constant is the `orderBy` that now travels + * with the read: an ORDERED page is the OLDEST rows, which is exactly the set + * the age ranking wants, so truncation can only bite when every one of the + * oldest `PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING` humans is non-authenticable. + * The old unordered `50` could drop the answer on a 51-row install. + * + * A ceiling is KEPT rather than dropped because this pass re-runs on every + * `sys_user` / `sys_account` insert until an admin exists + * (`shouldReplayBootstrapFor`), so an unbounded scan would be a per-sign-up + * full-table read on exactly the deployments that have not been promoted yet. + * What is NOT kept is the silence: reaching the ceiling WARNS, naming the + * number examined. "I only looked at N rows" was the whole defect. + * + * Exported so the guard reads the SAME numbers the selection does. A test that + * restates them is a test that goes quietly vacuous the day one is tuned. + */ +export const PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE = 200; +export const PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING = 5000; + +/** + * One read, with the sort and the page WHERE THE DRIVER CAN SEE THEM. + * + * `orderBy` / `offset` are optional and are only put on the query when a + * caller passes them, so every pre-existing call site sends the same query it + * always did. They exist because an UNORDERED read with a `limit` does not + * return "the first N rows" — it returns whichever N rows that driver happened + * to produce first, and the two families disagree by construction. Measured on + * this repo's own drivers with 113 `sys_user` rows and `limit: 50`: + * + * memory window[0] = usr_zzz_owner (insertion order) + * sqlite window[0] = usr_ats_c001 (id order) — usr_zzz_owner ABSENT + * + * A caller that then sorts the returned array is sorting a SAMPLE and + * reporting a global answer. Sorting in the query is the only way to make the + * cap select the rows the ranking actually wants. + */ +async function tryFind( + ql: any, + object: string, + where: any, + limit = 100, + orderBy?: { field: string; order: 'asc' | 'desc' }[], + offset?: number, +): Promise { try { - const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + const query: Record = { where, limit }; + if (orderBy) query.orderBy = orderBy; + if (offset !== undefined) query.offset = offset; + const rows = await ql.find(object, query, { context: SYSTEM_CTX }); return Array.isArray(rows) ? rows : []; } catch { return []; @@ -202,11 +260,25 @@ function genId(prefix: string): string { * a people directory (where boot finds humans but no logins) that is the * difference between "the first real sign-up is promoted" and "no platform * admin is ever promoted". - * - `single` + any update: could never change the promotion answer — - * `single` promotes the oldest authenticable human and never reads - * `email`/`email_verified`. The pre-#11974 update arm fired here for the - * walled match's sake only; with that gone it would be a pure re-run tax - * on every verification write. + * - `single` + `sys_user` update touching `email` / `email_verified`, ONLY + * while an owner address is declared: [#16682, maintainer ruling of + * 2026-09-08, decision batch #100] the `single` leg now consults + * `OS_PLATFORM_OWNER_EMAIL` and promotes only a VERIFIED holder of a + * declared address. So the verifying write is an INPUT to this function's + * answer again, and the ruling states the consequence directly: "the + * replay predicate promotes as soon as verification lands". Without this + * arm the accepted cost of that ruling would be far worse than the ruling + * describes — an owner who verified would keep waiting until some OTHER + * user happened to sign up. + * + * ⛔ NOT the pre-#11974 arm restored wholesale. It is gated on a + * declaration actually existing, which is the only configuration where an + * update can move the answer: with none declared, `single` still promotes + * the oldest authenticable human and still never reads + * `email`/`email_verified`, so the re-run tax the #11974 narrowing removed + * stays removed for every deployment that has not declared an owner. + * - `single` + any other update (a name change, a `sys_account` update): + * reads nothing this function ranks on. Never replays. */ export function shouldReplayBootstrapFor(opCtx: { object?: string; @@ -214,9 +286,15 @@ export function shouldReplayBootstrapFor(opCtx: { data?: unknown; }): boolean { if (opCtx?.object !== 'sys_user' && opCtx?.object !== 'sys_account') return false; + if (postureEnforcesWall(resolveTenancyPosture())) return false; const op = opCtx?.operation; - if (op !== 'create' && op !== 'insert') return false; - return !postureEnforcesWall(resolveTenancyPosture()); + if (op === 'create' || op === 'insert') return true; + if (op !== 'update' || opCtx.object !== 'sys_user') return false; + // [#16682] The verifying write, and only where it can decide something. + const data = opCtx.data; + if (!data || typeof data !== 'object') return false; + if (!('email_verified' in data) && !('email' in data)) return false; + return resolvePlatformAdminEmails().emails.length > 0; } /** @@ -273,6 +351,13 @@ export async function bootstrapPlatformAdmin( resynced?: number; /** [#2705] Existing rows left untouched by `resync` (admin/package-owned). */ resyncSkipped?: number; + /** + * [#16682] WHY this target was chosen, when one was. `declared-owner` means + * `OS_PLATFORM_OWNER_EMAIL` named them; `oldest-authenticable` means nobody + * did and the age rule answered. The highest-privilege grant in the system + * should not be auditable only by reading which code path ran. + */ + basis?: 'declared-owner' | 'oldest-authenticable'; }> { const logger = options.logger; if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { @@ -553,28 +638,262 @@ export async function bootstrapPlatformAdmin( // user's only account past it — which reads as "cannot authenticate" and // silently SKIPS a legitimate target. The typical fresh boot answers on the // first query. - const oldestAuthenticable = async (ql2: any, users: any[]): Promise => { - const byAge = [...users].sort(byCreatedAtAsc); - for (const user of byAge) { - if (user?.id === undefined || user?.id === null) continue; - const accounts = await tryFind(ql2, 'sys_account', { user_id: user.id }, 1); - if (accounts.length > 0) return user; - } + const canAuthenticate = async (user: any): Promise => { + if (user?.id === undefined || user?.id === null) return false; + const accounts = await tryFind(ql, 'sys_account', { user_id: user.id }, 1); + return accounts.length > 0; + }; + const firstAuthenticable = async (users: any[]): Promise => { + for (const user of users) if (await canAuthenticate(user)) return user; return undefined; }; + /** + * Mint the grant and RECORD it. One call site for both legs, so the write, + * the log and the seed-ownership handoff cannot drift apart per basis. + * + * [#16682] The old line was `first user promoted to platform admin: ` + * and nothing else. It is the only record of the highest-privilege grant + * this system ever makes, and it did not say WHY that row won or HOW MANY + * rows it was chosen from — so a promotion decided by a truncated, + * driver-ordered 50-row sample and one decided by an operator's declaration + * produced BYTE-IDENTICAL evidence. The prefix is unchanged (existing + * consumers match on it); the basis and the candidate pool are appended, and + * repeated in `meta` so a structured sink gets them as fields. + */ + const promote = async ( + chosen: any, + audit: { basis: 'declared-owner' | 'oldest-authenticable'; pool: string; candidatePoolSize: number }, + ) => { + const inserted = await tryInsert(ql, 'sys_user_permission_set', { + id: genId('ups'), + user_id: chosen.id, + permission_set_id: adminPsId, + organization_id: null, + granted_by: null, + }); + if (!inserted) { + logger?.warn?.(`[security] failed to grant admin_full_access to first user ${chosen.email ?? chosen.id}`); + return { seeded: seededCount, adminPromoted: false, reason: 'insert_failed', ...resyncCounts }; + } + logger?.info?.( + `[security] first user promoted to platform admin: ${chosen.email ?? chosen.id} ` + + `— basis: ${audit.basis}; candidate pool: ${audit.pool}`, + { basis: audit.basis, candidatePoolSize: audit.candidatePoolSize, userId: String(chosen.id) }, + ); + + // Hand seeded business records (owner_id NULL / usr_system) to the freshly + // promoted admin so owner-keyed UX works out of the box. Best-effort and + // idempotent — failures here must not undo the promotion above. + let ownershipClaimed = 0; + try { + const claims = await claimSeedOwnership(ql, chosen.id, { logger }); + ownershipClaimed = claims.reduce((sum, c) => sum + c.count, 0); + } catch (e) { + logger?.warn?.('[security] seed ownership handoff failed', { error: (e as Error).message }); + } + + return { + seeded: seededCount, + adminPromoted: true, + ownershipClaimed, + basis: audit.basis, + ...resyncCounts, + }; + }; + + // ── The candidate ORDER, stated to the DRIVER (#16682) ──────────────────── + // + // The age rule used to be applied by `[...users].sort(byCreatedAtAsc)` over + // whatever `tryFind(ql, 'sys_user', {}, 50)` returned. That read carried no + // `orderBy`, so "the oldest authenticable user" meant *the oldest + // authenticable user among whatever 50 rows this driver produced first* — + // and the two families disagree by construction. Measured on 113 seeded + // `sys_user` rows, the intended owner inserted FIRST and holding an id that + // sorts LAST: + // + // memory window[0] = usr_zzz_owner -> promoted admin@objectos.ai + // sqlite window[0] = usr_ats_c001 -> promoted candidate001@mail.example + // (usr_zzz_owner was not in the window AT ALL) + // + // Same code, same config, same data; the answer changed with the storage + // driver, and a job-seeker persona took the unscoped `admin_full_access` + // grant plus — through `claimSeedOwnership` — ownership of every seeded row. + // A client-side sort cannot notice this: it sorts a SAMPLE and reports a + // global answer. + // + // So the ranking moves into the query and there is deliberately NO + // client-side re-sort left behind. A defensive `.sort()` here would re-rank + // the returned page and hide it if the `orderBy` ever stopped being sent — + // the guard would go on passing while the selection went back to being a + // function of the driver. + // + // `id` is the tie-breaker, not decoration: seeded populations routinely + // share one `created_at`, and among ties an unordered read is exactly the + // sample-dependent answer this fixes. + const OLDEST_FIRST: { field: string; order: 'asc' | 'desc' }[] = [ + { field: 'created_at', order: 'asc' }, + { field: 'id', order: 'asc' }, + ]; // [#11974 / #11663 L4] `single` is the ONLY posture that still selects a // target and writes the grant row (Choice 4A). The walled selection — query // by declared email, verified-only, oldest wins — moved with the decision // itself into the derivation site (`resolve-authz-context.ts` §6b-config) // and, for the audit answer, `platform-admin-service.ts`. - const allUsers = await tryFind(ql, 'sys_user', {}, 50); - const humanUsers = allUsers.filter(isHumanUser); - if (humanUsers.length === 0) { + // + // ── Leg 1: the DECLARED owner, when the operator declared one (#16682) ──── + // + // `PLATFORM_OWNER_EMAIL_ENV` was imported into this file and read only on + // the walled branch. So a deployment that had SAID who the owner is could + // still have someone else promoted here — which is what made the loose read + // above a security defect rather than a nondeterminism one. This leg asks + // the anchor first. + // + // A VERIFIED holder, and nothing less — maintainer ruling of 2026-09-08 + // (decision batch #100) on this card, which supersedes the Choice 4A + // sentence for this one point: + // + // > F4 — verification is a requirement, not a preference, on the + // > declared-owner leg. A declared address held by a row with + // > `email_verified !== true` is treated like a declared owner nobody can + // > sign in as: REFUSE [...] zero grant rows, and the replay predicate + // > promotes as soon as verification lands. ⛔ No fall-back to + // > oldest-authenticable while a declared address exists. + // + // So a row is the declared owner only when all four hold: it holds the + // declared address, it is human, it can authenticate, and it has verified + // that address. An earlier draft of this leg ranked verified rows AHEAD of + // unverified ones instead — that ordering is ruled moot and is gone, because + // a preference only helps when a verified holder EXISTS. `sys_user.email` + // carries a UNIQUE index on the SQL family, so a squatter who registers the + // operator's address first leaves the operator unable to hold a row at all, + // and a preference then promotes the squat. `matchesConfiguredPlatformAdmin` + // states that threat for the walled derivation — "an attacker who registers + // the operator's address before the operator does gains no standing by it" — + // and this leg now answers it the same way the walled derivation does: by + // refusal. + // + // The accepted cost, stated by the ruling rather than discovered later: a + // `single` deployment whose declared owner has not verified their email gets + // NO platform admin at first boot until they do, loudly. That is the + // intended loud failure; it replaces a silent wrong promotion. + // + // `isHumanUser` still applies: a declared address sitting on `usr_system` or + // a `role: 'system'` row must not become a route to the grant. + const declaredOwners = resolvePlatformAdminEmails(); + if (declaredOwners.emails.length > 0) { + // Both spellings, same discipline as `resolvePlatformAdminStanding`: a + // driver `where` is an exact match and an imported/legacy row may not be + // stored lowercased. Declaration order decides between several declared + // addresses; `created_at` decides between several rows holding one. + let declaredTarget: any | undefined; + let declaredMatches = 0; + // Rows that hold a declared address, are human, and can sign in. The + // decline below discriminates on this count, so "nobody holds it / nobody + // can sign in as it" and "somebody can sign in but has not verified" + // report different reasons instead of one blurred refusal. + let declaredAuthenticable = 0; + for (let i = 0; i < declaredOwners.emails.length && !declaredTarget; i++) { + const email = declaredOwners.emails[i]!; + const spelling = declaredOwners.declaredSpellings[i] ?? email; + const byId = new Map(); + for (const s of new Set([email, spelling])) { + for (const row of await tryFind(ql, 'sys_user', { email: s }, 10)) { + if (row && typeof row === 'object' && row.id) byId.set(String(row.id), row); + } + } + const matching = [...byId.values()] + .filter((row) => normalizePlatformAdminEmail(row.email) === email) + .filter(isHumanUser) + .sort(byCreatedAtAsc); + declaredMatches += matching.length; + // Authenticability is asked FIRST so the two refusals stay separable: + // #14348's "nobody can sign in as the declared address" keeps its own + // reason code and its own pins, and the ruling's new requirement reports + // itself as itself. `isEmailVerifiedUserRow` is the same fail-closed + // predicate the walled derivation and the audit surface read (an ABSENT + // column is unverified) — never a second local copy of "looks verified". + for (const row of matching) { + if (!(await canAuthenticate(row))) continue; + declaredAuthenticable += 1; + if (!isEmailVerifiedUserRow(row)) continue; + declaredTarget = row; + break; + } + } + if (declaredTarget) { + return promote(declaredTarget, { + basis: 'declared-owner', + pool: + `${declaredOwners.emails.length} address(es) declared in ${PLATFORM_OWNER_EMAIL_ENV}, ` + + `${declaredMatches} matching human user row(s)`, + candidatePoolSize: declaredMatches, + }); + } + // Declared, and not one declared address is held by a verified holder who + // can sign in. ⛔ NOT a silent fall-back to whoever happens to be oldest: + // the operator named the owner, so promoting somebody else is the very + // outcome this card is about. The walled branch already refuses loudly for + // the same input (see the `walled_owner_email_undeclared` diagnostic + // above); this is that answer for `single`. The replay predicate re-runs + // this pass on the next `sys_user` / `sys_account` insert AND on the + // verifying update, so the declared owner is promoted the moment both + // their login and their verification exist. + const unverifiedOnly = declaredAuthenticable > 0; + const diagnosis = declaredMatches === 0 + ? 'no human sys_user row holds that address' + : unverifiedOnly + ? `none of the ${declaredAuthenticable} matching human row(s) that can sign in has VERIFIED that ` + + 'address (email_verified is not true), and verification is REQUIRED of the declared owner' + : `none of the ${declaredMatches} matching human row(s) can authenticate (no sys_account)`; + const remedy = unverifiedOnly + ? 'Complete the email verification for the declared address' + : 'Register and sign in as the declared address'; + const message = + `[security] ${PLATFORM_OWNER_EMAIL_ENV} declares ` + + `${declaredOwners.emails.map((e) => JSON.stringify(e)).join(', ')} as this deployment's platform ` + + `administrator, but ${diagnosis}` + + ' — platform admin NOT promoted. Promotion is NOT falling back to the oldest ' + + 'authenticable user: that would hand the highest-privilege grant to somebody the ' + + `operator did not choose. ${remedy}, or unset ` + + `${PLATFORM_OWNER_EMAIL_ENV} to use first-user promotion.`; + if (logger?.warn) logger.warn(message); + else logger?.info?.(message); + return { + seeded: seededCount, + adminPromoted: false, + // [#16682, batch #100] The ruling allows either a distinct code or a + // fold into `declared_owner_not_authenticable` with verification named + // in the warning. A distinct code is used for the verification miss so + // #14348's refusal keeps its own name and its own pins, and an operator + // reading a structured sink can tell "register a login" apart from + // "click the link in your mailbox". + reason: unverifiedOnly ? 'declared_owner_not_verified' : 'declared_owner_not_authenticable', + ...resyncCounts, + }; + } + + // ── Leg 2: no declaration — the oldest authenticable human ───────────────── + let scannedHumans = 0; + let scanTruncated = false; + let target: any | undefined; + const pageSize = PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE; + const ceiling = PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING; + for (let offset = 0; offset < ceiling && !target; offset += pageSize) { + const pageLimit = Math.min(pageSize, ceiling - offset); + const page = await tryFind(ql, 'sys_user', {}, pageLimit, OLDEST_FIRST, offset); + if (page.length === 0) break; + const humans = page.filter(isHumanUser); + scannedHumans += humans.length; + target = await firstAuthenticable(humans); + if (target) break; + if (page.length < pageLimit) break; + if (offset + page.length >= ceiling) scanTruncated = true; + } + if (scannedHumans === 0) { logger?.info?.('[security] no human users yet — first sign-up will be promoted to platform admin'); return { seeded: seededCount, adminPromoted: false, reason: 'no_users', ...resyncCounts }; } - const target = await oldestAuthenticable(ql, humanUsers); if (!target) { // [#14348] Humans exist, but not one of them can sign in. Measured on a // real composed boot before this branch existed: an app seeding people @@ -590,10 +909,23 @@ export async function bootstrapPlatformAdmin( // nobody has signed up yet), the same register the `no_users` line above // uses, and a published sink shape gains nothing from a louder level. logger?.info?.( - `[security] ${humanUsers.length} human user row(s) exist but none can authenticate (no sys_account) ` + + `[security] ${scannedHumans} human user row(s) exist but none can authenticate (no sys_account) ` + '— platform admin NOT promoted. The first human that signs in will be promoted instead; a ' + 'directory row nobody can sign in as would hold a grant it could never exercise.', ); + // ⛔ The truncation is never silent again (#16682). Reaching the ceiling is + // the ONE way an ordered scan can still answer "nobody" while a promotable + // human exists, so it says the number it examined instead of letting the + // line above read as a statement about the whole table. + if (scanTruncated) { + const truncation = + `[security] the platform-admin candidate scan stopped at its ceiling of ${ceiling} ` + + 'oldest sys_user row(s) and none of them can authenticate — rows beyond that point were NOT ' + + 'examined, so this deployment may hold a promotable human the boot did not see. Promote the ' + + `intended administrator explicitly by setting ${PLATFORM_OWNER_EMAIL_ENV}.`; + if (logger?.warn) logger.warn(truncation); + else logger?.info?.(truncation); + } return { seeded: seededCount, adminPromoted: false, @@ -602,29 +934,11 @@ export async function bootstrapPlatformAdmin( }; } - const inserted = await tryInsert(ql, 'sys_user_permission_set', { - id: genId('ups'), - user_id: target.id, - permission_set_id: adminPsId, - organization_id: null, - granted_by: null, + return promote(target, { + basis: 'oldest-authenticable', + pool: + `${scannedHumans} human user row(s) examined oldest-first by created_at` + + `${scanTruncated ? ` (scan ceiling ${ceiling} reached)` : ''}`, + candidatePoolSize: scannedHumans, }); - if (!inserted) { - logger?.warn?.(`[security] failed to grant admin_full_access to first user ${target.email ?? target.id}`); - return { seeded: seededCount, adminPromoted: false, reason: 'insert_failed', ...resyncCounts }; - } - logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`); - - // Hand seeded business records (owner_id NULL / usr_system) to the freshly - // promoted admin so owner-keyed UX works out of the box. Best-effort and - // idempotent — failures here must not undo the promotion above. - let ownershipClaimed = 0; - try { - const claims = await claimSeedOwnership(ql, target.id, { logger }); - ownershipClaimed = claims.reduce((s, c) => s + c.count, 0); - } catch (e) { - logger?.warn?.('[security] seed ownership handoff failed', { error: (e as Error).message }); - } - - return { seeded: seededCount, adminPromoted: true, ownershipClaimed, ...resyncCounts }; } diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 6475778e8b..5af76c1e4a 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2656,6 +2656,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts", + "verb": "update", + "pinned": 3 + }, { "file": "packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts", "verb": "update",