Skip to content
Merged
17 changes: 17 additions & 0 deletions .changeset/23397-ceremony-builtin-positions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/plugin-security': patch
'@objectstack/cli': patch
---

fix(cli,plugin-security): `os migrate organization-ownership --apply` resolves the built-in positions and the `everyone` baseline, so a 17.x database's seeded `everyone` binding no longer blocks the ceremony

Clause-②: no

- **What was wrong.** The ceremony's boot composes the security plugin for its declarations only: `init()` runs, and `start()` and every lifecycle hook do not. The plugin declared the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) in `start()`, and the `everyone` baseline at `kernel:ready`. So the ceremony's catalog held neither. Every 17.x environment database carries a seeded binding from `everyone` to `member_default`. On every such database, `authored-conversion` stopped with "Binding rows give position everyone the sets member_default, and the catalog holds no position of that name to carry them". With `OS_MULTI_ORG_ENABLED=true`, the rewrite gate stopped on the same binding ("deleting them would lose a grant").
- **What changed.** `SecurityPlugin.init()` now declares the six built-in positions. It also declares `everyone` naming the deployment baseline: the app's `isDefault` set composed with `member_default`. These are registry declarations only, and nothing is read from or written to the database. The ceremony's catalog now resolves `everyone` with `member_default`, as a started kernel does, so that binding converts as declared. A binding that names a position no catalog declares still blocks.
- **The baseline in `init()` is the fail-closed reading.** The high-privilege check runs with no declared-capability context there, because the stack's declarations are still arriving while plugins initialize. So a shipped baseline set whose system permission only an app's capability declaration excuses is not named in `init()`. The `everyone` derivation at `kernel:ready` is unchanged. It runs the check against every declared capability, adds the package defaults a `manage_metadata` holder accepted, and replaces the `init()` declaration.
- **What a started kernel sees.** Its catalog after boot is the same. Three things move earlier or stay put:
- the six positions, and `everyone`'s baseline, are in the engine registry from the plugin's `init()` instead of from its `start()` and `kernel:ready`;
- if the `kernel:ready` derivation fails, `everyone` keeps the baseline from `init()`, where it used to name no set;
- the "the built-in positions … were NOT declared as position metadata" warning, for an engine that exposes no registry, now prints from `init()`.
- **Unchanged.** The cold boot's refusal of an environment-held name still skips the built-in names. A stored definition under a built-in name still shadows the declaration at read. The ceremony's `authored-conversion` and rewrite steps are unchanged. The ceremony still does not read the accepted package defaults: a binding to a set that only an acceptance puts on `everyone` still blocks.
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The ownership ceremony's catalog is the one `os migrate organization-ownership
* --apply` boots: the deployment's served composition, every platform provider
* in it composed for its declarations only (`init()` runs; `start()` and every
* hook do not). `authored-conversion` and the rewrite gate resolve the position
* bindings an older release wrote against THAT catalog, so it must hold the
* built-in positions and the `everyone` baseline a started kernel serves.
*
* ## The fixture
*
* The catalog: the apply command's boot (`bootSchemaStack` with its four
* composition flags) over a compiled artifact, with an auth secret set so the
* security plugin is composed, read through core's security catalog reader as
* the command reads it. The database: the 17.x catalog rows every environment
* database holds — the seeded `everyone` position, the seeded `member_default`
* set and the seeded binding between them — plus the control: a seeded binding
* that names a position no catalog declares.
*
* ## What each case measures
*
* The catalog holds the six built-ins and `everyone`'s baseline. Under
* `single`, `authored-conversion` blocks on the control and on nothing else;
* under a wall it reports, and the rewrite gate blocks on the control and on
* nothing else. With the declarations made from `start()` and `kernel:ready`,
* which this boot never runs, both block on `everyone` as well.
*/

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createSecurityCatalogReader } from '@objectstack/core';
import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_NAMES } from '@objectstack/spec';
import { bootSchemaStack, type SchemaStack } from './schema-migrate.js';
import {
applyOrganizationOwnership,
type CatalogPort,
type CeremonyCheckpoint,
type OrganizationOwnershipApplyReport,
type StepReport,
} from './organization-ownership-apply.js';

// [#10126] Pay the first load of what the boot reaches through dynamic
// `import()`s at MODULE LOAD, outside the clocked hook.
import '@objectstack/runtime';
import '@objectstack/metadata-protocol';
import '@objectstack/platform-objects/plugin';
import '@objectstack/plugin-security';

const CASE_TIMEOUT_MS = 180_000;
const ENV_KEYS = ['OS_ARTIFACT_PATH', 'OS_DATABASE_URL', 'DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'NODE_ENV',
'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ENVIRONMENT_ID', 'OS_MULTI_ORG_ENABLED'] as const;
const savedEnv: Record<string, string | undefined> = {};

let dir: string;
let stack: SchemaStack;
let catalog: CatalogPort;

beforeAll(async () => {
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
for (const key of ENV_KEYS) delete process.env[key];
process.env.NODE_ENV = 'production';
process.env.OS_AUTH_SECRET = 'organization-ownership-catalog-'.padEnd(40, 'x');
dir = mkdtempSync(join(tmpdir(), 'os-ownership-catalog-'));
mkdirSync(join(dir, 'dist'), { recursive: true });
process.env.OS_HOME = join(dir, 'home');
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
writeFileSync(process.env.OS_ARTIFACT_PATH, JSON.stringify({
manifest: { id: 'com.example.ownership-catalog', name: 'ownership-catalog', version: '1.0.0', type: 'app' },
}));
// The apply command's boot, flag for flag.
stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${join(dir, 'boot.db')}`,
projectRoot: dir,
deferSchemaDdl: true,
composeHostStack: true,
composeServedPlatform: true,
composeAuthGatedSecurity: true,
});
const kernel = stack.kernel as { getService(name: string): unknown };
const engine = kernel.getService('objectql') as { registry: never };
catalog = createSecurityCatalogReader({ registry: engine.registry, metadata: kernel.getService('metadata') as never });
}, CASE_TIMEOUT_MS);

