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
44 changes: 44 additions & 0 deletions .changeset/rbac-catalog-seed-refusal-is-loud.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/plugin-security": patch
---

fix(security): a refused RBAC catalog write is now boot-visible instead of reporting a seed of zero (#12923)

The five RBAC catalog seeders answered a refused write with `null`/`false`,
which is byte-for-byte the answer for "nothing to do": the `seeded` counter
never incremented and the pass returned normally. On a deployment still
enforcing a **platform-wide** unique index on the name column — the shape that
predates per-organization materialization — every per-organization INSERT is
refused that way, so the boot log read as a successful seed of zero rows.
Measured on a deployed plane, undetected for weeks: an empty Setup (no
positions, no permission sets, no capabilities) under a clean log.

The outer handler was not missing, it was **disarmed**. `security-plugin.ts`
already wrapped the organization-creation seed in a `try`/`catch` that warns,
and it was unreachable for this failure class: the refusal was converted to
`null` three call layers below, so the `await` resolved normally and the hook
logged "RBAC catalog seeded" at `info` over a seed of nothing. Another outer
`try`/`catch` would fix nothing — the signal has to survive the inner helper,
which is where the change is.

Each seeder now accumulates the writes the database refused and reports them
**once per object per class per pass**, beside its counts:

- a **unique violation** is named as a deployment-schema defect, with the
migrate remedy (`os migrate plan` → `os migrate apply`, where the legacy
index surfaces as a `replace_unique_index` operation) and a pointer to the
query engine's own redacted `Insert operation failed` entries, which keep the
colliding index identifier;
- anything **else** gets its own line and is never relabelled as the above,
because no migration repairs it.

Classification uses the shipped cross-dialect predicates in
`@objectstack/types` (`isUniqueViolationError` / `uniqueViolationColumn`), not
a local `23505` / `ER_DUP_ENTRY` regex. The warning prints only the value-free
`code`/`errno` channel — never the driver's message, which a SQL driver
prefixes with the fully bound statement.

Diagnosis only: the seeders still **warn and continue**, never throw. A rethrow
would turn a silent degradation into a boot failure on every deployment
carrying the legacy index. Counts, accept/reject behaviour and the healthy-path
logs are unchanged, and a pass that refuses nothing stays silent.
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,10 +36,14 @@

import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA, EVERYONE_POSITION, GUEST_POSITION } from '@objectstack/spec';
import {
createSeedWriteRefusals,
resolveOwnOrganizationRow,
rowMatchesDeclaration,
seedCtx,
warnOrganizationLessRows,
reportSeedWriteRefusals,
type SeedLogger,
type SeedWriteRefusals,
} from './per-organization-catalog.js';

