From c927f5ba6b29a9f0aeff97207dc22fa5b4035a2c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 06:45:19 +0000 Subject: [PATCH 1/5] fix(plugin-security): declare the built-in positions and the everyone baseline in init() A boot that composes the security plugin for its declarations only runs init() and nothing after it, so a declaration made from start() or kernel:ready never reached that boot's catalog. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG --- .../plugin-security/src/builtin-positions.ts | 11 +- .../plugin-security/src/security-plugin.ts | 120 +++++++++++++----- 2 files changed, 92 insertions(+), 39 deletions(-) diff --git a/packages/plugins/plugin-security/src/builtin-positions.ts b/packages/plugins/plugin-security/src/builtin-positions.ts index 1d52bc0de21..115aaf6b166 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.ts @@ -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 @@ -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 { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b042c660f9c..7a64f142152 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1572,6 +1572,13 @@ export class SecurityPlugin implements Plugin { ], }); + // [ADR-0131 D2, ADR-0090 D5] The six built-in positions and the `everyone` + // anchor's deployment baseline are declarations, so they are made here, in + // the kernel contract's declaration phase — where a boot that composes this + // plugin for its declarations only reaches them too. See + // {@link declareBuiltinPositions}. + this.declareBuiltinPositions(ctx); + // ADR-0029 D8 — contribute this plugin's object translations to the i18n // service on kernel:ready (the i18n plugin may register after this one). if (typeof (ctx as any).hook === 'function') { @@ -1732,34 +1739,15 @@ export class SecurityPlugin implements Plugin { this.metadata = metadata; this.ql = ql; - // [ADR-0131 D2, C3's built-ins slice pulled into C2] The six built-in - // positions — the identity names and the `everyone` / `guest` anchors — are - // declared metadata of THIS plugin, as its permission sets are. Not through - // the manifest registration in `init()`: measured, a manifest `positions` - // key reaches no registry (see `builtin-positions.ts`), so they go to the - // engine registry by the registry's own seam, under this plugin's package - // id — the provenance the manifest stamps on `permissions`. Here, on the - // engine handle `start()` already holds, before `kernel:ready` runs the - // bootstrap and before any request reaches the metadata door. The - // declaration is their one home: the boot writes no `sys_position` row for - // them (ADR-0131 D2). - const builtinRegistry = (ql as { registry?: unknown }).registry; - if (registerBuiltinPositions(builtinRegistry, SECURITY_PLUGIN_ID) === 0) { - ctx.logger.warn( - '[security] the built-in positions (platform_admin, org_owner, org_admin, org_member, everyone, guest) ' - + 'were NOT declared as position metadata: the ObjectQL engine exposes no registry that can register ' - + 'them. The security catalog read and GET /api/v1/meta/position will not list them, and the ' - + '`everyone` baseline sets are granted through no position.', - ); - } - // [ADR-0090 D5, ADR-0131 D3/D4] `everyone` declares the deployment's - // baseline in its `permissionSets` — the binding the authorization resolver - // reads, in place of the junction rows the boot used to write — plus every - // package default a `manage_metadata` holder accepted (#22801 → A). At - // `kernel:ready`, because the high-privilege check reads the stack's - // declared capabilities (#18535) and they are all in by then; derived again - // on every accept, revoke, uninstall and package publish - // (`declareEveryone`). + // [ADR-0131 D2] The six built-in positions, and `everyone`'s deployment + // baseline as the fail-closed reading gives it, were declared in `init()` + // ({@link declareBuiltinPositions}). + // [ADR-0090 D5, ADR-0131 D3/D4] Here `everyone` is derived again with what + // only a started kernel has: the high-privilege check against the stack's + // declared capabilities (#18535), which are all in by `kernel:ready`, plus + // every package default a `manage_metadata` holder accepted (#22801 → A), + // which is a database read. Derived again on every accept, revoke, + // uninstall and package publish (`declareEveryone`). if (typeof (ctx as any).hook === 'function') { (ctx as any).hook('kernel:ready', () => this.declareEveryone(ctx.logger)); } @@ -4777,13 +4765,8 @@ export class SecurityPlugin implements Plugin { const ql = this.ql; try { const anchorContext = await readDeclaredCapabilityContext(ql, this.metadata); - const sets = this.baselinePermissionSets.filter((name) => { - const boot = this.bootstrapPermissionSets.find((p) => p.name === name); - const offending = boot ? describeHighPrivilegeBits(boot, anchorContext) : null; - if (offending) { - logger.warn('[security] the everyone anchor does not name a baseline set — high-privilege bits', { set: name, offending }); - } - return !offending; + const sets = this.everyoneBaseline(anchorContext, (set, offending) => { + logger.warn('[security] the everyone anchor does not name a baseline set — high-privilege bits', { set, offending }); }); try { const derived = await readAcceptedEveryoneSets(ql, this.metadata); @@ -4811,6 +4794,73 @@ export class SecurityPlugin implements Plugin { } } + /** + * [ADR-0090 D5] The deployment's baseline as `everyone` names it: the + * declared baseline minus every shipped set carrying a high-privilege bit, + * judged against `context` — the stack's declared capabilities. Pure: the + * one derivation {@link declareBuiltinPositions} and {@link declareEveryone} + * share. `onWithheld` hears each set it leaves out. + */ + private everyoneBaseline( + context: AnchorBindingContext | undefined, + onWithheld?: (set: string, offending: string) => void, + ): string[] { + return this.baselinePermissionSets.filter((name) => { + const boot = this.bootstrapPermissionSets.find((p) => p.name === name); + const offending = boot ? describeHighPrivilegeBits(boot, context) : null; + if (offending) onWithheld?.(name, offending); + return !offending; + }); + } + + /** + * [ADR-0131 D2, ADR-0090 D5] Declare the six built-in positions, with + * `everyone` naming the deployment's baseline, to the engine registry — from + * `init()`, the kernel contract's declaration phase. + * + * They are metadata of THIS plugin, as its permission sets are, and their one + * home: no boot step writes a `sys_position` row for them. Not through the + * manifest registration: a manifest `positions` key reaches no registry (see + * `builtin-positions.ts`), so they go to the engine registry by its own seam, + * under this plugin's package id. Registry writes only — nothing here reads or + * writes the database. + * + * Why `init()`: a boot that composes this plugin for its declarations only + * runs `init()` and no `start()` and no hook — `os migrate + * organization-ownership --apply` resolves position bindings against this + * catalog, and declared from `start()` and `kernel:ready` neither the + * built-ins nor the baseline reached it. + * + * The baseline here is the fail-closed reading: no declared-capability + * context, because the stack's declarations are still arriving while plugins + * initialize, and a verdict read off a still-filling registry is one the boot + * can contradict. With no context a token is never excused, so this list is + * never wider than the one {@link declareEveryone} derives at `kernel:ready`, + * which replaces it on a started kernel and adds the accepted package + * defaults (a database read, which stays there). Nothing is reported here: + * {@link declareEveryone} reports what it withholds. + */ + private declareBuiltinPositions(ctx: PluginContext): void { + let ql: { registry?: unknown } | undefined; + try { + ql = ctx.getService<{ registry?: unknown }>('objectql'); + } catch { + return; // No engine: `start()` reports it, and nothing can be declared. + } + if (!ql) return; + if (registerBuiltinPositions(ql.registry, SECURITY_PLUGIN_ID) === 0) { + ctx.logger.warn( + '[security] the built-in positions (platform_admin, org_owner, org_admin, org_member, everyone, guest) ' + + 'were NOT declared as position metadata: the ObjectQL engine exposes no registry that can register ' + + 'them. The security catalog read and GET /api/v1/meta/position will not list them, and the ' + + '`everyone` baseline sets are granted through no position.', + ); + return; + } + const baseline = this.everyoneBaseline(undefined); + if (baseline.length > 0) declareEveryoneBaseline(ql.registry, SECURITY_PLUGIN_ID, baseline); + } + async destroy(): Promise { this.metadataWatch?.unsubscribe(); this.metadataWatch = null; From 7f3b67b69adc9c3c5c79f7f3838852c357459a89 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:05:57 +0000 Subject: [PATCH 2/5] test: pin the built-in positions and the everyone baseline in the declarations-only catalog Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG --- ...p-apply.served-catalog.integration.test.ts | 166 ++++++++++++++++++ ...uiltin-positions.declaration-phase.test.ts | 101 +++++++++++ 2 files changed, 267 insertions(+) create mode 100644 packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts create mode 100644 packages/plugins/plugin-security/src/builtin-positions.declaration-phase.test.ts 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 new file mode 100644 index 00000000000..008f535b31d --- /dev/null +++ b/packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts @@ -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 = {}; + +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 { + let checkpoint: CeremonyCheckpoint | null = null; + return applyOrganizationOwnership({ + executor: { + client: 'better-sqlite3', + query: async (sql, params = []) => db.prepare(sql).all(...(params as unknown[])) as Record[], + 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); +}); diff --git a/packages/plugins/plugin-security/src/builtin-positions.declaration-phase.test.ts b/packages/plugins/plugin-security/src/builtin-positions.declaration-phase.test.ts new file mode 100644 index 00000000000..11bf03935a1 --- /dev/null +++ b/packages/plugins/plugin-security/src/builtin-positions.declaration-phase.test.ts @@ -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); + }); +}); From 6728b5d6e9abd06696045f9ff4cc1d573e48931c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:17:05 +0000 Subject: [PATCH 3/5] test(plugin-security): record the everyone baseline move in the init()+start() grant goldens Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG --- ...grant-permission-set-name-backfill.test.ts | 22 +++++++---- ...nt-permission-set-name.equivalence.test.ts | 17 +++++--- .../src/grant-readers-by-name.golden.test.ts | 25 +++++++----- ...lve-authz-grant-set-by-name.golden.test.ts | 39 +++++++++++++------ 4 files changed, 67 insertions(+), 36 deletions(-) diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts index bd5802bec94..a6f8427dc4a 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -380,10 +380,11 @@ describe('[ADR-0131 D4] grant name backfill — every principal’s grants are b await clearAllNames(world.engine); // The resolver reads a grant's set by its name (ADR-0131 C2 stage S5a), - // so a grant the backfill has not named yet confers nothing. + // so a grant the backfill has not named yet confers nothing: what is + // left is what `everyone` names, the deployment's baseline. const unnamed = (await grantsByPrincipal(world.engine)) as Record; expect(unnamed.member.permissions).not.toContain('viewer_readonly'); - expect(unnamed.organizationAdmin.permissions).toEqual([]); + expect(unnamed.organizationAdmin.permissions).toEqual(['member_default']); expect(unnamed.organizationAdmin.posture).toBe('MEMBER'); if (posture === 'single') { // The promoted first user's grant row is the anchor; walled, the @@ -695,6 +696,11 @@ describe('[ADR-0131 D4] grant name backfill — boot wiring', () => { * The resolver's envelope per principal — recorded from the tree BEFORE the * name column existed (the S4a grant-equivalence golden). The backfill brings * every principal back to it once the resolver reads the name (stage S5a). + * One recorded move since, the same in every principal: `member_default` through + * `everyone`. `init()` declares the deployment's baseline on that anchor + * (`builtin-positions.declaration-phase.test.ts`), and this harness runs + * `init()` and `start()` but fires no `kernel:ready`, where a started kernel + * has always derived it (`builtin-positions.boot.test.ts`'s grant golden). */ const GOLDEN: Record = { single: { @@ -709,7 +715,7 @@ const GOLDEN: Record = { accessible_org_ids: ['org_eq'], email: 'member@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['viewer_readonly'], + permissions: ['member_default', 'viewer_readonly'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -718,7 +724,7 @@ const GOLDEN: Record = { accessible_org_ids: ['org_eq'], email: 'orgadmin@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['organization_admin_no_bypass'], + permissions: ['member_default', 'organization_admin_no_bypass'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], @@ -727,7 +733,7 @@ const GOLDEN: Record = { accessible_org_ids: [], email: 'admin@eq.example', org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], - permissions: ['admin_full_access'], + permissions: ['admin_full_access', 'member_default'], positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ @@ -748,7 +754,7 @@ const GOLDEN: Record = { accessible_org_ids: ['org_eq'], email: 'member@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['viewer_readonly'], + permissions: ['member_default', 'viewer_readonly'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -757,7 +763,7 @@ const GOLDEN: Record = { accessible_org_ids: ['org_eq'], email: 'orgadmin@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['organization_admin'], + permissions: ['member_default', 'organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], @@ -766,7 +772,7 @@ const GOLDEN: Record = { accessible_org_ids: [], email: 'admin@eq.example', org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], - permissions: ['admin_full_access'], + permissions: ['admin_full_access', 'member_default'], positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index 6d42aa4cf6f..f31058574e1 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -235,6 +235,11 @@ describe('[ADR-0131 D4] grant equivalence — the name column changes no princip * and unchanged after it. `single` and walled differ in exactly one place: the * organization administrator's set — the walled `organization_admin` against * the wall-less `organization_admin_no_bypass` (ADR-0105 D4). + * One recorded move since, the same in every principal: `member_default` through + * `everyone`. `init()` declares the deployment's baseline on that anchor + * (`builtin-positions.declaration-phase.test.ts`), and this harness runs + * `init()` and `start()` but fires no `kernel:ready`, where a started kernel + * has always derived it (`builtin-positions.boot.test.ts`'s grant golden). */ const GOLDEN: Record<'single' | 'isolated', unknown> = { single: { @@ -249,7 +254,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: ['org_eq'], email: 'member@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['viewer_readonly'], + permissions: ['member_default', 'viewer_readonly'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -258,7 +263,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: ['org_eq'], email: 'orgadmin@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['organization_admin_no_bypass'], + permissions: ['member_default', 'organization_admin_no_bypass'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], @@ -267,7 +272,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: [], email: 'admin@eq.example', org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], - permissions: ['admin_full_access'], + permissions: ['admin_full_access', 'member_default'], positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ @@ -288,7 +293,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: ['org_eq'], email: 'member@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['viewer_readonly'], + permissions: ['member_default', 'viewer_readonly'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -297,7 +302,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: ['org_eq'], email: 'orgadmin@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['organization_admin'], + permissions: ['member_default', 'organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], @@ -306,7 +311,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: [], email: 'admin@eq.example', org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], - permissions: ['admin_full_access'], + permissions: ['admin_full_access', 'member_default'], positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ diff --git a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts index ff7a37634b0..f9788eac1af 100644 --- a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts @@ -302,6 +302,11 @@ describe('[ADR-0131 D4] grant readers by name — every principal answers the re * administrator's set (the walled `organization_admin` against the wall-less * `organization_admin_no_bypass`, ADR-0105 D4) and platform standing (a grant row * under `single`; the declared owner, which no grant row carries, under a wall). + * One recorded move since, the same in every principal: `member_default` through + * `everyone`. `init()` declares the deployment's baseline on that anchor + * (`builtin-positions.declaration-phase.test.ts`), and this harness runs + * `init()` and `start()` but fires no `kernel:ready`, where a started kernel + * has always derived it (`builtin-positions.boot.test.ts`'s grant golden). */ const SINGLE: unknown = { backfill: { granted: 0, revoked: 0, scanned: 3, skipped: 0 }, @@ -310,7 +315,7 @@ const SINGLE: unknown = { delegatedPositions: [], droppedGrants: [], hasPlatformAdminGrant: false, - permissions: [], + permissions: ['member_default'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -331,7 +336,7 @@ const SINGLE: unknown = { }, ], hasPlatformAdminGrant: false, - permissions: ['mcp_agent_data_read'], + permissions: ['mcp_agent_data_read', 'member_default'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -344,7 +349,7 @@ const SINGLE: unknown = { delegatedPositions: [], droppedGrants: [{ kind: 'permission_set', name: 'legacy_reports', state: 'deactivated' }], hasPlatformAdminGrant: false, - permissions: ['viewer_readonly'], + permissions: ['member_default', 'viewer_readonly'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -357,7 +362,7 @@ const SINGLE: unknown = { delegatedPositions: [], droppedGrants: [], hasPlatformAdminGrant: false, - permissions: ['organization_admin_no_bypass'], + permissions: ['member_default', 'organization_admin_no_bypass'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], @@ -370,7 +375,7 @@ const SINGLE: unknown = { delegatedPositions: [], droppedGrants: [], hasPlatformAdminGrant: true, - permissions: ['admin_full_access'], + permissions: ['admin_full_access', 'member_default'], positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ @@ -402,7 +407,7 @@ const WALLED: unknown = { delegatedPositions: [], droppedGrants: [], hasPlatformAdminGrant: false, - permissions: [], + permissions: ['member_default'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -423,7 +428,7 @@ const WALLED: unknown = { }, ], hasPlatformAdminGrant: false, - permissions: ['mcp_agent_data_read'], + permissions: ['mcp_agent_data_read', 'member_default'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -436,7 +441,7 @@ const WALLED: unknown = { delegatedPositions: [], droppedGrants: [{ kind: 'permission_set', name: 'legacy_reports', state: 'deactivated' }], hasPlatformAdminGrant: false, - permissions: ['viewer_readonly'], + permissions: ['member_default', 'viewer_readonly'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -449,7 +454,7 @@ const WALLED: unknown = { delegatedPositions: [], droppedGrants: [], hasPlatformAdminGrant: false, - permissions: ['organization_admin'], + permissions: ['member_default', 'organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], @@ -462,7 +467,7 @@ const WALLED: unknown = { delegatedPositions: [], droppedGrants: [], hasPlatformAdminGrant: true, - permissions: ['admin_full_access'], + permissions: ['admin_full_access', 'member_default'], positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index 71cf6dd152c..ba69e021ba0 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -253,6 +253,11 @@ describe('[ADR-0131 D4] the resolver reads a grant’s set by name — every pri * `organization_admin_no_bypass`, ADR-0105 D4); platform standing is a grant * row under `single` and the declared owner under a wall, and both answer the * same. + * One recorded move since, the same in every principal: `member_default` through + * `everyone`. `init()` declares the deployment's baseline on that anchor + * (`builtin-positions.declaration-phase.test.ts`), and this harness runs + * `init()` and `start()` but fires no `kernel:ready`, where a started kernel + * has always derived it (`builtin-positions.boot.test.ts`'s grant golden). */ const SINGLE: unknown = { agent: { @@ -267,7 +272,8 @@ const SINGLE: unknown = { 'usr_orgadmin' ], permissions: [ - 'mcp_agent_data_read' + 'mcp_agent_data_read', + 'member_default' ], positions: [ 'everyone', @@ -284,7 +290,7 @@ const SINGLE: unknown = { org_user_ids: [ 'usr_agent' ], - permissions: [], + permissions: ['member_default'], positions: [ 'everyone', 'org_member' @@ -306,6 +312,7 @@ const SINGLE: unknown = { 'usr_orgadmin' ], permissions: [ + 'member_default', 'viewer_readonly' ], positions: [ @@ -323,7 +330,7 @@ const SINGLE: unknown = { org_user_ids: [ 'usr_member' ], - permissions: [], + permissions: ['member_default'], positions: [ 'everyone', 'org_member' @@ -345,6 +352,7 @@ const SINGLE: unknown = { 'usr_orgadmin' ], permissions: [ + 'member_default', 'organization_admin_no_bypass' ], positions: [ @@ -367,7 +375,7 @@ const SINGLE: unknown = { org_user_ids: [ 'usr_orgadmin' ], - permissions: [], + permissions: ['member_default'], positions: [ 'everyone', 'org_owner' @@ -388,7 +396,8 @@ const SINGLE: unknown = { 'usr_orgadmin' ], permissions: [ - 'admin_full_access' + 'admin_full_access', + 'member_default' ], positions: [ 'everyone', @@ -414,7 +423,8 @@ const SINGLE: unknown = { 'usr_admin' ], permissions: [ - 'admin_full_access' + 'admin_full_access', + 'member_default' ], positions: [ 'everyone', @@ -450,7 +460,8 @@ const WALLED: unknown = { 'usr_orgadmin' ], permissions: [ - 'mcp_agent_data_read' + 'mcp_agent_data_read', + 'member_default' ], positions: [ 'everyone', @@ -467,7 +478,7 @@ const WALLED: unknown = { org_user_ids: [ 'usr_agent' ], - permissions: [], + permissions: ['member_default'], positions: [ 'everyone', 'org_member' @@ -489,6 +500,7 @@ const WALLED: unknown = { 'usr_orgadmin' ], permissions: [ + 'member_default', 'viewer_readonly' ], positions: [ @@ -506,7 +518,7 @@ const WALLED: unknown = { org_user_ids: [ 'usr_member' ], - permissions: [], + permissions: ['member_default'], positions: [ 'everyone', 'org_member' @@ -528,6 +540,7 @@ const WALLED: unknown = { 'usr_orgadmin' ], permissions: [ + 'member_default', 'organization_admin' ], positions: [ @@ -550,7 +563,7 @@ const WALLED: unknown = { org_user_ids: [ 'usr_orgadmin' ], - permissions: [], + permissions: ['member_default'], positions: [ 'everyone', 'org_owner' @@ -571,7 +584,8 @@ const WALLED: unknown = { 'usr_orgadmin' ], permissions: [ - 'admin_full_access' + 'admin_full_access', + 'member_default' ], positions: [ 'everyone', @@ -597,7 +611,8 @@ const WALLED: unknown = { 'usr_admin' ], permissions: [ - 'admin_full_access' + 'admin_full_access', + 'member_default' ], positions: [ 'everyone', From 1ebf9c5d7d00077661be67637affd9685eedf9a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:44:15 +0000 Subject: [PATCH 4/5] chore(changeset): the ceremony's catalog resolves the built-in positions and the everyone baseline Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG --- .changeset/23397-ceremony-builtin-positions.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/23397-ceremony-builtin-positions.md diff --git a/.changeset/23397-ceremony-builtin-positions.md b/.changeset/23397-ceremony-builtin-positions.md new file mode 100644 index 00000000000..b552d090f72 --- /dev/null +++ b/.changeset/23397-ceremony-builtin-positions.md @@ -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. From 35ab3835fbd46f53731ba81e3da3fe29eee1d877 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:59:30 +0000 Subject: [PATCH 5/5] fix(plugin-security): restore the AnchorBindingContext type import the everyone baseline filter uses The merge of main brought the audience-anchor gate's deletion, which took the import with it; the init() baseline filter still names the type. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG --- packages/plugins/plugin-security/src/security-plugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index f997327eb76..23b54065e47 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5,6 +5,7 @@ import { declaredHttpStatus } from '@objectstack/types'; import { Plugin, PluginContext, POSTURE_LADDER, buildEffectiveObjectPermissions, recordNotFoundError, withoutOperationPrivateKeys } from '@objectstack/core'; import type { EffectiveObjectPermission, PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; import { describeHighPrivilegeBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; +import type { AnchorBindingContext } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; // [#8220] The read-scope provenance mark: this middleware is one of the two // merge boundaries that stamp it (see the RLS injection below).