diff --git a/.changeset/great-pugs-attack.md b/.changeset/great-pugs-attack.md new file mode 100644 index 0000000000..477e065152 --- /dev/null +++ b/.changeset/great-pugs-attack.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-security': minor +--- + +Re-run the seed-ownership claim when the seed settles, and report whether each pass was final. + +`claimSeedOwnership` was reached exactly once per database lifetime, on the pass that promotes the first platform admin, while the platform's own seeder was still writing in the background — an app bundle that overruns `OS_INLINE_SEED_BUDGET_MS` (default 8 s) continues past kernel start rather than block it. Registry order and seed order are unrelated, so every object whose rows landed after that walk stayed `owner_id IS NULL` permanently: nothing re-ran the claim. Ownerless rows are invisible to every `readScope: 'own'` grant, and under `public_read` they read fine and answer 403 on every write at `modifyAllRecords: false` — a granted permission that can never be exercised. + +The claim now also runs on `app:seeded`, the published settle signal for that background continuation, against the same admin and with the same predicates — so it moves ownership for exactly the rows the promotion-time pass missed, and never for a row a human already owns. + +Two additive keys support it, both optional: `bootstrapPlatformAdmin` reports `adminUserId` on the promotion path and on the `already_have_admin` short-circuit (so the re-run reads the one existing holder scan instead of a second copy of it), and both `bootstrapPlatformAdmin` and `claimSeedOwnership` accept a `seedSettlement` snapshot read through the `seed-settlement` contract. No existing key, argument or return shape changed. + +Every claim pass now logs one line whether or not it claimed anything, and says whether its reading was final: a pass taken while a seed source is still writing is reported at `warn` as PROVISIONAL. Previously a pass that matched nothing logged nothing at all, so a boot that permanently orphaned rows and a boot with nothing to do produced identical evidence. diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 9ae10b0b3d..ad6a3cb471 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -80,6 +80,7 @@ import { reportLegacyPlatformAdminGrant, resolvePlatformAdminEmails, } from '@objectstack/core'; +import type { SeedSettlementSnapshot } from '@objectstack/spec/contracts'; import { claimSeedOwnership } from './claim-seed-ownership.js'; import { createSeedWriteRefusals, @@ -120,6 +121,18 @@ interface BootstrapOptions { * (owned by package metadata). */ resync?: boolean; + /** + * The seed pipeline's tally at the moment this bootstrap runs, read by the + * caller through the published `seed-settlement` contract. + * + * Handed straight to {@link claimSeedOwnership} and used for nothing else: + * the claim pass is the only step here whose answer depends on whether the + * platform's own seeder has finished writing, and a pass that cannot say so + * reports "claimed 0 of 0" for both "nothing to claim" and "nothing had + * landed yet". Absent for callers with no kernel context (`os meta resync`), + * which the claim reports as `unattested` rather than guessing. + */ + seedSettlement?: SeedSettlementSnapshot | undefined; } const SYSTEM_CTX = { isSystem: true }; @@ -383,6 +396,24 @@ export async function bootstrapPlatformAdmin( reason?: string; /** Count of seeded rows re-owned to the freshly-promoted admin. */ ownershipClaimed?: number; + /** + * WHO holds the unscoped `admin_full_access` grant after this pass — the user + * this pass promoted, or the holder the `already_have_admin` short-circuit + * found. Present on both, absent on every other return and under walled + * postures (where no grant row exists and standing is config-derived at + * request time, so there is no row-based answer to give). + * + * It exists because the seed-ownership claim is **not a single pass** and the + * later passes need a target. The claim hands seeded rows to this user; a + * bundle that overruns `OS_INLINE_SEED_BUDGET_MS` keeps writing rows after the + * promotion instant, and the re-run on `app:seeded` must re-own them to the + * SAME admin. Before this, the short-circuited pass knew the answer and threw + * it away, so the only way to re-own the missed rows was to re-derive the + * holder — a second implementation of the two-leg scan above, which is how the + * guard and its copy drift apart (#16861 is what that scan costs to get + * right). One owner, read by both passes. + */ + adminUserId?: string; /** [#2705] Existing platform-owned rows reconciled to dist under `resync`. */ resynced?: number; /** [#2705] Existing rows left untouched by `resync` (admin/package-owned). */ @@ -658,6 +689,10 @@ export async function bootstrapPlatformAdmin( seeded: seededCount, adminPromoted: false, reason: 'already_have_admin', + // The promotion is a no-op forever; the CLAIM is not. This pass is the + // only thing on a later boot that knows who the seeded rows belong to, + // and the seed-settle re-run needs that name (see `adminUserId` above). + ...(unscopedHolder.user_id ? { adminUserId: String(unscopedHolder.user_id) } : {}), ...resyncCounts, ...grantScanCounts, }; @@ -859,9 +894,19 @@ export async function bootstrapPlatformAdmin( // 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. + // + // ⚠️ This pass is NOT the last word, and does not pretend to be. The + // promotion instant is not the moment the seed is done: an app bundle that + // overruns `OS_INLINE_SEED_BUDGET_MS` keeps writing in the background, so + // rows can land after this walk and would stay ownerless forever. The + // settlement snapshot is what lets the pass SAY which of the two it was, + // and `security-plugin.ts` re-runs the claim on `app:seeded`. let ownershipClaimed = 0; try { - const claims = await claimSeedOwnership(ql, chosen.id, { logger }); + const claims = await claimSeedOwnership(ql, chosen.id, { + logger, + seedSettlement: options.seedSettlement, + }); ownershipClaimed = claims.reduce((sum, c) => sum + c.count, 0); } catch (e) { logger?.warn?.('[security] seed ownership handoff failed', { error: (e as Error).message }); @@ -871,6 +916,7 @@ export async function bootstrapPlatformAdmin( seeded: seededCount, adminPromoted: true, ownershipClaimed, + adminUserId: String(chosen.id), basis: audit.basis, ...resyncCounts, ...grantScanCounts, diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts new file mode 100644 index 0000000000..1fc5651d5c --- /dev/null +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts @@ -0,0 +1,402 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The seed-ownership claim is NOT a single pass — and it says so. + * + * ## The defect these pins are written against + * + * `claimSeedOwnership` was reached from `bootstrapPlatformAdmin` exactly once + * per database lifetime, on the pass that promotes the first admin, and it + * walked the object registry while the platform's own seeder was still writing + * in the BACKGROUND — `AppPlugin` races its inline seed against + * `OS_INLINE_SEED_BUDGET_MS` (default 8 s) and continues an over-budget bundle + * past kernel start rather than block it. Registry order and seed order are + * unrelated, so every object whose rows landed after its walk stayed + * `owner_id IS NULL` forever: nothing ever re-ran the claim. Measured on a CRM + * bundle at `@objectstack/* 17.4.0` — 73 rows across six objects, the SAME + * loser set on two independent boots on fresh databases. + * + * The consequence is a permission one, which is why the rows cannot just be + * left: an ownerless row is invisible to every `readScope: 'own'` grant, and + * under `public_read` it reads fine and answers 403 on every write for any + * grant at `modifyAllRecords: false` — a granted permission that can never be + * exercised. + * + * ## Two halves, pinned separately + * + * **Ordering** — `security-plugin.ts` re-runs the claim on `app:seeded`, the + * published settle signal for that background continuation. ⛔ Deliberately NOT + * done by widening `shouldReplayBootstrapFor`: a replayed bootstrap + * short-circuits on `already_have_admin` and returns BEFORE the claim, so a + * wider trigger re-runs a pass that cannot do the missed work. The end-to-end + * pin below therefore drives the real `SecurityPlugin`, not the claim helper — + * the wiring IS the fix. + * + * **The detector** — the silence was part of the defect, not a separate nit. + * A pass that matched nothing used to log nothing at all, so a boot that left + * rows permanently ownerless and a boot with nothing to do produced identical + * evidence. Every pass now reports what it did AND whether its reading was + * final, keyed on the published `seed-settlement` contract. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; +import type { SeedSettlementSnapshot } from '@objectstack/spec/contracts'; +import { SecurityPlugin } from './security-plugin.js'; +import { claimSeedOwnership } from './claim-seed-ownership.js'; + +const ADMIN = 'usr_admin_human'; +const SYSTEM = 'usr_system'; + +// ─────────────────────────────────────────────────────────────────────────── +// A generic in-memory engine double +// ─────────────────────────────────────────────────────────────────────────── + +/** + * `where` as this package spells it: field equality plus `{ id: { $in: [...] } }`. + * + * Anything else is REFUSED rather than answered — a combinator read as a field + * name, or an unimplemented value operator read as a literal, is silently wrong + * on exactly the shape a pin exists to judge. + */ +function rowMatches(row: any, where: Record = {}): boolean { + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) { + throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); + } + const actual = row?.[k] ?? null; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const ops = Object.keys(v as Record); + if (ops.length === 1 && ops[0] === '$in') { + return ((v as any).$in as unknown[]).some((m) => (m ?? null) === actual); + } + throw new Error( + `this double implements equality and $in only; it cannot answer ${JSON.stringify(ops)}`, + ); + } + return actual === (v ?? null); + }); +} + +/** + * An in-memory ObjectQL double over a table map. + * + * `update` opens with the PRODUCER's own dispatch predicate + * (`check:engine-double-contract`) rather than a hand-mirrored guard: a double + * looser than the engine would let a regression to single-id writes pass green. + * A predicate write resolves the AFFECTED ROW COUNT (#4639), never a record. + */ +function makeEngine(tables: Record, schemas: any[]) { + const middlewares: any[] = []; + const engine: any = { + tables, + middlewares, + registry: { getAllObjects: () => schemas }, + registerMiddleware: (mw: any) => middlewares.push(mw), + getSchema: (name: string) => schemas.find((s) => s.name === name), + async find(object: string, query: any = {}) { + const all = tables[object] ?? []; + let hits = all.filter((r) => rowMatches(r, query?.where ?? {})); + for (const ord of [...(query?.orderBy ?? [])].reverse()) { + hits = [...hits].sort((a, b) => { + const av = a?.[ord.field] ?? ''; + const bv = b?.[ord.field] ?? ''; + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return ord.order === 'desc' ? -cmp : cmp; + }); + } + if (typeof query?.offset === 'number') hits = hits.slice(query.offset); + if (typeof query?.limit === 'number') hits = hits.slice(0, query.limit); + return hits.map((r) => ({ ...r })); + }, + async findOne(object: string, query: any = {}) { + // The producer's own predicate, imported rather than re-derived + // (`check:engine-double-contract`): a double looser than `ObjectQL.findOne` + // is how a dead code path ships with its suite green. + assertEngineFindOnePredicate(object, query); + const rows = await engine.find(object, { ...query, limit: 1 }); + return rows[0] ?? null; + }, + async insert(object: string, data: any) { + (tables[object] ??= []).push({ ...data }); + return { ...data }; + }, + async update(object: string, data: any, options: any) { + const dispatch = assertEngineUpdateDispatch(data, options); + const rows = tables[object] ?? []; + if (dispatch.kind === 'multi') { + const matched = rows.filter((r) => rowMatches(r, options?.where ?? {})); + for (const r of matched) Object.assign(r, data); + return matched.length; + } + const target = rows.find((r) => r.id === (data?.id ?? options?.where?.id)); + if (target) Object.assign(target, data); + return target ? 1 : 0; + }, + }; + return engine; +} + +/** A seed-settlement tracker whose tally the test drives by hand. */ +function makeSettlement(initialInFlight: number) { + let inFlight = initialInFlight; + return { + settleOne: () => { + inFlight = Math.max(0, inFlight - 1); + }, + service: { + snapshot: (): SeedSettlementSnapshot => ({ pending: inFlight, inFlight, suppressed: [] }), + }, + }; +} + +/** A business object the claim is eligible to walk. */ +const businessObject = (name: string) => ({ + name, + fields: [{ name: 'id' }, { name: 'owner_id' }], +}); + +/** The seed loader's own write: a plain record, `owner_id` left unset. */ +const seedRow = (id: string) => ({ id, owner_id: null }); + +// ─────────────────────────────────────────────────────────────────────────── +// The ordering half — driven through the real plugin +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Boot `SecurityPlugin` over the double with one promotable human, one object + * whose seed rows have already landed, and one whose rows land later. + */ +async function bootPlugin() { + const settlement = makeSettlement(1); + const schemas = [businessObject('crm_account'), businessObject('crm_contract')]; + const tables: Record = { + // The seeder's identity row plus one human who can authenticate — the + // promotion target. + sys_user: [ + { id: SYSTEM, email: 'system@objectstack', created_at: '2020-01-01T00:00:00.000Z' }, + { id: ADMIN, email: 'admin@objectos.ai', created_at: '2026-01-01T00:00:00.000Z' }, + ], + sys_account: [{ id: 'acc_1', user_id: ADMIN, provider_id: 'credential' }], + sys_user_permission_set: [], + sys_permission_set: [], + // WINNER: rows the seeder already wrote before the promotion instant. + crm_account: [seedRow('acc_seed_1'), seedRow('acc_seed_2')], + // LOSER: the object whose rows the background seed has not reached yet. + crm_contract: [], + }; + const engine = makeEngine(tables, schemas); + const hooks: Array<[string, (...a: any[]) => any]> = []; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { get: async () => null, list: async () => [] }, + [SEED_SETTLEMENT_SERVICE]: settlement.service, + }; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const ctx: any = { + logger, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + hook: (name: string, cb: any) => hooks.push([name, cb]), + }; + + const plugin = new SecurityPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + + const fire = async (event: string, payload?: unknown) => { + const matching = hooks.filter(([n]) => n === event); + for (const [, cb] of matching) await cb(payload); + return matching.length; + }; + const ownerOf = (object: string) => (tables[object] ?? []).map((r) => r.owner_id); + return { tables, logger, fire, ownerOf, settlement }; +} + +describe('seed-ownership claim — the one-shot pass and the seed it races', () => { + it('re-owns rows seeded AFTER the promotion pass, once the seed settles', async () => { + const rig = await bootPlugin(); + + // ── Boot: the promotion pass runs while the seeder is still writing ───── + const readyHooks = await rig.fire('kernel:ready'); + expect(readyHooks).toBeGreaterThan(0); + + // Positive control. The one-shot pass is REAL and does claim: without this + // line a fixture that claimed nothing at all would satisfy the assertions + // below for the wrong reason. + expect(rig.ownerOf('crm_account')).toEqual([ADMIN, ADMIN]); + + // ── The background seed lands its remaining rows, past kernel start ───── + rig.tables.crm_contract.push(seedRow('con_1'), seedRow('con_2'), seedRow('con_3')); + // Ownerless at this instant — this is the defect's own moment, measured + // rather than asserted away: the one-shot pass walked `crm_contract` before + // these rows existed and nothing re-reads it. + expect(rig.ownerOf('crm_contract')).toEqual([null, null, null]); + + // ── The settle signal for exactly that continuation ───────────────────── + rig.settlement.settleOne(); + const seededHooks = await rig.fire('app:seeded', { appId: 'com.example.crm', overBudget: true }); + expect(seededHooks).toBe(1); + + // The rows the one-shot pass missed are now owned by the SAME admin. + expect(rig.ownerOf('crm_contract')).toEqual([ADMIN, ADMIN, ADMIN]); + }); + + it('moves ownership for the missed rows ONLY — a row a human already owns is untouched', async () => { + const rig = await bootPlugin(); + await rig.fire('kernel:ready'); + + // Three rows the background seed lands late, in the three states the claim + // can meet: unowned, owned by the seeder's own identity, and owned by a + // DIFFERENT human. The third is the permission boundary this repair must + // not move — re-owning it would be a change of who owns a row beyond the + // rows the one-shot pass missed. + rig.tables.crm_contract.push( + { id: 'con_null', owner_id: null }, + { id: 'con_system', owner_id: SYSTEM }, + { id: 'con_other', owner_id: 'usr_someone_else' }, + ); + + rig.settlement.settleOne(); + await rig.fire('app:seeded', { appId: 'com.example.crm', overBudget: true }); + + expect(rig.ownerOf('crm_contract')).toEqual([ADMIN, ADMIN, 'usr_someone_else']); + }); + + it('claims to the SAME admin on a later boot, where the promotion short-circuits', async () => { + // `already_have_admin`: the grant row already exists, so no promotion + // happens and the old code never reached the claim at all. The pass still + // knows who the admin is, and the settle re-run claims to that user. + const rig = await bootPlugin(); + rig.tables.sys_user_permission_set.push({ + id: 'ups_existing', + user_id: ADMIN, + permission_set_id: 'ps_admin_full_access', + organization_id: null, + }); + rig.tables.sys_permission_set.push({ id: 'ps_admin_full_access', name: 'admin_full_access' }); + + await rig.fire('kernel:ready'); + rig.tables.crm_contract.push(seedRow('con_late')); + rig.settlement.settleOne(); + await rig.fire('app:seeded', { appId: 'com.example.crm', overBudget: true }); + + expect(rig.ownerOf('crm_contract')).toEqual([ADMIN]); + }); + + it('does nothing when no admin has been resolved yet', async () => { + // An in-budget seed settles before any human exists. There is nobody to + // claim to, and the promotion that follows does its own claim against a + // seed that has already settled. + const rig = await bootPlugin(); + rig.tables.sys_user = []; + rig.tables.sys_account = []; + await rig.fire('kernel:ready'); + rig.tables.crm_contract.push(seedRow('con_1')); + + rig.settlement.settleOne(); + await rig.fire('app:seeded', { appId: 'com.example.crm', overBudget: false }); + + expect(rig.ownerOf('crm_contract')).toEqual([null]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// The detector half +// ─────────────────────────────────────────────────────────────────────────── + +/** The smallest rig that runs one claim pass and captures what it reported. */ +async function runClaim(rows: any[], seedSettlement: SeedSettlementSnapshot | undefined) { + const schemas = [businessObject('crm_contract')]; + const engine = makeEngine({ crm_contract: rows }, schemas); + const logger = { info: vi.fn(), warn: vi.fn() }; + const results = await claimSeedOwnership(engine, ADMIN, { logger, seedSettlement }); + return { results, logger }; +} + +describe('seed-ownership claim — "claimed 0 of 0" versus "nothing to claim"', () => { + it('WARNS that a pass taken while a seed is still writing is provisional', async () => { + // The defect's own shape: the walk reaches an object whose rows have not + // landed yet, matches nothing, and — before this — said nothing at all. + const { results, logger } = await runClaim([], { pending: 1, inFlight: 1, suppressed: [] }); + + expect(results).toEqual([]); + expect(logger.warn).toHaveBeenCalledTimes(1); + const [message, meta] = logger.warn.mock.calls[0]!; + expect(message).toContain('handed 0 seeded record(s)'); + expect(message).toContain('PROVISIONAL'); + expect(message).toContain('app:seeded'); + expect(meta).toMatchObject({ claimed: 0, eligibleObjects: 1, seedInFlight: 1 }); + // ⚠️ The whole point: this is NOT the line a settled pass emits. + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('reports a settled pass as FINAL — the same count, a different fact', async () => { + const { results, logger } = await runClaim([], { pending: 0, inFlight: 0, suppressed: [] }); + + expect(results).toEqual([]); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledTimes(1); + const [message] = logger.info.mock.calls[0]!; + expect(message).toContain('handed 0 seeded record(s)'); + expect(message).toContain('final'); + expect(message).toContain('nothing left to claim'); + }); + + it('the two zero-row passes are distinguishable — the count alone is not', async () => { + // Both walked one eligible object and claimed nothing. Before the repair + // they produced BYTE-IDENTICAL evidence (none), which is what let a boot + // that permanently orphaned 73 rows report healthy. + const provisional = await runClaim([], { pending: 1, inFlight: 1, suppressed: [] }); + const settled = await runClaim([], { pending: 0, inFlight: 0, suppressed: [] }); + + expect(provisional.results).toEqual(settled.results); + const said = (rig: typeof provisional) => + [...rig.logger.warn.mock.calls, ...rig.logger.info.mock.calls].map((c) => String(c[0])); + expect(said(provisional)).not.toEqual(said(settled)); + expect(said(provisional).join(' ')).toContain('PROVISIONAL'); + expect(said(settled).join(' ')).toContain('final'); + }); + + it('a suppressed source is NOT in flight — a multi-tenant boot is final, not provisional', async () => { + // Suppressed sources (multi-tenant replay, `skipSeedData`) never settle and + // write no rows during this boot, so there is nothing for this pass to + // miss on their account. Keying finality on `pending` would mark every such + // boot provisional forever — a permanent warning about correct behaviour. + const { logger } = await runClaim([], { + pending: 1, + inFlight: 0, + suppressed: ['multi-tenant-replay'], + }); + + expect(logger.warn).not.toHaveBeenCalled(); + expect(String(logger.info.mock.calls[0]![0])).toContain('final'); + }); + + it('says so when no settlement probe is registered, rather than guessing', async () => { + const { logger } = await runClaim([], undefined); + + expect(logger.warn).not.toHaveBeenCalled(); + const [message] = logger.info.mock.calls[0]!; + expect(message).toContain('unattested'); + expect(message).not.toContain('final'); + }); + + it('reports the claimed count and the walked population on a productive pass', async () => { + const { results, logger } = await runClaim( + [seedRow('c1'), { id: 'c2', owner_id: SYSTEM }, { id: 'c3', owner_id: 'usr_other' }], + { pending: 0, inFlight: 0, suppressed: [] }, + ); + + expect(results).toEqual([{ object: 'crm_contract', count: 2 }]); + const [message, meta] = logger.info.mock.calls[0]!; + expect(message).toContain('handed 2 seeded record(s)'); + expect(message).toContain('1 of 1 eligible object(s)'); + expect(meta).toMatchObject({ claimed: 2, eligibleObjects: 1 }); + }); +}); diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts index b772de37ab..6d34e418a5 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.ts @@ -11,12 +11,29 @@ * human can log in as, so owner-keyed UX — "My" views, owner reports, owner * notifications — is empty out of the box. * - * This helper runs **once**, right after `bootstrapPlatformAdmin` promotes the - * first human user to platform admin, and transfers ownership of those orphan - * rows to that admin. It is the ownership twin of org-scoping's - * `claimOrphanOrgRows` (which back-fills `organization_id`): walk every - * user-authored object that declares the canonical `owner_id` column, and - * re-own the rows that no human owns yet. + * This helper runs right after `bootstrapPlatformAdmin` promotes the first human + * user to platform admin, and transfers ownership of those orphan rows to that + * admin. It is the ownership twin of org-scoping's `claimOrphanOrgRows` (which + * back-fills `organization_id`): walk every user-authored object that declares + * the canonical `owner_id` column, and re-own the rows that no human owns yet. + * + * ## ⚠️ It is NOT a single pass, and cannot be + * + * The promotion instant is not the moment the seed is done. `AppPlugin` races + * its inline seed against `OS_INLINE_SEED_BUDGET_MS` (default 8 s) and continues + * an over-budget bundle IN THE BACKGROUND so it does not block kernel start — so + * for any non-trivial app the seeder is *guaranteed* to still be writing while a + * promotion-time pass walks the registry. Registry order and seed order are + * unrelated, so which objects a single pass misses is arbitrary, deterministic + * per app, and permanent: nothing re-ran the claim, and the missed rows stayed + * `owner_id IS NULL` forever — invisible to every `readScope: 'own'` grant and + * answering 403 on every write at `modifyAllRecords: false`. + * + * A claim on admin promotion that races a seeder the platform itself deferred + * cannot be correct as a single pass. `security-plugin.ts` therefore re-runs + * this helper on `app:seeded` — the published settle signal for exactly that + * background continuation — and every pass reports whether its own reading was + * final ({@link reportClaimPass}). * * Mistake-proof by construction: authors write plain seed records (no * `owner_id`), and the platform — not the author — performs the handoff. There @@ -86,12 +103,23 @@ import type { ServiceObject } from '@objectstack/spec/data'; import { BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE, MAX_BULK_PER_ROW_HOOK_ROWS } from '@objectstack/spec/data'; import { SystemUserId } from '@objectstack/spec/system'; +import type { SeedSettlementSnapshot } from '@objectstack/spec/contracts'; interface ClaimOwnershipOptions { logger?: { info: (message: string, meta?: Record) => void; warn: (message: string, meta?: Record) => void; }; + /** + * The seed pipeline's tally at the moment this pass starts, read through the + * published `seed-settlement` contract — `undefined` when no seed pipeline is + * registered on this kernel. + * + * This is what makes the pass's own report FALSIFIABLE; see + * {@link reportClaimPass} for why a pass that cannot say this is a detector + * that reports success for the one failure it exists to catch. + */ + seedSettlement?: SeedSettlementSnapshot | undefined; } const SYSTEM_CTX = { isSystem: true }; @@ -293,6 +321,100 @@ async function claimPredicate( return total; } +/** + * Say what this pass did AND whether its reading was FINAL — once, always. + * + * ## Why "claimed nothing" used to be unsayable + * + * This function used to report only when it had re-owned at least one row, so a + * pass that walked every eligible object and matched nothing logged NOTHING at + * all. That silence is the shape the ordering defect hid behind: the claim ran + * while the platform's own seeder was still writing in the background + * (`OS_INLINE_SEED_BUDGET_MS`), matched the rows that happened to have landed, + * and the rows that had not yet landed were never seen by anything. Its own + * failure paths — the per-object `claimSeedOwnership failed for …`, the paging + * warnings, the affected-count warnings — cannot fire on that shape, because + * from the claim's point of view there was simply nothing to match. So a boot + * that left rows permanently ownerless and a boot with nothing to do produced + * BYTE-IDENTICAL evidence, and the banner was clean either way. + * + * ## What makes the two distinguishable + * + * Not the count — "claimed 0 of 0" is the same number in both. The discriminator + * is whether a seed source was still WRITING when the pass ran, which is exactly + * what the published `seed-settlement` contract answers + * ({@link SeedSettlementSnapshot}). It is the same distinction AGENTS.md's + * startup-registry rule draws: reading a store that is still filling is fine, + * but recording "there was nothing here" as a VERDICT, when the same boot can + * still contradict it, is the defect. + * + * So every pass says which of three things it is: + * + * - **provisional** (`inFlight > 0`) — a seed source is still writing, so this + * pass is a reading and not a verdict. Rows that land after it are NOT + * covered by it. `warn`, because at the moment the line is printed those rows + * are unowned and nothing else about the boot looks wrong; the re-run on + * `app:seeded` is a promise, not yet a fact. + * - **final** (`inFlight === 0`) — every source this boot writes has settled, + * so "nothing matched" really does mean "nothing to claim". `info`. + * - **unattested** (no snapshot) — no seed pipeline registered on this kernel, + * or a host too old to publish the contract. Reported as its own state rather + * than folded into either: a pass that cannot tell must not claim it can. + * + * ⚠️ `suppressed` sources deliberately never settle (multi-tenant replay, + * `skipSeedData`) and are NOT counted as in-flight here: they write no rows + * during this boot, so there is nothing for this pass to miss on their account. + * Keying finality on `pending` instead would mark every multi-tenant boot + * provisional forever — a permanent warning about behaviour that is correct by + * design, which is how a log level gets trained away. + * + * The `handed N seeded record(s) to first admin X` prefix is unchanged and now + * fires on every pass including `N = 0`: existing consumers match on it, and the + * finality clause is appended rather than replacing it. + */ +function reportClaimPass( + logger: ClaimOwnershipOptions['logger'], + adminUserId: string, + results: { object: string; count: number }[], + eligibleObjects: number, + seedSettlement: SeedSettlementSnapshot | undefined, +): void { + const total = results.reduce((s, r) => s + r.count, 0); + const head = + `[security] handed ${total} seeded record(s) to first admin ${adminUserId} ` + + `(${results.length} of ${eligibleObjects} eligible object(s) had unowned rows)`; + const meta = { + adminUserId, + claimed: total, + eligibleObjects, + breakdown: results, + seedInFlight: seedSettlement?.inFlight, + seedSuppressed: seedSettlement?.suppressed, + }; + + if (seedSettlement && seedSettlement.inFlight > 0) { + logger?.warn?.( + `${head} — PROVISIONAL: ${seedSettlement.inFlight} seed source(s) were still writing when this ` + + 'pass ran, so rows seeded after it are NOT covered by it and stay unowned until the claim ' + + 're-runs on `app:seeded`. A count of 0 here is "nothing had landed yet", never "nothing to claim".', + meta, + ); + return; + } + if (!seedSettlement) { + logger?.info?.( + `${head} — unattested: no seed-settlement probe is registered on this kernel, so this pass ` + + 'cannot say whether a seed was still writing when it ran.', + meta, + ); + return; + } + logger?.info?.( + `${head} — final: every seed source this boot writes has settled, so there was nothing left to claim.`, + meta, + ); +} + /** * Re-own every orphan seed row (owner_id NULL or usr_system) to `adminUserId`. * @@ -325,6 +447,10 @@ export async function claimSeedOwnership( const schemas: ServiceObject[] = registry.getAllObjects(); const results: { object: string; count: number }[] = []; + // Objects this pass actually WALKED, after the four skips below. Reported so + // "claimed 0" can be read against the size of what was examined — a pass over + // zero eligible objects and a pass over forty are different facts. + let eligibleObjects = 0; for (const schema of schemas) { if (!schema?.name) continue; @@ -338,6 +464,7 @@ export async function claimSeedOwnership( // "no such table". Skip them entirely. if ((schema as any).external) continue; if (!hasOwnerField(schema)) continue; + eligibleObjects += 1; // Bound HERE, where `schema.name` is a literal argument at the call site — // see {@link ObjectWriter} for why that spelling is not incidental. @@ -374,11 +501,6 @@ export async function claimSeedOwnership( if (updated > 0) results.push({ object: schema.name, count: updated }); } - if (results.length > 0) { - const total = results.reduce((s, r) => s + r.count, 0); - logger?.info?.(`[security] handed ${total} seeded record(s) to first admin ${adminUserId}`, { - breakdown: results, - }); - } + reportClaimPass(logger, adminUserId, results, eligibleObjects, options.seedSettlement); return results; } diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 57b8a8e2c2..70f04ed0f9 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -109,6 +109,9 @@ import { type AuthoredRowWriteVerdict, type AuthoredRowWriteOperation, type DelegationNarrowing, + SEED_SETTLEMENT_SERVICE, + type ISeedSettlementService, + type SeedSettlementSnapshot, } from '@objectstack/spec/contracts'; import { matchesFilterCondition } from '@objectstack/formula'; import { FieldMasker } from './field-masker.js'; @@ -123,6 +126,7 @@ import { } from './errors.js'; import { assertEngineOwnedWriteAllowed } from './system-write-guard.js'; import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; +import { claimSeedOwnership } from './claim-seed-ownership.js'; import { createPlatformAdminService } from './platform-admin-service.js'; import { backfillOrgAdminGrants, @@ -3468,6 +3472,37 @@ export class SecurityPlugin implements Plugin { // insert seed rows. Falls back to immediate execution when the // kernel does not expose `hook` (test stubs). let bootstrapRanOnce = false; + /** + * Who the seed-ownership claim hands rows to — the admin the last bootstrap + * pass promoted, or the one it found already holding the unscoped grant. + * + * Kept because the claim is not a single pass (see the `app:seeded` hook + * below). `bootstrapPlatformAdmin` is the ONE place that answers "who is the + * platform admin" from the grant rows — through a two-leg, ordered, bounded + * scan that took its own card to get right — so the re-run reads its answer + * rather than growing a second copy of that scan here. + */ + let claimTargetAdminUserId: string | undefined; + /** + * "Has this boot's own seed data finished landing?", asked through the + * published `seed-settlement` contract rather than by sniffing the runtime's + * internal `seed-datasets` service — that array's presence says a seed + * source EXISTS, never whether it has SETTLED, and the gap between those two + * facts is the whole defect. `undefined` means no seed pipeline registered + * on this kernel, which by `kernel:ready` is a fact and not a not-yet (every + * source is declared in Phase 2 `start()`). + */ + const readSeedSettlement = (): SeedSettlementSnapshot | undefined => { + try { + const svc = (ctx as any).getService?.(SEED_SETTLEMENT_SERVICE) as + | ISeedSettlementService + | undefined; + if (!svc || typeof svc.snapshot !== 'function') return undefined; + return svc.snapshot(); + } catch { + return undefined; + } + }; // [ADR-0094] Guard so the env-projection wiring runs exactly once even // though runBootstrap re-runs (e.g. after the first user insert) — // registerMutationProjector replaces idempotently, but the legacy @@ -3624,7 +3659,16 @@ export class SecurityPlugin implements Plugin { } const report = await bootstrapPlatformAdmin(ql, this.bootstrapPermissionSets, { logger: ctx.logger, + // Read per run, never cached: a pass at `kernel:ready` and a replay + // after a later sign-up see different tallies, and it is exactly the + // difference that decides whether that pass's claim is the last word. + seedSettlement: readSeedSettlement(), }); + // Remember the claim's target for the `app:seeded` re-run below. Only + // ever overwritten with a real answer: a later pass that returns none + // (walled posture, an unreadable engine) must not erase the admin an + // earlier pass resolved and leave the re-run with nobody to claim to. + if (report?.adminUserId) claimTargetAdminUserId = report.adminUserId; // Which organizations this boot seeds. Resolved ONCE per bootstrap run // and reused by all four catalog steps, so a sweep costs one // organization enumeration rather than four. @@ -3909,6 +3953,62 @@ export class SecurityPlugin implements Plugin { void runBootstrap(); } + // ── Re-run the seed-ownership CLAIM when the seed actually settles ──────── + // + // The claim used to run exactly once per database lifetime, inside the one + // pass that promotes the first admin — and that instant is not the moment + // the seed is done. `AppPlugin` races its inline seed against + // `OS_INLINE_SEED_BUDGET_MS` (default 8 s) and continues an over-budget + // bundle in the BACKGROUND rather than block kernel start, so for any + // non-trivial app the seeder is still writing while the claim walks the + // registry. Registry order and seed order are unrelated: every object whose + // rows land after its walk stayed `owner_id IS NULL` forever, because + // nothing re-ran the claim. Measured on a CRM bundle: 73 rows across six + // objects, the same loser set on two independent boots. + // + // ⛔ The fix is NOT to widen `shouldReplayBootstrapFor`. A replayed + // bootstrap short-circuits on `already_have_admin` and RETURNS before it + // ever reaches the claim, so a wider trigger re-runs a pass that cannot do + // the thing that was missed. What re-runs here is the claim itself. + // + // `app:seeded` is the published settle signal for exactly that background + // continuation — the runtime settles the source BEFORE it triggers, so a + // consumer inside this hook sees its own signal already reflected in the + // tally. It fires once per app bundle, so the first fire is not necessarily + // the last; the claim is idempotent (only NULL / `usr_system`-owned rows + // match) and every pass reports whether its own reading was final, so + // running on each fire costs a no-op walk and buys the guarantee. + // + // ⚠️ Scope: this moves ownership for exactly the rows the promotion-time + // pass missed — the predicates, the target admin and the object filter are + // the one-shot pass's own, unchanged. A row a human already owns is not + // matched by either predicate and cannot be touched here. + // + // No admin yet ⇒ nothing to do: an in-budget seed settles before any user + // exists, and the promotion that follows does its own claim against a seed + // that has already settled. + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('app:seeded', async (payload?: { appId?: string; overBudget?: boolean }) => { + const adminUserId = claimTargetAdminUserId; + if (!adminUserId) return; + try { + await claimSeedOwnership(ql, adminUserId, { + logger: ctx.logger, + seedSettlement: readSeedSettlement(), + }); + } catch (e) { + // Best-effort, exactly like the promotion-time call: a failed claim + // leaves the rows unowned and the next run claims them, because the + // predicate is still true of them. It must not break the boot. + ctx.logger.warn('[security] seed-settle ownership claim failed', { + appId: payload?.appId, + overBudget: payload?.overBudget, + error: (e as Error).message, + }); + } + }); + } + // Re-run bootstrap after a sys_user write that can change the promotion // answer, so the platform admin is promoted without a server restart: // diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 061e161b39..5fbce63502 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2711,6 +2711,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/claim-seed-ownership.test.ts", "verb": "update",