/**
Expand DownExpand Up@@ -73,15 +77,37 @@ async function tryFind(ql: any, object: string, where: any, limit = 100, organiz
return Array.isArray(rows) ? rows : [];
} catch { return []; }
}
async function tryInsert(ql: any, object: string, data: any, organizationId?: string): Promise<any | null> {
try { return await ql.insert(object, data, { context: seedCtx(organizationId) }); } catch { return null; }
// ⛔ The `catch` RECORDS before it answers — see the sibling in
// `bootstrap-declared-positions.ts`. Answering `null`/`false` alone is what
// made a refused write indistinguishable from "nothing to do".
async function tryInsert(
ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals,
): Promise<any | null> {
try {
return await ql.insert(object, data, { context: seedCtx(organizationId) });
} catch (e) { refusals?.record(object, e); return null; }
}
async function tryUpdate(ql: any, object: string, data: any, organizationId?: string): Promise<boolean> {
try { await ql.update(object, data, { context: seedCtx(organizationId) }); return true; } catch { return false; }
async function tryUpdate(
ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals,
): Promise<boolean> {
try {
await ql.update(object, data, { context: seedCtx(organizationId) }); return true;
} catch (e) { refusals?.record(object, e); return false; }
}

interface SeedOptions {
logger?: { info: (m: string, meta?: Record<string, any>) => void; warn: (m: string, meta?: Record<string, any>) => void };
logger?: {
info: (m: string, meta?: Record<string, any>) => void;
warn: (m: string, meta?: Record<string, any>) => void;
/**
* Durability channel for a catalog write that was refused — see
* {@link SeedLogger.error} for the signature and why it is optional.
* Declared here so the level this seeder reaches for is visible in its
* own options rather than only inside the reporter; absent, the report
* falls back to `warn` and is never dropped.
*/
error?: SeedLogger['error'];
};
/**
* Seed THIS organization's copies. Omitted = the `single`-posture pass, the
* one place an organization-less catalog row is the correct shape.
Expand All@@ -101,6 +127,8 @@ export async function bootstrapBuiltinRoles(
let updated = 0;
let unchanged = 0;
const residue: string[] = [];
// One log per pass, not per refused row (see the sibling seeders).
const refusals = createSeedWriteRefusals();
const rows: Array<[string, { label: string; description: string }]> = [
...BUILTIN_IDENTITY_NAMES.map((n) => [n, BUILTIN_IDENTITY_METADATA[n]] as [string, { label: string; description: string }]),
...Object.entries(AUDIENCE_ANCHOR_METADATA),
Expand All@@ -121,11 +149,11 @@ export async function bootstrapBuiltinRoles(
if (own?.id) {
// O(changed declarations): an unchanged row costs no write at all.
if (rowMatchesDeclaration(own, fields)) { unchanged += 1; continue; }
if (await tryUpdate(ql, 'sys_position', { id: own.id, ...fields }, organizationId)) updated += 1;
if (await tryUpdate(ql, 'sys_position', { id: own.id, ...fields }, organizationId, refusals)) updated += 1;
} else {
const created = await tryInsert(ql, 'sys_position', {
id: genId('position'), name, ...fields, active: true, is_default: false,
}, organizationId);
}, organizationId, refusals);
if (created) seeded += 1;
}
}
Expand All@@ -134,6 +162,8 @@ export async function bootstrapBuiltinRoles(
// writer survives for `sys_position`, so no platform bucket is declared.
warnOrganizationLessRows(options.logger, 'sys_position', residue, organizationId);
}
// Before the counts, so an operator reads WHY the count is zero beside it.
reportSeedWriteRefusals(options.logger, refusals, organizationId);
if (seeded + updated > 0) {
options.logger?.info?.('[security] built-in identity names + audience anchors seeded into sys_position', {
seeded, updated, unchanged, total: rows.length, ...(organizationId ? { organization: organizationId } : {}),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,11 @@ import {
tryUpdate,
type ProjectionLogger,
} from './permission-set-projection.js';
import {
createSeedWriteRefusals,
reportSeedWriteRefusals,
type SeedWriteRefusals,
} from './per-organization-catalog.js';
import { buildExistingByName, type ExistingByNameIndex } from './seed-name-lookup.js';
import { readDeclared } from './bootstrap-declared-permissions.js';
import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security';
Expand DownExpand Up@@ -264,6 +269,11 @@ async function upsertPackageCapability(
*/
existingByName: ExistingByNameIndex,
logger?: ProjectionLogger,
/**
* Collects writes the database REFUSED, so the pass reports them once
* instead of returning a zero that reads as "nothing to do".
*/
refusals?: SeedWriteRefusals,
): Promise<boolean> {
if (!cap?.name) return false;

Expand DownExpand Up@@ -314,7 +324,7 @@ async function upsertPackageCapability(
package_id: packageId,
active: true,
};
const created = await tryInsert(ql, 'sys_capability', row);
const created = await tryInsert(ql, 'sys_capability', row, undefined, refusals);
if (created) {
out.seeded += 1;
// [#11096] The oracle is a SNAPSHOT taken before the loop, so it cannot
Expand DownExpand Up@@ -348,7 +358,7 @@ async function upsertPackageCapability(
// `bootstrap-seed-round-trips.test.ts` exist to make impossible.
if (!capabilityRecordDiffers(existing, fields)) {
out.unchanged += 1;
} else if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields })) {
} else if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields }, undefined, refusals)) {
out.updated += 1;
}
} else {
Expand All@@ -365,7 +375,7 @@ async function upsertPackageCapability(
// Non-curated (curated excluded above) platform row = a derived-from-
// systemPermissions placeholder. The explicit declaration CLAIMS it:
// upgrade to package provenance with the authored label/description/scope.
if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields, managed_by: 'package', package_id: packageId })) {
if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields, managed_by: 'package', package_id: packageId }, undefined, refusals)) {
out.claimed += 1;
}
return true;
Expand DownExpand Up@@ -399,6 +409,10 @@ export async function bootstrapDeclaredCapabilities(

const grantorsByCapability = indexGrantors(options.permissionSets);

// One log per pass, not per refused row: a legacy platform-wide unique index
// refuses EVERY declared capability, and a line each would bury the remedy.
const refusals = createSeedWriteRefusals();

// [#11096] ONE existence read for the whole declaration, hoisted out of the
// loop below. Each declared capability used to cost its own sequential
// `SELECT … WHERE name = ? LIMIT 1` — invisible on a local file database, one
Expand DownExpand Up@@ -426,13 +440,17 @@ export async function bootstrapDeclaredCapabilities(
// spec `packageId` (ADR-0086 D3) as fallback.
const packageId: string | undefined = cap._packageId ?? cap.packageId ?? undefined;
const grantors = grantorsByCapability.get(cap.name) ?? [];
const materialized = await upsertPackageCapability(ql, cap, packageId, out, grantors, existingByName, options.logger);
const materialized = await upsertPackageCapability(ql, cap, packageId, out, grantors, existingByName, options.logger, refusals);
// [#4967 Part 1] Report the name ONLY once this pass knows a row exists for
// it. Reporting it before the upsert decided anything is what let a refused
// declaration suppress the derivation it needed.
if (materialized) out.materializedNames.push(cap.name);
}

// Before the counts, so an operator reads WHY the count is zero beside it.
// This seeder is organization-less today (see the lookup note above), so the
// report carries no organization either.
reportSeedWriteRefusals(options.logger, refusals);
if (out.unreadable > 0) {
// [#11096] Said ONCE with the count, like the sibling seeders: a per-name
// warn on a database that is down is a log flood that buries its own
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,9 +61,12 @@ import {
} from './seed-name-lookup.js';
import { defaultPermissionSets } from './objects/default-permission-sets.js';
import {
createSeedWriteRefusals,
resolveOwnOrganizationRow,
seedCtx,
warnOrganizationLessRows,
reportSeedWriteRefusals,
type SeedWriteRefusals,
} from './per-organization-catalog.js';

export type { PermissionSeedOutcome } from './permission-set-projection.js';
Expand DownExpand Up@@ -190,6 +193,14 @@ export async function upsertPackagePermissionSet(
organizationId?: string;
/** Collects names whose pre-fix organization-less row is still standing. */
residue?: string[];
/**
* Collects writes the database REFUSED, so the pass can report them once
* instead of returning a zero that reads as "nothing to do". Passed by the
* boot catalog loop; the ADR-0086 P2 publish materializer passes nothing
* and keeps its own honest outcome (`success: false` with a reason when it
* materialized nothing).
*/
refusals?: SeedWriteRefusals;
},
): Promise<PermissionSeedOutcome> {
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0 };
Expand DownExpand Up@@ -232,7 +243,7 @@ export async function upsertPackagePermissionSet(
package_id: packageId,
managed_by: 'package',
};
const created = await tryInsert(ql, 'sys_permission_set', row, organizationId);
const created = await tryInsert(ql, 'sys_permission_set', row, organizationId, opts?.refusals);
if (created) {
out.seeded += 1;
// A batched oracle is a snapshot taken before the loop — tell it about
Expand DownExpand Up@@ -263,7 +274,7 @@ export async function upsertPackagePermissionSet(
// beautiful round-trip curve.
if (!recordDiffersFromBody(existing, ps)) {
out.unchanged += 1;
} else if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...permissionSetRowFields(ps) }, organizationId)) {
} else if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...permissionSetRowFields(ps) }, organizationId, opts?.refusals)) {
out.updated += 1;
}
} else {
Expand DownExpand Up@@ -325,13 +336,16 @@ export async function bootstrapDeclaredPermissions(
// organization's own row is created regardless — the leftover is reported,
// never treated as "already seeded" (#10103).
const residue: string[] = [];
// One log per pass, not per refused row: a legacy platform-wide unique index
// refuses EVERY declared permission set, and a line each would bury the remedy.
const refusals = createSeedWriteRefusals();

for (const ps of sets) {
if (!ps?.name) continue;
// Registry provenance first (ADR-0010 `_packageId`), author-declared
// spec `packageId` (ADR-0086 D3) as fallback.
const packageId: string | undefined = ps._packageId ?? ps.packageId ?? undefined;
const r = await upsertPackagePermissionSet(ql, ps, packageId, options.logger, { existingByName, organizationId, residue });
const r = await upsertPackagePermissionSet(ql, ps, packageId, options.logger, { existingByName, organizationId, residue, refusals });
out.seeded += r.seeded;
out.updated += r.updated;
out.unchanged += r.unchanged;
Expand All@@ -349,6 +363,8 @@ export async function bootstrapDeclaredPermissions(
options.platformBucketNames ?? SHIPPED_PLATFORM_BUCKET_NAMES,
);
}
// Before the counts, so an operator reads WHY the count is zero beside it.
reportSeedWriteRefusals(options.logger, refusals, organizationId);
if (out.unreadable > 0) {
// Said once, with the count: these sets were neither seeded nor reconciled
// because the record could not be READ. Silence here would read exactly
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,23 +26,55 @@
*/

import { buildExistingByName } from './seed-name-lookup.js';
import { seedCtx, warnOrganizationLessRows } from './per-organization-catalog.js';
import {
createSeedWriteRefusals,
seedCtx,
warnOrganizationLessRows,
reportSeedWriteRefusals,
type SeedLogger,
type SeedWriteRefusals,
} from './per-organization-catalog.js';

function genId(prefix: string): string {
const rand = Math.random().toString(36).slice(2, 10);
const ts = Date.now().toString(36);
return `${prefix}_${ts}${rand}`;
}

async function tryInsert(ql: any, object: string, data: any, organizationId?: string): Promise<any | null> {
try { return await ql.insert(object, data, { context: seedCtx(organizationId) }); } catch { return null; }
// ⛔ The `catch` RECORDS before it answers. Answering `null`/`false` alone is
// what made a refused INSERT indistinguishable from "nothing to do": `seeded`
// never increments, the pass returns normally, and the boot logs a successful
// seed of zero rows. The refusal log is what carries the signal past this
// frame — see `reportSeedWriteRefusals`. Still no rethrow: the pass reports and
// continues, it does not decide whether the deployment boots.
async function tryInsert(
ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals,
): Promise<any | null> {
try {
return await ql.insert(object, data, { context: seedCtx(organizationId) });
} catch (e) { refusals?.record(object, e); return null; }
}
async function tryUpdate(ql: any, object: string, data: any, organizationId?: string): Promise<boolean> {
try { await ql.update(object, data, { context: seedCtx(organizationId) }); return true; } catch { return false; }
async function tryUpdate(
ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals,
): Promise<boolean> {
try {
await ql.update(object, data, { context: seedCtx(organizationId) }); return true;
} catch (e) { refusals?.record(object, e); return false; }
}

interface SeedOptions {
logger?: { info: (m: string, meta?: Record<string, any>) => void; warn: (m: string, meta?: Record<string, any>) => void };
logger?: {
info: (m: string, meta?: Record<string, any>) => void;
warn: (m: string, meta?: Record<string, any>) => void;
/**
* Durability channel for a catalog write that was refused — see
* {@link SeedLogger.error} for the signature and why it is optional.
* Declared here so the level this seeder reaches for is visible in its
* own options rather than only inside the reporter; absent, the report
* falls back to `warn` and is never dropped.
*/
error?: SeedLogger['error'];
};
/**
* Seed THIS organization's copies. Omitted = the `single`-posture pass, the
* one place an organization-less catalog row is the correct shape.
Expand DownExpand Up@@ -124,6 +156,9 @@ export async function bootstrapDeclaredPositions(
let updated = 0;
let unchanged = 0;
let unreadable = 0;
// One log per pass, not per refused row: a legacy platform-wide unique index
// refuses EVERY declared position, and a line each would bury the remedy.
const refusals = createSeedWriteRefusals();
for (const r of positions) {
if (!r?.name) continue;
const fields = positionRowFields(r);
Expand DownExpand Up@@ -152,14 +187,14 @@ export async function bootstrapDeclaredPositions(
// re-seed (#2909 T2), so they can neither cause nor suppress one.
if (!positionRecordDiffers(existing, fields)) {
unchanged += 1;
} else if (await tryUpdate(ql, 'sys_position', { id: existing.id, ...fields }, organizationId)) {
} else if (await tryUpdate(ql, 'sys_position', { id: existing.id, ...fields }, organizationId, refusals)) {
updated += 1;
}
} else {
const row = {
id: genId('position'), name: r.name, ...fields, active: true, is_default: false,
};
const created = await tryInsert(ql, 'sys_position', row, organizationId);
const created = await tryInsert(ql, 'sys_position', row, organizationId, refusals);
if (created) {
seeded += 1;
// The batched oracle is a snapshot taken before the loop; a name
Expand All@@ -174,6 +209,9 @@ export async function bootstrapDeclaredPositions(
// row any more, so every leftover here really is pre-fix residue (#11532).
warnOrganizationLessRows(options.logger, 'sys_position', residue, organizationId);
}
// Before the counts are reported, so an operator reads WHY the count is zero
// in the same place they read the zero.
reportSeedWriteRefusals(options.logger, refusals, organizationId);
if (unreadable > 0) {
// Said once, with the count — see the sibling warn in
// `bootstrap-declared-permissions.ts`.
Expand Down
Loading
Loading