From ce8c58118d758b71dad5a11753158801caf87da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:50:05 +0000 Subject: [PATCH 1/6] fix(cli): the ownership ceremony names the uncomposed security plugin for a shipped set or built-in position (WIP) Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .../migrate/organization-ownership.ts | 3 ++ .../src/utils/organization-ownership-apply.ts | 45 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index 905532e9b5e..a4ce0f93c21 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -525,6 +525,9 @@ export default class MigrateOrganizationOwnership extends Command { const liveStack = stack; report = await applyOrganizationOwnership({ executor, + // Whether this boot composed the security plugin: a shipped set or a + // built-in position it alone declares is reported with that cause. + securityPlugin: stack.composition.securityPlugin, ...(flags['template-choice']?.length ? { templateChoices: flags['template-choice'] } : {}), ...(flags['metadata-choice']?.length ? { metadataChoices: flags['metadata-choice'] } : {}), posture, diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 8fa3392d285..c891d086d88 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -41,6 +41,7 @@ import { type LegacySetRow, type UnnamedReason, } from './organization-ownership-grants.js'; +import { describeSecurityPluginComposition, type SchemaMigrationComposition } from './schema-migration-plugins.js'; /** * The ADR-0131 D10 ceremony's apply step — ceremony items 2 to 4: the backup @@ -470,6 +471,17 @@ export interface ApplyOptions { /** The kept plan file the operator reviewed; the run refuses unless it matches (see the module doc). */ reviewedPlan: ReviewedPlan; inventory?: readonly InventoryEntry[]; + /** + * [#23359] What the boot's auth gate answered for the security plugin + * (`bootSchemaStack`'s `composition.securityPlugin`). The plugin declares the + * platform's shipped permission sets and the built-in positions, so when the + * boot did not compose it, a grant or binding naming one of them does not + * resolve for that reason alone: the step's blocker says so in the + * composition's own sentence and never prescribes removing it + * ({@link uncomposedSecurityRemedy}). Absent, every unresolved name keeps its + * remedy. It resolves nothing: the step still refuses. + */ + securityPlugin?: SchemaMigrationComposition['securityPlugin']; /** * The operator's choice for each template conflict: one candidate row id per * `(name, locale)` whose customized or authored rows differ. Validated before @@ -724,6 +736,34 @@ async function declaredPositionSets(run: Run, names: Iterable): Promise< return declared; } +/** + * [#23359] The remedy for a permission set or position the catalog does not + * resolve because the boot did not compose the security plugin — or + * `undefined`, and the caller keeps its own remedy, when the boot composed it + * (or did not say), or when the name is not one the plugin declares. + * + * The composition is the boot's answer ({@link ApplyOptions.securityPlugin}), + * never inferred from the missing name. The names are read from their owners: + * the plugin's shipped sets (`securityDefaultPermissionSets`), and the built-in + * positions its `init()` declares — the spec's identity names and audience + * anchors, the two lists its declarations are built from. The sentence is the + * composition's own (`describeSecurityPluginComposition`), followed by the one + * instruction that holds whatever the gate's reason: keep the rows, and re-run + * where the plugin is composed. ⛔ Never "remove" for such a name: removing a + * shipped grant strips the standing the platform seeded. Nothing is resolved + * here; the step still refuses. + */ +async function uncomposedSecurityRemedy(run: Run, type: 'permission' | 'position', name: string): Promise { + const status = run.opts.securityPlugin; + if (!status || status.composed) return undefined; + const declared: readonly string[] = type === 'permission' + ? (await import('@objectstack/plugin-security')).securityDefaultPermissionSets.map((set) => set.name) + : await import('@objectstack/spec').then((spec) => [...spec.BUILTIN_IDENTITY_NAMES, ...spec.AUDIENCE_ANCHOR_POSITIONS]); + if (!declared.includes(name)) return undefined; + const kept = type === 'permission' ? 'permission sets it ships: keep the grants' : 'built-in positions it declares: keep the bindings'; + return `${describeSecurityPluginComposition(status)} ${name} is one of the ${kept}, and re-run where the security plugin is composed.`; +} + async function runAuthoredConversion(run: Run): Promise { const blockers: string[] = []; const notes: string[] = []; @@ -858,7 +898,7 @@ async function runAuthoredConversion(run: Run): Promise { if (!existing) { blockers.push( `Binding rows give position ${name} the sets ${permissionSets.join(', ')}, and the catalog holds no position of ` + - 'that name to carry them. Declare the position (or remove the bindings), then re-run.', + `that name to carry them. ${(await uncomposedSecurityRemedy(run, 'position', name)) ?? 'Declare the position (or remove the bindings), then re-run.'}`, ); continue; } @@ -1940,7 +1980,8 @@ async function verifyReferences(run: Run): Promise { for (const [name, ids] of unresolved) { out.blockers.push( `${ids.length} grant(s) name the permission set ${name}, which the security catalog does not resolve ` + - `(${ids.slice(0, 20).join(', ')}${ids.length > 20 ? ', …' : ''}). Declare or author the set, or remove the grants, then re-run.`, + `(${ids.slice(0, 20).join(', ')}${ids.length > 20 ? ', …' : ''}). ` + + ((await uncomposedSecurityRemedy(run, 'permission', name)) ?? 'Declare or author the set, or remove the grants, then re-run.'), ); } if (disagree.length) { From 02f2d1647247b319fc4c77b0c6aceffb9549e83e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:54:10 +0000 Subject: [PATCH 2/6] test(cli): pins for the uncomposed security plugin's names in the ownership ceremony (WIP) Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .../organization-ownership-apply.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index fbb5601837c..86ca8cf57a6 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -46,6 +46,7 @@ import { } from './organization-ownership-apply.js'; import { backfillOrgAdminGrants } from '@objectstack/plugin-security'; import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { describeSecurityPluginComposition, type SchemaMigrationComposition } from './schema-migration-plugins.js'; let dir: string; let file: string; @@ -422,6 +423,7 @@ const apply = ( columnDrops?: ColumnDropPort; reviewed?: Partial; lock?: MetadataLockPort; + securityPlugin?: SchemaMigrationComposition['securityPlugin']; } = {}, ): Promise => { c.boot(); @@ -442,6 +444,7 @@ const apply = ( executor: opts.executor ?? executor(), ...(opts.templateChoices ? { templateChoices: opts.templateChoices } : {}), ...(opts.metadataChoices ? { metadataChoices: opts.metadataChoices } : {}), + ...(opts.securityPlugin ? { securityPlugin: opts.securityPlugin } : {}), posture: opts.posture ?? 'single', database: file, catalog: c, @@ -2008,3 +2011,124 @@ describe('ADR-0131 D10 --apply — the grant\'s retired id column (C7b, seat ans }); }); }); + +describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin declares (triage 6159130107)', () => { + /** What the boot's auth gate answers with no auth secret and no development boot: the card's setup. */ + const NO_SECRET = { composed: false, reason: 'no-secret' } as const; + const COMPOSITION = describeSecurityPluginComposition(NO_SECRET); + type Step = OrganizationOwnershipApplyReport['steps'][number]; + const step = (report: OrganizationOwnershipApplyReport, name: Step['step']): Step => report.steps.find((s) => s.step === name)!; + /** The one blocker of `s` that names `subject`. */ + const blockerOf = (s: Step, subject: string): string => { + const found = s.blockers.filter((b) => b.includes(subject)); + expect(found, subject).toHaveLength(1); + return found[0]!; + }; + /** Every DELETE the run issued, beside the settings rung that moves before the rewrite. */ + const deletes = (): string[] => writes.filter((w) => /^DELETE/.test(w) && !/^DELETE FROM "sys_setting"/.test(w)); + const v17Bindings = ` + INSERT INTO sys_position VALUES + ('pos_everyone', NULL, 'everyone', 'Everyone', 'platform', 0, 1), + ('pos_gone', NULL, 'zz_undeclared', 'Undeclared', 'platform', 0, 1); + INSERT INTO sys_position_permission_set VALUES + ('bind_everyone', NULL, 'pos_everyone', 'ps_null'), + ('bind_gone', NULL, 'pos_gone', 'ps_null'); + `; + + it('rewrite: a grant naming a shipped set gets the composition, never removal; an unknown set keeps its remedy', async () => { + db.exec(` + INSERT INTO sys_user_permission_set VALUES + ('grant_admin', 'org_default', 'usr_admin', NULL, 'admin_full_access'), + ('grant_nobypass', 'org_default', 'usr_admin', NULL, 'organization_admin_no_bypass'), + ('grant_unknown', 'org_default', 'usr_9', NULL, 'zz_unknown_set'); + `); + const report = await apply(catalog(), ledger(), { securityPlugin: NO_SECRET }); + + const rewrite = step(report, 'rewrite'); + expect(rewrite.status).toBe('blocked'); + for (const name of ['admin_full_access', 'organization_admin_no_bypass']) { + const blocker = blockerOf(rewrite, `the permission set ${name},`); + expect(blocker).toContain(COMPOSITION); + expect(blocker).not.toMatch(/\bremove\b/); + } + const unknown = blockerOf(rewrite, 'the permission set zz_unknown_set,'); + expect(unknown).not.toContain(COMPOSITION); + expect(unknown).toMatch(/or remove the grants/); + // Still refused: the gate holds and nothing is deleted. + expect(step(report, 'mirror-deletion').status).toBe('not-reached'); + expect(deletes()).toEqual([]); + }); + + it('rewrite: a legacy grant whose set row is a shipped set gets the composition, never removal; an unknown set row keeps its remedy', async () => { + db.exec(` + INSERT INTO sys_permission_set VALUES + ('ps_admin', NULL, 'admin_full_access', 'Admin', 'platform', 1, '{}', '[]'), + ('ps_gone', NULL, 'zz_unknown_set', 'Gone', 'platform', 1, '{}', '[]'); + INSERT INTO sys_user_permission_set VALUES + ('grant_admin_legacy', 'org_default', 'usr_admin', 'ps_admin', NULL), + ('grant_gone_legacy', 'org_default', 'usr_9', 'ps_gone', NULL); + `); + const report = await apply(catalog(), ledger(), { securityPlugin: NO_SECRET }); + + const rewrite = step(report, 'rewrite'); + expect(rewrite.status).toBe('blocked'); + const shipped = blockerOf(rewrite, 'grant_admin_legacy'); + expect(shipped).toContain('admin_full_access'); + expect(shipped).toContain(COMPOSITION); + expect(shipped).not.toMatch(/\bremove\b/); + const unknown = blockerOf(rewrite, 'grant_gone_legacy'); + expect(unknown).not.toContain(COMPOSITION); + expect(unknown).toMatch(/name or remove each/); + expect(step(report, 'mirror-deletion').status).toBe('not-reached'); + expect(deletes()).toEqual([]); + }); + + it('authored-conversion: the seeded everyone binding gets the composition, never removal; an undeclared position keeps its remedy', async () => { + db.exec(v17Bindings); + const report = await apply(catalog(), ledger(), { securityPlugin: NO_SECRET }); + + const conversion = step(report, 'authored-conversion'); + expect(conversion.status).toBe('blocked'); + const everyone = blockerOf(conversion, 'position everyone '); + expect(everyone).toContain(COMPOSITION); + expect(everyone).not.toMatch(/\bremove\b/); + const undeclared = blockerOf(conversion, 'position zz_undeclared '); + expect(undeclared).not.toContain(COMPOSITION); + expect(undeclared).toMatch(/or remove the bindings/); + expect(step(report, 'rewrite').status).toBe('not-reached'); + expect(deletes()).toEqual([]); + }); + + it('under a wall, the rewrite gate: the everyone binding gets the composition; an undeclared position keeps its remedy', async () => { + db.exec(v17Bindings); + const report = await apply(catalog(), ledger(), { posture: 'isolated', securityPlugin: NO_SECRET }); + + expect(step(report, 'authored-conversion').status).toBe('done'); + const rewrite = step(report, 'rewrite'); + expect(rewrite.status).toBe('blocked'); + const everyone = blockerOf(rewrite, 'position everyone '); + expect(everyone).toContain(COMPOSITION); + expect(everyone).not.toMatch(/\bremove\b/); + const undeclared = blockerOf(rewrite, 'position zz_undeclared '); + expect(undeclared).not.toContain(COMPOSITION); + expect(undeclared).toMatch(/resolve its blocker/); + expect(deletes()).toEqual([]); + }); + + it('control: composed, or not said, every unresolved name keeps its remedy — the composition is the boot\'s answer, never inferred', async () => { + db.exec(` + INSERT INTO sys_user_permission_set VALUES ('grant_admin', 'org_default', 'usr_admin', NULL, 'admin_full_access'); + ${v17Bindings} + `); + for (const securityPlugin of [{ composed: true } as const, undefined]) { + writes = []; + const conversion = step(await apply(catalog(), ledger(), { securityPlugin }), 'authored-conversion'); + expect(blockerOf(conversion, 'position everyone ')).toMatch(/or remove the bindings/); + const walled = await apply(catalog(), ledger(), { posture: 'isolated', securityPlugin }); + const rewrite = step(walled, 'rewrite'); + expect(blockerOf(rewrite, 'the permission set admin_full_access,')).toMatch(/or remove the grants/); + expect(blockerOf(rewrite, 'position everyone ')).toMatch(/resolve its blocker/); + expect(rewrite.blockers.join('\n')).not.toContain('Did not compose the security plugin'); + } + }); +}); From eb96bd419b4711d93c7b6507f8891be84a30b202 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:56:38 +0000 Subject: [PATCH 3/6] fix(cli): cover the unnamed-grant and walled-gate remedies, and pin the command's wiring (WIP) Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- ...ership.fate-supplement.integration.test.ts | 10 ++++ .../organization-ownership-apply.test.ts | 29 ++++++---- .../src/utils/organization-ownership-apply.ts | 53 +++++++++++++++++-- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts index b2d30d28318..c416795d140 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts @@ -68,6 +68,8 @@ beforeEach(() => { : undefined, }, tableAbsent: () => false, + // What the boot's auth gate answered for the security plugin, as a boot with no auth secret records it. + composition: { securityPlugin: { composed: false, reason: 'no-secret' } }, shutdown: async () => {}, })); apply.mockImplementation(async () => ({ @@ -175,6 +177,14 @@ describe('--apply hands the merged inventory to the apply step', () => { }, CASE_TIMEOUT_MS); }); +describe('--apply hands the boot\'s security-plugin composition to the apply step', () => { + it('the apply step receives what the boot\'s auth gate answered, so a name only that plugin declares is reported with it', async () => { + await runJson(['--apply', '--backup-taken', '--plan-file', writePlan(), ...out('apply.json')]); + expect(apply).toHaveBeenCalledTimes(1); + expect(apply.mock.calls[0]![0]).toMatchObject({ securityPlugin: { composed: false, reason: 'no-secret' } }); + }, CASE_TIMEOUT_MS); +}); + describe('a refusal of the reviewed plan raised after the boot', () => { it('reaches the operator as apply_refused with its reason and both values, never as a resumable failure', async () => { const { ReviewedPlanRefusal } = await import('../../utils/organization-ownership-apply.js'); diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 86ca8cf57a6..6cf35e3f327 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -2115,20 +2115,27 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de expect(deletes()).toEqual([]); }); - it('control: composed, or not said, every unresolved name keeps its remedy — the composition is the boot\'s answer, never inferred', async () => { + it.each([ + ['composed', { composed: true } as const], + ['not said', undefined], + ])('control (%s): authored-conversion keeps the remedy — the composition is the boot\'s answer, never inferred', async (_label, securityPlugin) => { + db.exec(v17Bindings); + const conversion = step(await apply(catalog(), ledger(), { securityPlugin }), 'authored-conversion'); + expect(blockerOf(conversion, 'position everyone ')).toMatch(/or remove the bindings/); + expect(conversion.blockers.join('\n')).not.toContain(COMPOSITION); + }); + + it.each([ + ['composed', { composed: true } as const], + ['not said', undefined], + ])('control (%s), under a wall: the rewrite keeps every remedy', async (_label, securityPlugin) => { db.exec(` INSERT INTO sys_user_permission_set VALUES ('grant_admin', 'org_default', 'usr_admin', NULL, 'admin_full_access'); ${v17Bindings} `); - for (const securityPlugin of [{ composed: true } as const, undefined]) { - writes = []; - const conversion = step(await apply(catalog(), ledger(), { securityPlugin }), 'authored-conversion'); - expect(blockerOf(conversion, 'position everyone ')).toMatch(/or remove the bindings/); - const walled = await apply(catalog(), ledger(), { posture: 'isolated', securityPlugin }); - const rewrite = step(walled, 'rewrite'); - expect(blockerOf(rewrite, 'the permission set admin_full_access,')).toMatch(/or remove the grants/); - expect(blockerOf(rewrite, 'position everyone ')).toMatch(/resolve its blocker/); - expect(rewrite.blockers.join('\n')).not.toContain('Did not compose the security plugin'); - } + const rewrite = step(await apply(catalog(), ledger(), { posture: 'isolated', securityPlugin }), 'rewrite'); + expect(blockerOf(rewrite, 'the permission set admin_full_access,')).toMatch(/or remove the grants/); + expect(blockerOf(rewrite, 'position everyone ')).toMatch(/resolve its blocker/); + expect(rewrite.blockers.join('\n')).not.toContain(COMPOSITION); }); }); diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index c891d086d88..d3dd0eda148 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -1932,6 +1932,44 @@ interface Verification { verified: number; } +/** + * [#23359] Of the grants still unnamed (`ids`), those the rewrite leaves + * unnamed for one reason alone — their set row names a permission set the + * security plugin ships, which the catalog does not resolve because the boot + * did not compose that plugin — by that set's name, each with + * {@link uncomposedSecurityRemedy}'s sentence. Which grants those are is the + * rewrite's own decision (`planGrantRewrite`'s `unresolved` reason, over the + * same set rows and catalog), so a grant unnamed for any other reason (no id, a + * missing row, another organization's row) keeps the general blocker. + */ +async function unnamedForComposition(run: Run, ids: readonly string[]): Promise> { + const out = new Map(); + const grantCols = await columnsOf(run, GRANTS); + const setCols = await columnsOf(run, SETS); + if (!ids.length || run.opts.securityPlugin?.composed !== false || !setCols || !grantCols?.has('permission_set_id')) return out; + const sets = new Map(); + for (const row of await readAll(run, SETS, ['name', ...(setCols.has(ORG) ? [ORG] : [])])) { + sets.set(String(row.id), { name: text(row.name), organizationId: text(row[ORG]) }); + } + const unnamed = new Set(ids); + const legacy = (await readAll(run, GRANTS, ['permission_set_id', 'user_id', ...(grantCols.has(ORG) ? [ORG] : [])])) + .filter((g) => unnamed.has(String(g.id))) + .map((g) => ({ id: String(g.id), userId: text(g.user_id), organizationId: text(g[ORG]), setId: text(g.permission_set_id) })); + const resolvable = new Map(); + for (const grant of legacy) { + const name = grant.setId ? sets.get(grant.setId)?.name : null; + if (name && !resolvable.has(name)) resolvable.set(name, (await resolveName(run, 'permission', name)) !== undefined); + } + const setNameOf = new Map(legacy.map((g) => [g.id, g.setId ? sets.get(g.setId)?.name : null])); + for (const id of planGrantRewrite(legacy, [], sets, (name) => resolvable.get(name) === true).reasons.unresolved) { + const name = setNameOf.get(id); + const remedy = name ? await uncomposedSecurityRemedy(run, 'permission', name) : undefined; + if (!name || remedy === undefined) continue; + out.set(name, { ids: [...(out.get(name)?.ids ?? []), id], remedy }); + } + return out; +} + /** Read-only: does every reference resolve by name? The gate of fate 2. */ async function verifyReferences(run: Run): Promise { const out: Verification = { blockers: [], notes: [], grants: 0, verified: 0 }; @@ -1971,12 +2009,21 @@ async function verifyReferences(run: Run): Promise { } out.verified++; } - if (unnamed.length) { + const forComposition = await unnamedForComposition(run, unnamed); + const held = new Set([...forComposition.values()].flatMap((g) => g.ids)); + const rest = unnamed.filter((id) => !held.has(id)); + if (rest.length) { out.blockers.push( - `${unnamed.length} grant(s) still name no permission set (${unnamed.slice(0, 20).join(', ')}${unnamed.length > 20 ? ', …' : ''}). ` + + `${rest.length} grant(s) still name no permission set (${rest.slice(0, 20).join(', ')}${rest.length > 20 ? ', …' : ''}). ` + 'The rewrite could not derive their name (see its notes); name or remove each, then re-run.', ); } + for (const [name, { ids, remedy }] of forComposition) { + out.blockers.push( + `${ids.length} grant(s) still name no permission set (${ids.slice(0, 20).join(', ')}${ids.length > 20 ? ', …' : ''}): ` + + `their set row is ${name}, which the security catalog does not resolve. ${remedy}`, + ); + } for (const [name, ids] of unresolved) { out.blockers.push( `${ids.length} grant(s) name the permission set ${name}, which the security catalog does not resolve ` + @@ -2005,7 +2052,7 @@ async function verifyReferences(run: Run): Promise { position.fate === 'conflicting' ? `Position ${position.name}: organizations bind different permission sets to it; the bindings cannot be carried into one definition.` : `Binding rows give position ${position.name} sets its catalog definition does not name (${position.permissionSets.join(', ')}); ` + - 'deleting them would lose a grant. The authored-conversion step carries them; resolve its blocker, then re-run.', + `deleting them would lose a grant. ${(await uncomposedSecurityRemedy(run, 'position', position.name)) ?? 'The authored-conversion step carries them; resolve its blocker, then re-run.'}`, ); } } From 563552a332183b6b0c7a0016fa2ea540d0931875 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:01:01 +0000 Subject: [PATCH 4/6] test(cli): the composed control verifies two shipped sets on the served boot (WIP) Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- ...ip-apply.served-catalog.integration.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts b/packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts index 4db326677d1..892eb08dd7c 100644 --- a/packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts @@ -130,6 +130,8 @@ async function ceremony(posture: string, db: Database.Database): Promise [] }, lock: { verdict: async () => ({ editable: true }) }, @@ -160,6 +162,22 @@ describe('the ownership ceremony resolves bindings against what a started kernel expect(conversion.blockers.join('\n')).not.toContain('everyone'); }, CASE_TIMEOUT_MS); + it('control: the boot composed the plugin, and the rewrite verifies grants of two shipped sets, 2 of 2', async () => { + expect(stack.composition.securityPlugin).toEqual({ composed: true }); + const db = new Database(join(dir, 'shipped.db')); + db.exec(` + CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT); + INSERT INTO sys_organization VALUES ('org_default', 'default'); + CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT, permission_set TEXT); + INSERT INTO sys_user_permission_set VALUES + ('grant_admin', 'org_default', 'usr_admin', 'admin_full_access'), + ('grant_nobypass', 'org_default', 'usr_admin', 'organization_admin_no_bypass'); + `); + const rewrite = step(await ceremony('single', db), 'rewrite'); + expect(rewrite.blockers).toEqual([]); + expect(rewrite).toMatchObject({ status: 'done', summary: { grants: 2, verified: 2 } }); + }, CASE_TIMEOUT_MS); + it('walled: the rewrite gate blocks on the undeclared position, and not on everyone', async () => { const report = await ceremony('isolated', v17Database('walled')); expect(step(report, 'authored-conversion').status).toBe('done'); From ae53da30bf44be3dd0ce23465d6a96f068669d51 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:14:23 +0000 Subject: [PATCH 5/6] chore(cli): changeset for the uncomposed security plugin remedy Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .../23359-uncomposed-security-remedy.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/23359-uncomposed-security-remedy.md diff --git a/.changeset/23359-uncomposed-security-remedy.md b/.changeset/23359-uncomposed-security-remedy.md new file mode 100644 index 00000000000..a28303b6e9c --- /dev/null +++ b/.changeset/23359-uncomposed-security-remedy.md @@ -0,0 +1,22 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os migrate organization-ownership --apply` names the uncomposed security plugin, and no longer prescribes removing the grants or bindings of the platform's own sets and positions, when it runs without an auth secret + +Clause-②: no + +**What was wrong.** The ceremony's boot composes the security plugin only where `os serve` would: behind the auth gate, which needs `OS_AUTH_SECRET` or a development boot. That plugin declares the platform's shipped permission sets (`admin_full_access`, `organization_admin_no_bypass`, `member_default` and the rest) and the built-in positions (`everyone`, `org_admin` and the rest). Run without the secret, the catalog resolves none of them, and the ceremony blocked with a remedy aimed at the rows instead of the cause: + +- `rewrite`, a grant that names a shipped set. FROM: "… which the security catalog does not resolve (…). Declare or author the set, or remove the grants, then re-run." Removing those grants strips the seeded administrator's standing. +- `rewrite`, a legacy grant whose set row is a shipped set (a 17.x database). FROM: "… still name no permission set (…). The rewrite could not derive their name (see its notes); name or remove each, then re-run." +- `authored-conversion`, the seeded `everyone` binding. FROM: "… the catalog holds no position of that name to carry them. Declare the position (or remove the bindings), then re-run." +- The rewrite gate under a wall, the same binding. FROM: "… deleting them would lose a grant. The authored-conversion step carries them; resolve its blocker, then re-run." Under a wall that step only reports, so it has no blocker to resolve. + +**What changed.** When the boot did not compose the security plugin and the name is one the plugin declares, each blocker keeps its first sentence and its remedy becomes, TO: the composition's own sentence, the one `os migrate security-catalog-overlays` prints ("Did not compose the security plugin: `os serve` composes none here (REASON), so its shipped permission sets are not package-held names on this deployment's boot."), then "NAME is one of the permission sets it ships: keep the grants, and re-run where the security plugin is composed." For a position it reads "NAME is one of the built-in positions it declares: keep the bindings, and re-run where the security plugin is composed." A legacy grant's blocker names its set row: "N grant(s) still name no permission set (…): their set row is NAME, which the security catalog does not resolve." + +**Unchanged.** + +- Every step still refuses. Nothing is resolved, skipped or guessed, and nothing is deleted. +- A name the plugin does not declare keeps its remedy. So does every name when the boot composed the plugin. Whether it was composed is the boot's own answer, never inferred from a missing name. +- A legacy grant left unnamed for any other reason (no id, a missing set row, another organization's row) keeps the general blocker. From 2dc556b5689484d89927908fd46a953893e13bf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:14:51 +0000 Subject: [PATCH 6/6] test(cli): read the unknown-name control before the shipped name in each pin Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .../organization-ownership-apply.test.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 6cf35e3f327..6946bd50dd2 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -2046,14 +2046,14 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de const rewrite = step(report, 'rewrite'); expect(rewrite.status).toBe('blocked'); + const unknown = blockerOf(rewrite, 'the permission set zz_unknown_set,'); + expect(unknown).not.toContain(COMPOSITION); + expect(unknown).toMatch(/or remove the grants/); for (const name of ['admin_full_access', 'organization_admin_no_bypass']) { const blocker = blockerOf(rewrite, `the permission set ${name},`); expect(blocker).toContain(COMPOSITION); expect(blocker).not.toMatch(/\bremove\b/); } - const unknown = blockerOf(rewrite, 'the permission set zz_unknown_set,'); - expect(unknown).not.toContain(COMPOSITION); - expect(unknown).toMatch(/or remove the grants/); // Still refused: the gate holds and nothing is deleted. expect(step(report, 'mirror-deletion').status).toBe('not-reached'); expect(deletes()).toEqual([]); @@ -2072,13 +2072,13 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de const rewrite = step(report, 'rewrite'); expect(rewrite.status).toBe('blocked'); + const unknown = blockerOf(rewrite, 'grant_gone_legacy'); + expect(unknown).not.toContain(COMPOSITION); + expect(unknown).toMatch(/name or remove each/); const shipped = blockerOf(rewrite, 'grant_admin_legacy'); expect(shipped).toContain('admin_full_access'); expect(shipped).toContain(COMPOSITION); expect(shipped).not.toMatch(/\bremove\b/); - const unknown = blockerOf(rewrite, 'grant_gone_legacy'); - expect(unknown).not.toContain(COMPOSITION); - expect(unknown).toMatch(/name or remove each/); expect(step(report, 'mirror-deletion').status).toBe('not-reached'); expect(deletes()).toEqual([]); }); @@ -2089,12 +2089,12 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de const conversion = step(report, 'authored-conversion'); expect(conversion.status).toBe('blocked'); - const everyone = blockerOf(conversion, 'position everyone '); - expect(everyone).toContain(COMPOSITION); - expect(everyone).not.toMatch(/\bremove\b/); const undeclared = blockerOf(conversion, 'position zz_undeclared '); expect(undeclared).not.toContain(COMPOSITION); expect(undeclared).toMatch(/or remove the bindings/); + const everyone = blockerOf(conversion, 'position everyone '); + expect(everyone).toContain(COMPOSITION); + expect(everyone).not.toMatch(/\bremove\b/); expect(step(report, 'rewrite').status).toBe('not-reached'); expect(deletes()).toEqual([]); }); @@ -2106,12 +2106,12 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de expect(step(report, 'authored-conversion').status).toBe('done'); const rewrite = step(report, 'rewrite'); expect(rewrite.status).toBe('blocked'); - const everyone = blockerOf(rewrite, 'position everyone '); - expect(everyone).toContain(COMPOSITION); - expect(everyone).not.toMatch(/\bremove\b/); const undeclared = blockerOf(rewrite, 'position zz_undeclared '); expect(undeclared).not.toContain(COMPOSITION); expect(undeclared).toMatch(/resolve its blocker/); + const everyone = blockerOf(rewrite, 'position everyone '); + expect(everyone).toContain(COMPOSITION); + expect(everyone).not.toMatch(/\bremove\b/); expect(deletes()).toEqual([]); });