Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/great-pugs-attack.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 47 additions & 1 deletion packages/plugins/plugin-security/src/bootstrap-platform-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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 });
Expand All @@ -871,6 +916,7 @@ export async function bootstrapPlatformAdmin(
seeded: seededCount,
adminPromoted: true,
ownershipClaimed,
adminUserId: String(chosen.id),
basis: audit.basis,
...resyncCounts,
...grantScanCounts,
Expand Down
Loading
Loading