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
57 changes: 57 additions & 0 deletions .changeset/walled-platform-bucket-diagnostic.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'@objectstack/plugin-security': patch
---

Stop the per-organization catalog pass from reporting the platform's own
permission sets as "pre-fix" leftovers with a remedy that recreates them

On a fresh walled deployment (`OS_TENANCY_POSTURE=isolated`, three
organizations) the boot log warned, once per organization, that *"pre-fix
organization-less `sys_permission_set` rows are still present"* and offered
*"re-initialize the deployment, or adopt each row by hand"*. Both halves were
wrong there:

- **Nothing was pre-fix.** The eight rows it named (`admin_full_access`,
`organization_admin`, `organization_admin_no_bypass`, `member_default`,
`viewer_readonly`, `mcp_agent_data_read`, `mcp_agent_data_write`,
`mcp_agent_restricted`) were minted 1.3 s earlier — before the deployment's
first organization existed — by `bootstrapPlatformAdmin`, the fifth seeder,
which the #10103 ruling deliberately left outside the per-organization
conversion. An operator on a deployment hours old was told they were carrying
legacy state they never had.
- **Its first remedy did not terminate.** Re-initializing a fresh walled
deployment mints exactly those eight rows again on the next boot, so only the
hand-adoption branch ends — and that one hands a platform-wide bucket to a
single tenant.

The pass now separates the two classes it was conflating and reports each with
the remedy that fits, carrying a machine-readable `origin`
(`'platform-bucket'` / `'pre-fix-residue'`) beside the named rows:

- the **platform bucket** — names an organization-less writer still seeds on
every boot — is reported as what it is, states that this organization's own
copies were created and no action is required, and says plainly that
re-initializing does *not* clear it;
- a **genuine pre-fix leftover** keeps the original wording and the original
remedy, unchanged.

Membership is decided by name rather than by `managed_by`, because the question
the remedy turns on is "will a re-initialized deployment have this row again?"
— true for these names whatever provenance the current row carries (a
pre-#8692 install stores `'admin'` on the very same names). It falls back to the
shipped `defaultPermissionSets`, so a host that never threads the new
`platformBucketNames` option still classifies correctly; the option exists for
a host that overrode `SecurityPluginOptions.defaultPermissionSets`.

`bootstrapPlatformAdmin` also declares what it wrote: under a walled posture it
now logs that the platform defaults were seeded *without* an organization and
that each organization's copies come from the catalog pass. The rig's boot line
read `{"seeded":8}` with nothing to indicate the rows carried no organization
at all, so the operator's first sight of them was the warning above.

**No behaviour change to the seeding itself.** The eight rows are still minted,
still organization-less, still unreaped — that is the ruled outcome of #10103
(2026-08-20), and `PLATFORM_ADMIN` is derived from an unscoped grant pointing at
the `admin_full_access` row *by row id*, so removing them would silently demote
every platform admin. Whether the platform bucket should be materialized per
organization remains the maintainer's open call, not this change.
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,7 +39,7 @@ import {
resolveOwnOrganizationRow,
rowMatchesDeclaration,
seedCtx,
warnPreFixOrganizationLessRows,
warnOrganizationLessRows,
} from './per-organization-catalog.js';

/**
Expand DownExpand Up@@ -130,7 +130,9 @@ export async function bootstrapBuiltinRoles(
}
}
if (organizationId) {
warnPreFixOrganizationLessRows(options.logger, 'sys_position', residue, organizationId);
// See the sibling in `bootstrap-declared-positions.ts`: no organization-less
// writer survives for `sys_position`, so no platform bucket is declared.
warnOrganizationLessRows(options.logger, 'sys_position', residue, organizationId);
}
if (seeded + updated > 0) {
options.logger?.info?.('[security] built-in identity names + audience anchors seeded into sys_position', {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,10 +59,11 @@ import {
type ExistingByNameIndex,
type ExistingLookupResult,
} from './seed-name-lookup.js';
import { defaultPermissionSets } from './objects/default-permission-sets.js';
import {
resolveOwnOrganizationRow,
seedCtx,
warnPreFixOrganizationLessRows,
warnOrganizationLessRows,
} from './per-organization-catalog.js';

export type { PermissionSeedOutcome } from './permission-set-projection.js';
Expand DownExpand Up@@ -106,6 +107,26 @@ interface SeedOptions {
* one place an organization-less catalog row is the correct shape.
*/
organizationId?: string;
/**
* [#11532] Names that `bootstrapPlatformAdmin` still seeds organization-less
* on every boot — the PLATFORM BUCKET. Supplied by the caller because the
* caller is the one holding that array (`security-plugin.ts` hands the same
* `bootstrapPermissionSets` to the platform bootstrap and to the manifest),
* so the list is exact rather than inferred from a row's provenance.
*
* An organization-less row for one of these names is NOT a pre-fix leftover
* and the pre-fix remedy is a loop for it: re-initializing the deployment
* mints it again on the next boot. See {@link warnOrganizationLessRows}.
*
* Omitted falls back to the SHIPPED `defaultPermissionSets` — which is what
* `bootstrapPlatformAdmin` seeds unless the host passed
* `SecurityPluginOptions.defaultPermissionSets`. So the classification is
* right for every shipped composition even if this option is never threaded,
* and the option exists for the one case the fallback cannot know about: a
* host that overrode the array. Pass `[]` to state that this caller's
* deployment has no organization-less writer at all.
*/
platformBucketNames?: readonly string[];
}