afterAll(async () => {
await stack?.shutdown();
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
rmSync(dir, { recursive: true, force: true });
});

/** The 17.x catalog rows, and the control binding to a position no catalog declares. */
function v17Database(name: string): Database.Database {
const db = new Database(join(dir, `${name}.db`));
db.exec(`
CREATE TABLE sys_position (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, managed_by TEXT,
delegatable INTEGER, active INTEGER);
INSERT INTO sys_position VALUES
('pos_everyone', NULL, 'everyone', 'Everyone', 'platform', 0, 1),
('pos_gone', NULL, 'zz_undeclared', 'Undeclared', 'platform', 0, 1);
CREATE TABLE sys_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, managed_by TEXT,
active INTEGER, object_permissions TEXT, system_permissions TEXT);
INSERT INTO sys_permission_set VALUES ('ps_member_default', NULL, 'member_default', 'Member', 'platform', 1, '{}', '[]');
CREATE TABLE sys_position_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, position_id TEXT,
permission_set_id TEXT);
INSERT INTO sys_position_permission_set VALUES
('bind_everyone', NULL, 'pos_everyone', 'ps_member_default'),
('bind_gone', NULL, 'pos_gone', 'ps_member_default');
`);
return db;
}

async function ceremony(posture: string, db: Database.Database): Promise<OrganizationOwnershipApplyReport> {
let checkpoint: CeremonyCheckpoint | null = null;
return applyOrganizationOwnership({
executor: {
client: 'better-sqlite3',
query: async (sql, params = []) => db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[],
execute: async (sql, params = []) => { db.prepare(sql).run(...(params as unknown[])); },
},
posture,
database: 'v17',
catalog,
door: { save: async (type, name) => { throw new Error(`the door was asked to save ${type} ${name}`); } },
templates: { find: async () => undefined },
columnDrops: { drop: async (tables) => tables.map((table) => ({ table, outcome: 'not-proposed' as const })) },
ledger: { read: async () => checkpoint, write: async (cp) => { checkpoint = cp; } },
});
}

const step = (report: OrganizationOwnershipApplyReport, name: StepReport['step']): StepReport =>
report.steps.find((s) => s.step === name)!;

describe('the ownership ceremony resolves bindings against what a started kernel serves', () => {
it('the apply boot\'s catalog holds the six built-in positions, and everyone names member_default', async () => {
for (const name of [...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]) {
expect((await catalog.resolve('position', name))?.definition.name, name).toBe(name);
}
expect((await catalog.resolve('position', 'everyone'))?.definition.permissionSets).toContain('member_default');
});

it('single: authored-conversion blocks on the undeclared position, and not on everyone', async () => {
const report = await ceremony('single', v17Database('single'));
const conversion = step(report, 'authored-conversion');
expect(conversion.status).toBe('blocked');
expect(conversion.blockers).toHaveLength(1);
expect(conversion.blockers[0]).toContain('position zz_undeclared');
expect(conversion.blockers.join('\n')).not.toContain('everyone');
}, 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');
const rewrite = step(report, 'rewrite');
expect(rewrite.status).toBe('blocked');
expect(rewrite.blockers).toHaveLength(1);
expect(rewrite.blockers[0]).toContain('position zz_undeclared');
expect(rewrite.blockers.join('\n')).not.toContain('everyone');
}, CASE_TIMEOUT_MS);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0131 D2, ADR-0090 D5] The six built-in positions, and the `everyone`
* anchor's deployment baseline, are declared in `init()` — the kernel
* contract's declaration phase.
*
* A boot that composes this plugin for its declarations only runs `init()` and
* no `start()` and no hook; `os migrate organization-ownership --apply`
* resolves the position bindings an older release wrote against that boot's
* catalog. Each case here runs `init()` ALONE, over a real engine registry, and
* reads through core's security catalog reader — the read the ceremony and the
* authorization resolver both make.
*/

