Skip to content
22 changes: 22 additions & 0 deletions .changeset/23359-uncomposed-security-remedy.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/migrate/organization-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ async function ceremony(posture: string, db: Database.Database): Promise<Organiz
// The plan the operator reviewed: the one this run computes.
reviewedPlan: { file: 'plan.json', generatedAt: '2026-10-14T00:00:00Z', database: 'v17', inventoryDigest: inventoryDigest(), posture },
catalog,
// What the command hands it: the boot's own answer for the security plugin.
securityPlugin: stack.composition.securityPlugin,
// No view is served here: the form-withdrawal carry has nothing to judge.
views: { list: async () => [] },
lock: { verdict: async () => ({ editable: true }) },
Expand Down Expand Up @@ -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');
Expand Down
131 changes: 131 additions & 0 deletions packages/cli/src/utils/organization-ownership-apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -422,6 +423,7 @@ const apply = (
columnDrops?: ColumnDropPort;
reviewed?: Partial<ReviewedPlan>;
lock?: MetadataLockPort;
securityPlugin?: SchemaMigrationComposition['securityPlugin'];
} = {},
): Promise<OrganizationOwnershipApplyReport> => {
c.boot();
Expand All @@ -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,
Expand Down Expand Up @@ -2008,3 +2011,131 @@ 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');
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/);
}
// 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 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/);
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 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([]);
});

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 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([]);
});

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}
`);
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);
});
});
Loading
Loading