/**
Expand DownExpand Up@@ -263,6 +284,15 @@ export async function upsertPackagePermissionSet(
return out;
}

/**
* [#11532] The names the SHIPPED `bootstrapPlatformAdmin` seeds
* organization-less on every boot. Computed once from the same declaration the
* platform bootstrap iterates, so the two cannot drift within a release.
*/
const SHIPPED_PLATFORM_BUCKET_NAMES: readonly string[] = defaultPermissionSets
.map((ps) => ps.name)
.filter((n): n is string => typeof n === 'string' && n !== '');

export async function bootstrapDeclaredPermissions(
ql: any,
metadataService: any,
Expand DownExpand Up@@ -311,7 +341,13 @@ export async function bootstrapDeclaredPermissions(
}

if (organizationId) {
warnPreFixOrganizationLessRows(options.logger, 'sys_permission_set', residue, organizationId);
warnOrganizationLessRows(
options.logger,
'sys_permission_set',
residue,
organizationId,
options.platformBucketNames ?? SHIPPED_PLATFORM_BUCKET_NAMES,
);
}
if (out.unreadable > 0) {
// Said once, with the count: these sets were neither seeded nor reconciled
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,7 @@
*/

import { buildExistingByName } from './seed-name-lookup.js';
import { seedCtx, warnPreFixOrganizationLessRows } from './per-organization-catalog.js';
import { seedCtx, warnOrganizationLessRows } from './per-organization-catalog.js';

function genId(prefix: string): string {
const rand = Math.random().toString(36).slice(2, 10);
Expand DownExpand Up@@ -170,7 +170,9 @@ export async function bootstrapDeclaredPositions(
}
}
if (organizationId) {
warnPreFixOrganizationLessRows(options.logger, 'sys_position', residue, organizationId);
// No `platformBucketNames`: nothing mints an organization-less `sys_position`
// row any more, so every leftover here really is pre-fix residue (#11532).
warnOrganizationLessRows(options.logger, 'sys_position', residue, organizationId);
}
if (unreadable > 0) {
// Said once, with the count — see the sibling warn in
Expand Down
17 changes: 17 additions & 0 deletions packages/plugins/plugin-security/src/bootstrap-platform-admin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -243,6 +243,23 @@ export async function bootstrapPlatformAdmin(
}

const seededCount = Object.keys(seeded).length;
// [#11532] Under a walled posture these rows are organization-less BY RULING
// (#10103, 2026-08-20) and unreadable through the wall, and the catalog pass
// that runs next reports them once per organization. Saying so HERE is what
// stops the operator's first sight of them being a warning that calls the
// platform's own output legacy state: the fresh walled rig logged
// `seeded: 8` with nothing to indicate the rows carried no organization at
// all. Not a behaviour change — the seeding above is byte-identical.
if (seededCount > 0 && postureEnforcesWall(resolveTenancyPosture())) {
logger?.info?.(
'[security] platform default permission sets seeded WITHOUT an organization (the platform ' +
'bucket) — ruled 2026-08-20 and unchanged: the platform-admin grant points at the ' +
'admin_full_access row by id. Under a walled posture they are unreadable through the ' +
'tenant wall; each organization gets its own copies from the per-organization catalog ' +
'pass, so no principal is missing a set.',
{ seeded: seededCount, names: Object.keys(seeded).sort() },
);
}
// Attached to every return below so `os meta resync` can report the reconcile
// outcome even when admin promotion short-circuits (the common dev case: a DB
// that already has an admin returns `already_have_admin`).
Expand Down
124 changes: 99 additions & 25 deletions packages/plugins/plugin-security/src/per-organization-catalog.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,8 +41,20 @@
* #8617 reaped its pre-fix organization-less rows. This catalog does not, and
* the difference is deliberate rather than an omission:
*
* - a fresh walled deployment never mints an organization-less catalog row once
* these seeders run per organization, so there is nothing to migrate;
* - a fresh walled deployment never mints an organization-less catalog row
* FROM THESE FOUR SEEDERS once they run per organization, so there is nothing
* of theirs to migrate. It does NOT follow that a fresh walled deployment
* holds none, and this file used to claim it did (#11532).
* `bootstrapPlatformAdmin` is a FIFTH seeder, outside the four converted
* here, and it writes one organization-less `sys_permission_set` row per
* `defaultPermissionSets` entry on EVERY boot — before any organization
* exists (measured on a fresh walled rig: 8 rows, 1.3 s ahead of the first
* `sys_organization`). That is the RULED outcome rather than a leak: the
* 2026-08-20 maintainer ruling on #10103 keeps that platform bucket
* "unreaped and loudly warned about under walled posture", and PLATFORM_ADMIN
* is derived from an unscoped grant pointing at its `admin_full_access` row
* BY ROW ID, so a reap would silently demote every platform admin. The guard
* below therefore has to tell the two classes apart;
* - a `single`-posture deployment is where organization-less rows are the
* CORRECT shape, and the carve-out below leaves it byte-for-byte unchanged;
* - the rows a reap would delete are grant TARGETS — `sys_user_position`,
Expand All@@ -52,7 +64,7 @@
* because it never touched a junction table; here the junctions ARE the
* grants.
*
* So the pass says so instead. {@link warnPreFixOrganizationLessRows} names the
* So the pass says so instead. {@link warnOrganizationLessRows} names the
* rows and names the remedy, and — this is the load-bearing half — the pass
* still CREATES the organization's own copy. The failure shape it exists to
* prevent is the silent no-op: a tenant-threaded pass sees the pre-fix
Expand DownExpand Up@@ -180,37 +192,99 @@ export function resolveOwnOrganizationRow(
}

/**
* The loud guard that stands in place of a reap.
* The machine-readable half of the guard below: WHY this organization-less row
* is visible to a per-organization pass. The two answers take opposite
* remedies, so the classification is a field rather than something a reader has
* to infer from prose (#11532).
*/
export type OrganizationLessRowOrigin = 'platform-bucket' | 'pre-fix-residue';

/**
* The loud guard that stands in place of a reap — and the ONE place that tells
* the platform bucket apart from a genuine pre-fix leftover.
*
* Called once per pass with everything the pass found, so an operator gets ONE
* actionable line naming the affected rows rather than a warning per name. The
* remedy is named because "invalid state" with no next step is not a diagnosis:
* either re-initialize the deployment (correct while it is pre-launch, which is
* the premise this whole repair was ruled on), or adopt each row by hand by
* stamping it with the organization that should own it.
*
* The pass that emits this has ALREADY created the organization's own copies —
* the warning describes leftovers, never a refusal to seed.
* actionable line per class rather than a warning per name.
*
* ## The two classes, and why conflating them was a defect (#11532)
*
* - **`pre-fix-residue`** — a row from before the per-organization conversion.
* Nothing regenerates it, so the ruled remedy holds: re-initialize the
* deployment (correct while it is pre-launch, which is the premise this whole
* repair was ruled on), or adopt each row by hand.
*
* - **`platform-bucket`** — a name `bootstrapPlatformAdmin` still seeds
* organization-less on EVERY boot (`platformBucketNames`). Calling one of
* these "pre-fix" tells an operator they are carrying legacy state they never
* had: on the measured fresh walled rig they were minted 1.3 s before the
* first organization existed, by the very code that then warned about them.
* And the pre-fix remedy does not terminate here — re-initializing recreates
* exactly these rows on the next boot, so the only branch that ends is hand
* adoption, which is also the branch an operator is least likely to pick and
* which hands a platform-wide bucket to one tenant.
*
* Membership is decided by NAME, not by `managed_by`, because the question the
* remedy turns on is "will a re-initialized deployment have this row again?" —
* and for these names it will, whatever provenance the current row carries (a
* pre-#8692 install stores `'admin'` on the very same names).
*
* The pass that emits either warning has ALREADY created the organization's own
* copies — both describe rows beside that catalog, never a refusal to seed.
*/
export function warnPreFixOrganizationLessRows(
export function warnOrganizationLessRows(
logger: SeedLogger | undefined,
object: string,
names: string[],
organizationId: string,
platformBucketNames?: Iterable<string>,
): void {
if (names.length === 0) return;
logger?.warn?.(
`[security] pre-fix organization-less ${object} rows are still present for names this ` +
`organization seeds — under a walled posture a row that belongs to no organization is ` +
`invalid state, not a platform-wide default. This organization's own rows WERE created, so ` +
`its catalog is complete; the leftovers below are readable through the driver's ` +
`compatibility arm and belong to nobody. Remedy: re-initialize the deployment, or adopt each ` +
`row by hand by stamping it with the organization that should own it. They are NOT deleted ` +
`automatically — grants (sys_user_position, sys_position_permission_set, ` +
`sys_user_permission_set) point at these row ids, so reaping them would revoke standing ` +
`access with no signal at the moment of loss.`,
{ object, organization: organizationId, names: [...names].sort(), count: names.length },
);
const bucketNames = new Set(platformBucketNames ?? []);
const bucket = names.filter((n) => bucketNames.has(n));
const residue = names.filter((n) => !bucketNames.has(n));

if (bucket.length > 0) {
logger?.warn?.(
`[security] organization-less ${object} rows for the PLATFORM BUCKET are visible to this ` +
`organization's pass. They are not leftovers from an older release: bootstrapPlatformAdmin ` +
`seeds these names without an organization on every boot, including the one that just ran, ` +
`and the ruling of 2026-08-20 keeps it that way (unreaped, reported, outside the ` +
`per-organization conversion) because the platform-admin grant points at the ` +
`admin_full_access row by id. This organization's own copies WERE created, so its catalog ` +
`is complete and no action is required. Re-initializing the deployment does NOT clear ` +
`them — the next boot mints them again. Adopting one by hand (stamping it with an ` +
`organization) does remove it from this list, but hands a platform-wide row to a single ` +
`tenant, so do that only if that is what you mean.`,
{
object,
organization: organizationId,
origin: 'platform-bucket' satisfies OrganizationLessRowOrigin,
names: [...bucket].sort(),
count: bucket.length,
},
);
}

if (residue.length > 0) {
logger?.warn?.(
`[security] pre-fix organization-less ${object} rows are still present for names this ` +
`organization seeds — under a walled posture a row that belongs to no organization is ` +
`invalid state, not a platform-wide default. This organization's own rows WERE created, so ` +
`its catalog is complete; the leftovers below are readable through the driver's ` +
`compatibility arm and belong to nobody. Remedy: re-initialize the deployment, or adopt each ` +
`row by hand by stamping it with the organization that should own it. They are NOT deleted ` +
`automatically — grants (sys_user_position, sys_position_permission_set, ` +
`sys_user_permission_set) point at these row ids, so reaping them would revoke standing ` +
`access with no signal at the moment of loss.`,
{
object,
organization: organizationId,
origin: 'pre-fix-residue' satisfies OrganizationLessRowOrigin,
names: [...residue].sort(),
count: residue.length,
},
);
}
}

/**
Expand Down
13 changes: 12 additions & 1 deletion packages/plugins/plugin-security/src/security-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2926,9 +2926,20 @@ export class SecurityPlugin implements Plugin {
ctx.logger.warn('[security] declared-position seeding failed', { error: (e as Error).message, organization: organizationId });
}
};
// [#11532] The names `bootstrapPlatformAdmin` seeds organization-less on
// every boot. The per-organization pass sees those rows through the driver's
// compatibility arm and used to report them as PRE-FIX leftovers with a
// remedy ("re-initialize the deployment") that mints them again — on a fresh
// walled deployment the whole line was false and its first branch was a
// loop. Handed over from here because this is where the one array lives:
// the same `bootstrapPermissionSets` goes to the platform bootstrap above
// and onto the manifest as `permissions`, so the two can never disagree.
const platformBucketNames = this.bootstrapPermissionSets
.map((p) => p.name)
.filter((n): n is string => typeof n === 'string' && n !== '');
const seedCatalogPermissions = async (organizationId?: string): Promise<void> => {
try {
await bootstrapDeclaredPermissions(ql, this.metadata, { logger: ctx.logger, organizationId });
await bootstrapDeclaredPermissions(ql, this.metadata, { logger: ctx.logger, organizationId, platformBucketNames });
} catch (e) {
ctx.logger.warn('[security] declared-permission seeding failed', { error: (e as Error).message, organization: organizationId });
}
Expand Down
Loading
Loading