import { describe, expect, it, vi } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { createSecurityCatalogReader } from '@objectstack/core';
import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_NAMES, EVERYONE_POSITION } from '@objectstack/spec';
import type { PermissionSet } from '@objectstack/spec/security';
import { SecurityPlugin } from './security-plugin.js';
import { SECURITY_PLUGIN_ID } from './manifest.js';

const NOT_DECLARED = 'were NOT declared as position metadata';
const WITHHELD = 'the everyone anchor does not name a baseline set';

/** `init()` alone, with the engine registry the plugin declares to. */
async function initOnly(plugin: SecurityPlugin, engine: unknown) {
const hook = vi.fn();
const ctx: any = {
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerService: vi.fn(),
getService: (name: string) => {
if (name === 'manifest') return { register: vi.fn() };
if (name === 'objectql' && engine !== undefined) return engine;
throw new Error(`service not registered: ${name}`);
},
hook,
};
await plugin.init(ctx);
const warned = (text: string) => ctx.logger.warn.mock.calls.filter((c: unknown[]) => String(c[0]).includes(text)).length;
return { ctx, warned };
}

const catalogOf = (engine: ObjectQL) =>
createSecurityCatalogReader({
registry: (engine as any).registry,
metadata: { get: async () => undefined, list: async () => [] },
});

describe('the built-in positions and the everyone baseline, after init() alone', () => {
it('resolves all six built-in positions, owned by this plugin', async () => {
const engine = new ObjectQL();
const { warned } = await initOnly(new SecurityPlugin(), engine);
const catalog = catalogOf(engine);
for (const name of [...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]) {
const entry = await catalog.resolve('position', name);
expect(entry?.definition.name, name).toBe(name);
expect(entry?.packageId, name).toBe(SECURITY_PLUGIN_ID);
}
expect(warned(NOT_DECLARED)).toBe(0);
});

it('names the deployment baseline on everyone: the app’s default set, then member_default', async () => {
const engine = new ObjectQL();
await initOnly(new SecurityPlugin({ fallbackPermissionSet: 'field_default' }), engine);
const everyone = await catalogOf(engine).resolve('position', EVERYONE_POSITION);
expect(everyone?.definition.permissionSets).toEqual(['field_default', 'member_default']);
});

// The declared-capability context is the stack's, and the stack is still
// declaring while plugins initialize: `init()` takes the fail-closed reading,
// so a token only a declaration excuses is not named yet — and nothing is
// reported, because `kernel:ready` derives again with every declaration in
// (security-plugin.test.ts pins that the excused set is named there).
it('takes the fail-closed reading: a set whose token only a declaration excuses is not named, and nothing is said', async () => {
const engine = new ObjectQL();
(engine as any).registry.registerItem('capability', { name: 'crm.export_pipeline', label: 'Export' }, 'name', 'com.example.app');
const appDefault = {
name: 'app_member_default',
label: 'App Member Default',
isDefault: true,
systemPermissions: ['crm.export_pipeline'],
objects: {},
} as unknown as PermissionSet;
const { warned } = await initOnly(new SecurityPlugin({ defaultPermissionSets: [appDefault] }), engine);
const everyone = await catalogOf(engine).resolve('position', EVERYONE_POSITION);
expect(everyone?.definition.permissionSets).toEqual(['member_default']);
expect(warned(WITHHELD)).toBe(0);
});

it('says so when the engine exposes no registry that can register them', async () => {
const { warned } = await initOnly(new SecurityPlugin(), { registerMiddleware: () => {} });
expect(warned(NOT_DECLARED)).toBe(1);
});

it('declares nothing and says nothing when no engine is registered: start() reports that', async () => {
const { warned } = await initOnly(new SecurityPlugin(), undefined);
expect(warned(NOT_DECLARED)).toBe(0);
});
});
11 changes: 7 additions & 4 deletions packages/plugins/plugin-security/src/builtin-positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* ## One list, one home
*
* {@link securityBuiltinPositions} is the only place the six are listed.
* `SecurityPlugin.start` declares them to the engine registry
* `SecurityPlugin.init` declares them to the engine registry
* ({@link registerBuiltinPositions}), under this plugin's package id, so the
* security catalog read (`createSecurityCatalogReader`, `@objectstack/core`)
* and the metadata door (`GET /api/v1/meta/position`) list them beside every
Expand Down Expand Up @@ -51,9 +51,12 @@
* [ADR-0090 D5, ADR-0131 D3/D4] The `everyone` anchor distributes the
* deployment's baseline sets — the app's `isDefault` set composed with the
* platform's `member_default` — in its definition's `permissionSets`, which
* the authorization resolver reads. The plugin declares it at `kernel:ready`
* ({@link declareEveryoneBaseline}), once the stack's declared capabilities
* the high-privilege check reads are in; no other built-in names a set.
* the authorization resolver reads. The plugin declares it
* ({@link declareEveryoneBaseline}) in `init()` with the fail-closed reading of
* the high-privilege check, so a boot that composes the plugin for its
* declarations only holds it too, and again at `kernel:ready`, once the stack's
* declared capabilities that check reads are in, with the accepted package
* defaults; no other built-in names a set.
*/

import {
Expand Down
Loading
Loading