From f9f54c59e9e5102b6d718e21f9e949e6218c54b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 17:33:25 +0000 Subject: [PATCH 1/4] fix(plugin-security): echo metadata-door permission sets and positions into an environment kernel's security catalog On an environment kernel the metadata door hydrates no stored row into the engine registry, so the security catalog can find a metadata-door permission set or position only in the kernel's metadata service. The permission-set projector's echo was the only thing that put a set there, and C3 stage 8 unwired the projector: since then such a set resolved to nothing, granted nothing and recorded no everyone suggestion. Positions never had an echo. A new module, security-catalog-echo.ts, keeps only the echo: SecurityPlugin registers it as the awaited mutation projector for permission and position, and runs one pass over the stored env-wide overlays at kernel:ready. It writes no row. The projection module (unwired) now imports the shared helpers from it. The echo-owner pins move onto the new module with the #21789 provenance cases, and a boot-level CLI test drives an environment kernel and a host kernel through the production wiring, across a restart. Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- .changeset/23308-security-catalog-echo.md | 21 ++ ...atalog-echo-env-kernel.integration.test.ts | 241 +++++++++++++++ .../src/permission-set-projection.ts | 37 +-- ....test.ts => security-catalog-echo.test.ts} | 229 ++++++++++++-- .../src/security-catalog-echo.ts | 280 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 38 ++- 6 files changed, 788 insertions(+), 58 deletions(-) create mode 100644 .changeset/23308-security-catalog-echo.md create mode 100644 packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts rename packages/plugins/plugin-security/src/{permission-set-echo-package-owner.test.ts => security-catalog-echo.test.ts} (51%) create mode 100644 packages/plugins/plugin-security/src/security-catalog-echo.ts diff --git a/.changeset/23308-security-catalog-echo.md b/.changeset/23308-security-catalog-echo.md new file mode 100644 index 00000000000..4b6c99ad96d --- /dev/null +++ b/.changeset/23308-security-catalog-echo.md @@ -0,0 +1,21 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): an environment kernel's security catalog serves metadata-door permission sets and positions again + +Clause-②: no + +On an environment kernel (the per-project kernel a hosted tenant runs), a permission set or a position saved, published or deleted through the metadata door is in the security catalog again: + +- It resolves from the kernel's metadata service, under the package its stored row is bound to. +- A user granted it is authorized by it. +- An `isDefault: true` set in a runtime package records its pending `everyone` suggestion again (ADR-0090 D5). + +After a restart, the stored overlays are served again too. + +This regressed in C3 stage 8 (`feat(plugin-security)!: retire the four security-catalog row objects`). That change unwired the permission-set projector. On an environment kernel, the projector's in-memory echo was the only thing that put a stored set into the metadata service, because the metadata door hydrates no project row into the engine registry. Since stage 8, such a set resolved to nothing and granted nothing (fail-closed), and no suggestion was recorded. + +Positions never had an echo. On an environment kernel, a metadata-door position was unresolved before stage 8 as well, and it now resolves. + +`SecurityPlugin` now registers its own mutation projector for `permission` and `position` on the protocol, plus one pass over the stored environment-wide overlays at `kernel:ready`. Both only register the overlay body in the metadata service. They write no row, and they change nothing a host kernel resolves, since a host kernel reads these from the engine registry first. diff --git a/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts new file mode 100644 index 00000000000..03ef149e509 --- /dev/null +++ b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A permission set or a position written through the metadata door is in the + * security catalog of an ENVIRONMENT kernel: it resolves, it grants, and an + * `isDefault: true` set in a runtime package records its `everyone` + * suggestion. Booted through the production wiring, across a restart. + * + * ## Why this boots kernels + * + * On an environment kernel the metadata door registers no stored row in the + * engine registry (`applyRegistryWriteThrough` returns before the hydrator + * when the kernel has an `environmentId`), so the security catalog + * (ADR-0131 D3) finds such a set or position only in the kernel's metadata + * service. One thing put it there: the permission-set projector's registry + * echo. C3 stage 8 unwired the projector, and the echo went with it. Every + * metadata-door permission set on an environment kernel then resolved to + * nothing, granted nothing and suggested nothing — and the unit suite that + * pinned the echo stayed green, because it wired the projector itself. + * Positions had the same gap before stage 8: nothing ever echoed them. + * + * So this file wires NOTHING itself. Each case boots the composition a hosted + * tenant runs (cloud's per-project shape, as quoted in + * `src/utils/platform-migrations-arming.integration.test.ts`: an + * environment-scoped `ObjectQLPlugin` with the protocol delegated to + * `createMetadataProtocolPlugin`), plus the real metadata service, auth and + * `SecurityPlugin`, and asserts on what a request sees. The host kernel (the + * `os serve` shape: built-in protocol, no environment) runs the same scenario + * as a control; its stored rows reach the engine registry, so it never needed + * the echo. + * + * The restart leg boots a second kernel over the same database file: the + * overlays are stored rows by then, and only `SecurityPlugin`'s + * `kernel:ready` pass puts them back in the metadata service. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectKernel, AppPlugin, DefaultDatasourcePlugin } from '@objectstack/runtime'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { createMetadataProtocolPlugin } from '@objectstack/metadata-protocol'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { securityCatalogReaderOf, resolveAuthzContext, assembleExecutionContext } from '@objectstack/core'; + +const ENV = 'env_catalog_echo'; +const SYS = { context: { isSystem: true } } as any; + +/** A writable runtime package, never installed as code. */ +const RUNTIME_PKG = 'app.catalog_echo'; +/** Its default set: `isDefault: true`, no `packageId` in the body. */ +const PKG_SET = 'echo_pkg_default'; +/** A set saved into no package. */ +const LOOSE_SET = 'echo_loose_reader'; +/** A position saved into no package, naming the set the stack declares. */ +const POSITION = 'echo_position'; +const DECLARED_SET = 'echo_declared_reader'; + +const stack: any = { + manifest: { id: 'com.example.catalog_echo', name: 'catalog_echo', version: '0.0.1', type: 'app' }, + objects: [{ name: 'echo_doc', label: 'Echo Doc', fields: { title: { type: 'text', label: 'Title' } } }], + permissions: [{ name: DECLARED_SET, label: 'Declared reader', objects: { echo_doc: { allowRead: true } } }], +}; + +type Kind = 'environment' | 'host'; + +async function boot(kind: Kind, dbFile: string, rootDir: string): Promise { + const kernel = new ObjectKernel(); + await kernel.use(new ObjectQLPlugin(kind === 'environment' ? { environmentId: ENV, registerProtocol: false } : {}) as any); + await kernel.use(new (DefaultDatasourcePlugin as any)({ driver: 'sqlite', config: { filename: dbFile } }, { dev: false })); + if (kind === 'environment') await kernel.use(createMetadataProtocolPlugin({ environmentId: ENV }) as any); + await kernel.use(new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' }, rootDir }) as any); + await kernel.use(new AppPlugin(stack) as any); + await kernel.use(new PlatformObjectsPlugin() as any); + await kernel.use(new AuthPlugin({ secret: 'catalog-echo-secret-catalog-echo-secret' }) as any); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(stack)) as any); + await kernel.bootstrap(); + return kernel; +} + +/** Read `echo_doc` as `userId`, with the context the request path builds. */ +async function readAs(ql: any, userId: string, org: string): Promise<'granted' | 'refused'> { + const authz = await resolveAuthzContext({ + ql, + headers: {}, + getSession: () => ({ user: { id: userId }, session: { userId, activeOrganizationId: org } }), + } as any); + const context = assembleExecutionContext({ authz, oauth: undefined, localization: undefined, requestLocale: undefined } as any); + try { + const rows = await ql.find('echo_doc', { where: {} }, { context }); + return Array.isArray(rows) && rows.length === 1 ? 'granted' : 'refused'; + } catch { + return 'refused'; + } +} + +const USERS = { pkg: 'usr_echo_pkg', loose: 'usr_echo_loose', position: 'usr_echo_position' } as const; + +/** + * One kind's scenario: author the two sets and the position through the + * metadata door on the first boot, and grant each to its own user. Boot `n` + * reopens the same file; nothing is authored again. + */ +function scenario(kind: Kind) { + const dir = mkdtempSync(join(tmpdir(), `catalog-echo-${kind}-`)); + const dbFile = join(dir, 'echo.db'); + const state: { kernel?: any; ql?: any; protocol?: any; org?: string; beforeGrant?: string } = {}; + + const open = async () => { + state.kernel = await boot(kind, dbFile, dir); + state.ql = await state.kernel.getServiceAsync('objectql'); + state.protocol = await state.kernel.getServiceAsync('protocol'); + const orgs = (await state.ql.find('sys_organization', { where: {} }, SYS)) as any[]; + state.org = orgs[0]?.id; + }; + const close = async () => { + try { await state.kernel?.shutdown(); } catch { /* the next boot reports */ } + state.kernel = undefined; + }; + + const author = async () => { + const { ql, protocol, org } = state as Required; + expect(protocol.environmentId ?? null, 'topology').toBe(kind === 'environment' ? ENV : null); + const set = (name: string) => ({ name, label: name, isDefault: true, objects: { echo_doc: { allowRead: true } } }); + await protocol.saveMetaItem({ type: 'permission', name: PKG_SET, item: set(PKG_SET), packageId: RUNTIME_PKG, mode: 'draft' }); + const published = await protocol.publishPackageDrafts({ packageId: RUNTIME_PKG, actor: 'system' }); + expect(published, JSON.stringify(published)).toMatchObject({ outcome: 'published', failed: [] }); + await protocol.saveMetaItem({ type: 'permission', name: LOOSE_SET, item: { ...set(LOOSE_SET), isDefault: false } }); + await protocol.saveMetaItem({ + type: 'position', name: POSITION, item: { name: POSITION, label: 'Echo position', permissionSets: [DECLARED_SET] }, + }); + + await ql.insert('echo_doc', { id: 'doc_1', title: 'only a grant reads this', organization_id: org }, SYS); + for (const id of Object.values(USERS)) { + await ql.insert('sys_user', { id, email: `${id}@example.test`, name: id, email_verified: true }, SYS); + await ql.insert('sys_member', { id: `mem_${id}`, user_id: id, organization_id: org, role: 'member' }, SYS); + } + state.beforeGrant = await readAs(ql, USERS.pkg, org); + await ql.insert('sys_user_permission_set', { id: 'ups_pkg', user_id: USERS.pkg, permission_set: PKG_SET, organization_id: org }, SYS); + await ql.insert('sys_user_permission_set', { id: 'ups_loose', user_id: USERS.loose, permission_set: LOOSE_SET, organization_id: org }, SYS); + await ql.insert('sys_user_position', { id: 'upos_1', user_id: USERS.position, position: POSITION, organization_id: org }, SYS); + }; + + const resolve = (type: 'permission' | 'position', name: string) => securityCatalogReaderOf(state.ql)!.resolve(type, name); + const pendingSuggestions = async () => + ((await state.ql.find('sys_audience_binding_suggestion', { where: { permission_set_name: PKG_SET } }, SYS)) as any[]) + .filter((row) => row.status === 'pending'); + + return { dir, dbFile, state, open, close, author, resolve, pendingSuggestions }; +} + +describe('environment kernel: a metadata-door permission set or position is in the security catalog', () => { + const s = scenario('environment'); + + beforeAll(async () => { + await s.open(); + await s.author(); + }, 180_000); + afterAll(async () => { + await s.close(); + rmSync(s.dir, { recursive: true, force: true }); + }); + + it('the runtime-package set resolves from the metadata service, under its owning package', async () => { + // The topology under test: no stored row reaches the engine registry. + expect(s.state.ql.registry.getItem('permission', PKG_SET)).toBeFalsy(); + expect(await s.resolve('permission', PKG_SET)).toMatchObject({ source: 'metadata', packageId: RUNTIME_PKG }); + }); + + it('the read it grants is refused before the grant and granted after it', async () => { + expect(s.state.beforeGrant).toBe('refused'); + expect(await readAs(s.state.ql, USERS.pkg, s.state.org!)).toBe('granted'); + }); + + it('records one pending everyone suggestion for it', async () => { + await vi.waitFor(async () => expect(await s.pendingSuggestions()).toHaveLength(1), { timeout: 10_000, interval: 100 }); + expect((await s.pendingSuggestions())[0]).toMatchObject({ package_id: RUNTIME_PKG, anchor: 'everyone' }); + }, 30_000); + + it('the set saved into no package resolves, unowned, and grants', async () => { + const entry = await s.resolve('permission', LOOSE_SET); + expect(entry).toMatchObject({ source: 'metadata' }); + expect(entry).not.toHaveProperty('packageId'); + expect(await readAs(s.state.ql, USERS.loose, s.state.org!)).toBe('granted'); + }); + + it('the position resolves, and its holder reads through the set it names', async () => { + expect(await s.resolve('position', POSITION)).toMatchObject({ source: 'metadata' }); + expect(await readAs(s.state.ql, USERS.position, s.state.org!)).toBe('granted'); + }); + + it('after a restart over the same stored overlays, all three resolve and grant again', async () => { + await s.close(); + // The overlays are stored rows now, read through a connection of our own. + const probe = new SqlDriver({ client: 'better-sqlite3', connection: { filename: s.dbFile }, useNullAsDefault: true }); + try { + const rows = await (probe as any).knex('sys_metadata').select('type', 'name').where({ state: 'active' }).whereNull('organization_id'); + const stored = rows.map((r: any) => `${r.type}/${r.name}`); + expect(stored).toEqual(expect.arrayContaining([`permission/${PKG_SET}`, `permission/${LOOSE_SET}`, `position/${POSITION}`])); + } finally { + await (probe as any).knex.destroy(); + } + + await s.open(); + expect(await s.resolve('permission', PKG_SET)).toMatchObject({ source: 'metadata', packageId: RUNTIME_PKG }); + expect(await s.resolve('permission', LOOSE_SET)).toMatchObject({ source: 'metadata' }); + expect(await s.resolve('position', POSITION)).toMatchObject({ source: 'metadata' }); + expect(await readAs(s.state.ql, USERS.pkg, s.state.org!)).toBe('granted'); + expect(await readAs(s.state.ql, USERS.loose, s.state.org!)).toBe('granted'); + expect(await readAs(s.state.ql, USERS.position, s.state.org!)).toBe('granted'); + expect(await s.pendingSuggestions()).toHaveLength(1); + }, 180_000); +}); + +describe('control — host kernel: the same writes resolve from the engine registry', () => { + const s = scenario('host'); + + beforeAll(async () => { + await s.open(); + await s.author(); + }, 180_000); + afterAll(async () => { + await s.close(); + rmSync(s.dir, { recursive: true, force: true }); + }); + + it('both sets and the position resolve, and each grants', async () => { + expect(s.state.beforeGrant).toBe('refused'); + expect(await s.resolve('permission', PKG_SET)).toMatchObject({ source: 'registry' }); + expect(await s.resolve('permission', LOOSE_SET)).toMatchObject({ source: 'registry' }); + expect(await s.resolve('position', POSITION)).toMatchObject({ source: 'registry' }); + expect(await readAs(s.state.ql, USERS.pkg, s.state.org!)).toBe('granted'); + expect(await readAs(s.state.ql, USERS.loose, s.state.org!)).toBe('granted'); + expect(await readAs(s.state.ql, USERS.position, s.state.org!)).toBe('granted'); + }); +}); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 251b44ecf50..dd7db86dece 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -101,6 +101,7 @@ import { type LayeredProbe, } from './packaged-permission-set-lock.js'; import { PermissionSetNameConflictError } from './errors.js'; +import { isProjectionEcho, readServedPackageBinding, stripDecorations } from './security-catalog-echo.js'; export const SYSTEM_CTX = { isSystem: true }; @@ -463,16 +464,9 @@ function touchesDefinition(payload: Record): boolean { * is, and only one of them guards a write door. */ -/** Strip layered-read / registry decorations so a re-authored body is clean. */ -function stripDecorations(body: any): any { - if (!body || typeof body !== 'object') return body; - const { _packageId, _provenance, _diagnostics, _lock, _lockReason, _lockSource, [ENV_PROJECTION_MARKER]: _mark, ...clean } = body; - return clean; -} - -/** True when a layered-read layer is just our own registry echo, not a real artifact. */ -const isProjectionEcho = (v: any): boolean => - !!(v && typeof v === 'object' && (v as any)[ENV_PROJECTION_MARKER]); +// `stripDecorations` and `isProjectionEcho` live in `security-catalog-echo.ts` +// (imported above), the module that owns the echo since C3 stage 8 unwired +// this projector. /** * Read the DECLARED (artifact) body for a permission set from the engine's @@ -788,26 +782,9 @@ async function syncEvaluatorRegistry( } catch { /* best-effort */ } } -/** - * [#23233] The package the stored row a by-name read SERVES for `name` is - * bound to: the `_packageId` the protocol's single-item read states on the - * item from that row's `package_id` column. `undefined` when the row is bound - * to no package, or the protocol has no single-item read (minimal embeddings, - * unit-test stubs), which keys no row by package either. - * - * ⛔ The one read of that binding in this module — the data door's update leg - * ({@link createPermissionSetWriteThrough}, #21861) and the registry echo - * ({@link syncEvaluatorRegistry}) both ask it. Never from a patch, the - * projected record or a registry item: those are copies, and the row is the - * fact. A read that fails is not caught here; each caller decides what a - * binding it could not read means for its own write. - */ -async function readServedPackageBinding(protocol: any, name: string): Promise { - if (typeof protocol?.getMetaItem !== 'function') return undefined; - const served = await protocol.getMetaItem({ type: 'permission', name }); - const packageId = served?.item?._packageId; - return typeof packageId === 'string' && packageId !== '' ? packageId : undefined; -} +// [#23233] `readServedPackageBinding`, the one read of a stored row's package +// binding — asked by the data door's update leg and the registry echo here — +// lives in `security-catalog-echo.ts` (imported above). /** Drop the in-memory `permission` entry for a retired definition. */ function dropEvaluatorRegistryEntry(metadata: any, name: string): void { diff --git a/packages/plugins/plugin-security/src/permission-set-echo-package-owner.test.ts b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts similarity index 51% rename from packages/plugins/plugin-security/src/permission-set-echo-package-owner.test.ts rename to packages/plugins/plugin-security/src/security-catalog-echo.test.ts index 196ebe1fdc9..573e74069a0 100644 --- a/packages/plugins/plugin-security/src/permission-set-echo-package-owner.test.ts +++ b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts @@ -1,8 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #23233 — the permission-set projection's registry echo names the package - * its stored row is bound to, so a set published into a writable runtime + * The security catalog's echo (`security-catalog-echo.ts`), as the security + * plugin wires it on an environment kernel. + * + * #23233 — the echo names the package its stored row is bound to, so a set published into a writable runtime * package records its `everyone` suggestion (ADR-0090 D5: "packages suggest, * admins confirm"), and the security catalog and the protocol's by-name read * name the same owner. @@ -16,7 +18,8 @@ * (`applyRegistryWriteThrough` returns before the hydrator when the kernel has * an environment), so the security catalog finds the set in the metadata * service — and the only body the metadata service holds for it is the - * projection's echo. The catalog takes its owner from `_packageId` alone + * echo. (Until C3 stage 8 the permission-set projector registered it; the + * echo now lives in its own module and writes no row.) The catalog takes its owner from `_packageId` alone * (`ownerOf`, `@objectstack/core`); the echo used to arrive without one, so * the set was served unowned and the suggestion collector skipped it. * @@ -35,7 +38,7 @@ import { assertEngineUpdateDispatch, } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { projectPermissionMutation, registerPermissionSetProjection } from './permission-set-projection.js'; +import { echoSecurityCatalogMutation, echoStoredSecurityCatalogOverlays, registerSecurityCatalogEcho } from './security-catalog-echo.js'; import { syncAudienceBindingSuggestions } from './suggested-audience-bindings.js'; import { declareEveryoneBaseline } from './builtin-positions.js'; import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; @@ -142,19 +145,22 @@ function makeEngine() { return engine; } -/** The kernel `metadata` service as the projection and the catalog use it: registry-first, in memory. */ +/** The kernel `metadata` service as the echo and the catalog use it: registry-first, in memory, keyed by type. */ function makeMetadataService() { - const store = new Map(); + const byType = new Map>(); + const of = (type: string) => byType.get(type) ?? byType.set(type, new Map()).get(type)!; return { - store, - registerInMemory: (_type: string, name: string, data: Row) => { store.set(name, data); }, - get: (_type: string, name: string) => store.get(name), - list: (_type: string) => [...store.values()], - unregister: (_type: string, name: string) => { store.delete(name); }, + /** The `permission` entries — what every permission-set case reads. */ + store: of('permission'), + of, + registerInMemory: (type: string, name: string, data: Row) => { of(type).set(name, data); }, + get: (type: string, name: string) => of(type).get(name), + list: (type: string) => [...of(type).values()], + unregister: (type: string, name: string) => { of(type).delete(name); }, }; } -/** One environment kernel: the protocol, the projection wired as the security plugin wires it, the catalog bound. */ +/** One environment kernel: the protocol, the echo wired as the security plugin wires it, the catalog bound. */ function boot() { const engine = makeEngine(); const metadata = makeMetadataService(); @@ -165,7 +171,7 @@ function boot() { () => new Map([['metadata', metadata]]), 'env_tenant', // an ENVIRONMENT kernel — see the module doc ) as any; - expect(registerPermissionSetProjection(protocol, { ql: engine, metadata, logger })).toBe(true); + expect(registerSecurityCatalogEcho(protocol, { ql: engine, metadata, logger })).toBe(true); const catalog = createSecurityCatalogReader({ registry: engine.registry, metadata }); bindSecurityCatalogReader(engine, catalog); declareEveryoneBaseline(engine.registry, SECURITY_PACKAGE, ['member_default']); @@ -182,6 +188,9 @@ async function publishThroughPackageDoor(protocol: any, packageId: string, body: const suggestionRows = (engine: ReturnType, set: string) => (engine.tables.sys_audience_binding_suggestion ?? []).filter((row) => row.permission_set_name === set); +/** The layered read's `code` layer: the metadata service's view, i.e. the echo. */ +const metadataEcho = (layered: any) => layered?.code; + const runtimeDefault = () => ({ name: RUNTIME_SET, label: 'Workspace member', isDefault: true, objects: {} }); describe('#23233 — a runtime package\'s isDefault set, published without a body packageId', () => { @@ -223,9 +232,9 @@ describe('#23233 — a runtime package\'s isDefault set, published without a bod }); describe('#23233 — the edit surface does not move', () => { - // The projected `sys_permission_set` row keeps `managed_by: 'admin'` and no - // `package_id`: the owner rides on the echo the catalog reads, and nothing - // that gates an admin's edit reads the echo's `_packageId`. + // The owner rides on the echo the catalog reads, and nothing that gates an + // admin's edit reads the echo's `_packageId`. No `sys_permission_set` row is + // written at all: C3 stage 8 retired the row, and the echo writes none. it('the owner-carrying echo is still not a shipped artifact: the lock answers org, the item stays editable, and an edit lands', async () => { const { engine, protocol } = boot(); await publishThroughPackageDoor(protocol, RUNTIME_PKG, runtimeDefault()); @@ -240,10 +249,9 @@ describe('#23233 — the edit surface does not move', () => { type: 'permission', name: RUNTIME_SET, packageId: RUNTIME_PKG, item: { ...runtimeDefault(), label: 'Edited' }, }); expect((await protocol.getMetaItem({ type: 'permission', name: RUNTIME_SET })).item).toMatchObject({ label: 'Edited', _packageId: RUNTIME_PKG }); - expect((engine.tables.sys_permission_set ?? []).filter((row) => row.name === RUNTIME_SET)).toEqual([ - expect.objectContaining({ label: 'Edited', managed_by: 'admin' }), - ]); - expect(engine.tables.sys_permission_set.find((row) => row.name === RUNTIME_SET)?.package_id ?? null).toBeNull(); + // The edit reached the catalog through the echo, and no row was written. + expect(metadataEcho(await protocol.getMetaItemLayered({ type: 'permission', name: RUNTIME_SET }))).toMatchObject({ label: 'Edited', _packageId: RUNTIME_PKG }); + expect(engine.tables.sys_permission_set ?? []).toEqual([]); }); }); @@ -257,7 +265,7 @@ describe('#23233 — controls: the owner is carried, never invented', () => { expect.objectContaining({ package_id: CODE_PKG, status: 'pending' }), ]); expect(await catalog.resolve('permission', CODE_SET)).toMatchObject({ source: 'registry', packageId: CODE_PKG }); - // No overlay row, so the projection registered no echo of it at all. + // No overlay row, so no echo of it was registered at all. expect(metadata.store.has(CODE_SET)).toBe(false); }); @@ -282,14 +290,189 @@ describe('#23233 — controls: the owner is carried, never invented', () => { getMetaItem: async () => { throw new Error('metadata store unavailable'); }, }; - const out = await projectPermissionMutation(protocol, { ql: engine, metadata, logger: { warn: (m: string, meta?: any) => warnings.push({ message: m, meta }) } }, { + const out = await echoSecurityCatalogMutation(protocol, { ql: engine, metadata, logger: { warn: (m: string, meta?: any) => warnings.push({ message: m, meta }) } }, { type: 'permission', name: RUNTIME_SET, state: 'active', }); - expect(out?.seeded).toBe(1); + expect(out).toBe('registered'); const echo = metadata.store.get(RUNTIME_SET); expect(echo).toMatchObject({ name: RUNTIME_SET, _provenance: 'org', _envProjection: true }); expect(echo).not.toHaveProperty('_packageId'); expect(warnings.filter((w) => w.meta?.name === RUNTIME_SET)).toHaveLength(1); }); }); + +/** + * A layered-read stub: `overlays` are the stored env-wide rows (with their + * package binding), `declared` the artifacts. Enough for the echo's own + * decisions, which read nothing else. + */ +function stubProtocol(overlays: Record, declared: Record = {}) { + return { + getMetaItemLayered: async ({ type, name }: { type: string; name: string }) => { + const overlay = overlays[`${type}/${name}`]?.body ?? null; + const code = declared[`${type}/${name}`] ?? null; + return { type, name, code, overlay, effective: overlay ?? code }; + }, + getMetaItem: async ({ type, name }: { type: string; name: string }) => { + const stored = overlays[`${type}/${name}`]; + return { type, name, item: stored ? { ...stored.body, ...(stored.packageId ? { _packageId: stored.packageId } : {}) } : declared[`${type}/${name}`] }; + }, + }; +} + +/** An engine whose registry declares `artifacts` by type, and nothing else. */ +const engineDeclaring = (artifacts: Record = {}) => ({ + registry: { listItems: (type: string) => artifacts[type] ?? [] }, + find: async () => [], +}); + +// [#21789] The echo is served by the protocol's layered read as the item's +// `code` layer, and the envelope's `provenance` is read off it — so the echo +// must say what the lock says, or the read reports a lock the doors do not +// enforce (an org's own set rendered "locked by a code package"). +describe('[#21789] the echo states the lock\'s verdict as its provenance', () => { + const body = (over: Row = {}) => ({ name: 'organization_admin', label: 'Organization Administrator', objects: {}, ...over }); + + it('the echo of a set no code package ships says so: `_provenance: \'org\'`', async () => { + // The registry holds the stored row the way a host kernel's hydrator + // registers it — a runtime package id stamped on, tenant provenance over it. + const engine = engineDeclaring({ permission: [{ ...body(), _packageId: 'app.crm_workspace', _provenance: 'org' }] }); + const metadata = makeMetadataService(); + const protocol = stubProtocol({ 'permission/organization_admin': { body: body() } }); + + expect(await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' })).toBe('registered'); + const entry = metadata.store.get('organization_admin'); + expect({ marker: entry?._envProjection, provenance: entry?._provenance }).toEqual({ marker: true, provenance: 'org' }); + }); + + it('control: the echo of a legacy overlay of a code-shipped set does NOT claim tenant provenance, nor an owner', async () => { + const shipped = { ...body({ systemPermissions: ['declared.only'] }), _packageId: 'com.example.crm', _provenance: 'package' }; + const engine = engineDeclaring({ permission: [shipped] }); + const metadata = makeMetadataService(); + const protocol = stubProtocol( + { 'permission/organization_admin': { body: body({ systemPermissions: ['overlaid'] }), packageId: 'com.example.crm' } }, + { 'permission/organization_admin': shipped }, + ); + + await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' }); + const entry = metadata.store.get('organization_admin'); + expect(entry?.systemPermissions, 'precondition: the overlay was echoed').toEqual(['overlaid']); + expect({ marker: entry?._envProjection, provenance: entry?._provenance ?? null, owner: entry?._packageId ?? null }) + .toEqual({ marker: true, provenance: null, owner: null }); + }); +}); + +describe('the echo follows the overlay: healed, dropped, skipped', () => { + const body = (over: Row = {}) => ({ name: 'ops_reader', label: 'Ops reader', objects: {}, ...over }); + + it('a delete over a DECLARED set heals the echo back to the declared body, unmarked', async () => { + const declared = body({ systemPermissions: ['declared.only'] }); + const engine = engineDeclaring({ permission: [declared] }); + const metadata = makeMetadataService(); + const overlays: Record = { 'permission/ops_reader': { body: body({ systemPermissions: ['overlaid'] }) } }; + const protocol = stubProtocol(overlays, { 'permission/ops_reader': declared }); + await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'ops_reader', state: 'active' }); + expect(metadata.store.get('ops_reader')?.systemPermissions).toEqual(['overlaid']); + + delete overlays['permission/ops_reader']; + expect(await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'ops_reader', state: 'deleted' })).toBe('healed'); + const healed = metadata.store.get('ops_reader'); + expect(healed?.systemPermissions).toEqual(['declared.only']); + expect(healed?._envProjection).toBeUndefined(); + }); + + it('a delete that leaves no body drops the echo', async () => { + const metadata = makeMetadataService(); + const overlays: Record = { 'permission/ops_reader': { body: body() } }; + const protocol = stubProtocol(overlays); + await echoSecurityCatalogMutation(protocol, { ql: engineDeclaring(), metadata }, { type: 'permission', name: 'ops_reader', state: 'active' }); + expect(metadata.store.has('ops_reader')).toBe(true); + + delete overlays['permission/ops_reader']; + expect(await echoSecurityCatalogMutation(protocol, { ql: engineDeclaring(), metadata }, { type: 'permission', name: 'ops_reader', state: 'deleted' })).toBe('dropped'); + expect(metadata.store.has('ops_reader')).toBe(false); + }); + + it('a delete never touches an entry that is not our echo', async () => { + const metadata = makeMetadataService(); + metadata.registerInMemory('permission', 'ops_reader', body({ label: 'from a stack' })); + expect(await echoSecurityCatalogMutation(stubProtocol({}), { ql: engineDeclaring(), metadata }, { type: 'permission', name: 'ops_reader', state: 'deleted' })).toBe('skipped'); + expect(metadata.store.get('ops_reader')).toMatchObject({ label: 'from a stack' }); + }); + + it('skips drafts and every other type', async () => { + const metadata = makeMetadataService(); + const protocol = stubProtocol({ 'permission/ops_reader': { body: body() }, 'object/ops_reader': { body: body() } }); + expect(await echoSecurityCatalogMutation(protocol, { ql: engineDeclaring(), metadata }, { type: 'permission', name: 'ops_reader', state: 'draft' })).toBe('skipped'); + expect(await echoSecurityCatalogMutation(protocol, { ql: engineDeclaring(), metadata }, { type: 'object', name: 'ops_reader', state: 'active' })).toBe('skipped'); + expect(metadata.store.size + metadata.of('object').size).toBe(0); + }); +}); + +describe('positions are echoed too', () => { + const position = (over: Row = {}) => ({ name: 'field_lead', label: 'Field lead', permissionSets: ['crm_readonly'], ...over }); + + it('a stored position no artifact declares is echoed tenant-authored, under the package its row is bound to', async () => { + const metadata = makeMetadataService(); + const protocol = stubProtocol({ 'position/field_lead': { body: position(), packageId: RUNTIME_PKG } }); + expect(await echoSecurityCatalogMutation(protocol, { ql: engineDeclaring(), metadata }, { type: 'position', name: 'field_lead', state: 'active' })).toBe('registered'); + expect(metadata.of('position').get('field_lead')).toMatchObject({ + permissionSets: ['crm_readonly'], _provenance: 'org', _packageId: RUNTIME_PKG, _envProjection: true, + }); + expect(metadata.store.size, 'nothing lands under permission').toBe(0); + }); + + it('control: an overlay of a declared position claims no tenant provenance', async () => { + const declared = { ...position({ permissionSets: [] }), _packageId: CODE_PKG, _provenance: 'package' }; + const metadata = makeMetadataService(); + const protocol = stubProtocol({ 'position/field_lead': { body: position(), packageId: CODE_PKG } }, { 'position/field_lead': declared }); + await echoSecurityCatalogMutation(protocol, { ql: engineDeclaring({ position: [declared] }), metadata }, { type: 'position', name: 'field_lead', state: 'active' }); + const entry = metadata.of('position').get('field_lead'); + expect(entry?.permissionSets, 'precondition: the overlay was echoed').toEqual(['crm_readonly']); + expect({ provenance: entry?._provenance ?? null, owner: entry?._packageId ?? null }).toEqual({ provenance: null, owner: null }); + }); + + it('a position saved through the real environment protocol resolves in the catalog', async () => { + const { protocol, catalog } = boot(); + await protocol.saveMetaItem({ type: 'position', name: 'field_lead', item: position({ permissionSets: [CODE_SET] }) }); + expect(await catalog.resolve('position', 'field_lead')).toMatchObject({ source: 'metadata', definition: { permissionSets: [CODE_SET] } }); + }); +}); + +describe('the boot pass echoes every stored env-wide overlay, and only those', () => { + it('registers active env-wide rows of both types; organization rows and drafts are left alone', async () => { + const rows: Row[] = [ + { type: 'permission', name: 'env_set', state: 'active', organization_id: null }, + { type: 'permissions', name: 'legacy_plural_set', state: 'active', organization_id: null }, + { type: 'position', name: 'env_position', state: 'active', organization_id: null }, + { type: 'permission', name: 'org_set', state: 'active', organization_id: 'org_a' }, + ]; + const engine = { + registry: { listItems: () => [] }, + find: async (object: string, query: { where: Row }) => + (object === 'sys_metadata' ? rows.filter((row) => matches(row, query.where)) : []), + }; + const overlays: Record = {}; + for (const row of rows) { + const type = row.type.replace(/s$/, ''); + overlays[`${type}/${row.name}`] = { body: { name: row.name, label: row.name } }; + } + const metadata = makeMetadataService(); + + expect(await echoStoredSecurityCatalogOverlays(stubProtocol(overlays), { ql: engine, metadata })).toBe(3); + expect([...metadata.store.keys()].sort()).toEqual(['env_set', 'legacy_plural_set']); + expect([...metadata.of('position').keys()]).toEqual(['env_position']); + }); + + it('a metadata table it cannot read is said, not read as "no overlays"', async () => { + const warnings: string[] = []; + const engine = { registry: { listItems: () => [] }, find: async () => { throw new Error('no such table: sys_metadata'); } }; + const out = await echoStoredSecurityCatalogOverlays(stubProtocol({}), { + ql: engine, metadata: makeMetadataService(), logger: { warn: (message: string) => { warnings.push(message); } }, + }); + expect(out).toBe(0); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings[0]).toContain('grants nothing'); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-catalog-echo.ts b/packages/plugins/plugin-security/src/security-catalog-echo.ts new file mode 100644 index 00000000000..eb119d9437b --- /dev/null +++ b/packages/plugins/plugin-security/src/security-catalog-echo.ts @@ -0,0 +1,280 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The security catalog's echo: an environment-wide `permission` or `position` + * overlay registered in the kernel's metadata service, so the security + * catalog (ADR-0131 D3, `createSecurityCatalogReader`) finds it on an + * ENVIRONMENT kernel. + * + * ## Why it exists + * + * The catalog reads the engine registry first, then the metadata service. On + * a host kernel the metadata door hydrates every stored row into the engine + * registry, so a set or position saved there is found. On an environment + * kernel it does not: `applyRegistryWriteThrough` returns before the hydrator + * when the kernel has an `environmentId` (project rows never enter the + * process-wide registry). The metadata service is then the only place the + * catalog can find a stored row's body, and nothing in the metadata door puts + * it there. Until C3 stage 8 the permission-set projector did, as the + * by-product of projecting a `sys_permission_set` row; stage 8 unwired the + * projector and the echo went with it, so every metadata-door permission set + * on an environment kernel resolved to nothing, granted nothing and recorded + * no `everyone` suggestion. Positions never had an echo at all. + * + * This module keeps the echo and nothing else: no row is written, and no + * authorization decision is made here — the catalog, the resolver and the + * suggestion collector read what this registers exactly as they read any + * other metadata-service item. + * + * ## The two entry points + * + * - {@link registerSecurityCatalogEcho}: the protocol's AWAITED mutation + * projector for both types (ADR-0094), so a save, publish or delete has + * been echoed when it returns — before the post-persistence listeners run, + * which is where the suggestion reconcile reads the catalog. + * - {@link echoStoredSecurityCatalogOverlays}: one pass at `kernel:ready` + * over every stored env-wide overlay of both types, because the metadata + * service is in memory and a restart starts it empty. + * + * ## What an echo carries + * + * The overlay body, stripped of layered-read decorations, stamped with + * {@link ENV_PROJECTION_MARKER} so it never reads as a shipped artifact + * (`packaged-permission-set-lock.ts` skips marked items). When the item is + * tenant-authored it also carries: + * + * - `_provenance: 'org'` (#21789). The protocol's layered read serves the + * echo as the item's `code` layer, and an unstamped code layer is how a + * client reads "a code package ships this". For a permission set the + * verdict is the packaged-permission-set lock's, the classifier both write + * doors ask, so the reported and the enforced state are one judgment. For + * a position it is "no artifact in the engine registry declares the name". + * - `_packageId` (#23233): the package the stored row is bound to, as the + * protocol's by-name read serves it. The catalog takes an item's owner + * from `_packageId` alone, and the suggestion collector skips an unowned + * `isDefault: true` set (ADR-0090 D5: packages suggest, admins confirm). + * The owner is carried, never derived. + * + * When the overlay is gone, an echo of ours is healed back to the declared + * body the engine registry holds, or dropped. + */ + +import { isTenantAuthored } from '@objectstack/metadata-core'; +import { seedCtx } from './organization-scope.js'; +import { + ENV_PROJECTION_MARKER, + classifyPackagedPermissionSet, +} from './packaged-permission-set-lock.js'; + +/** The security-catalog types whose stored rows this module echoes. */ +export const SECURITY_CATALOG_ECHO_TYPES = ['permission', 'position'] as const; +export type SecurityCatalogEchoType = (typeof SECURITY_CATALOG_ECHO_TYPES)[number]; + +export interface SecurityCatalogEchoLogger { + warn(message: string, meta?: Record): void; + info?(message: string, meta?: Record): void; +} + +export interface SecurityCatalogEchoDeps { + /** The engine: its `registry` holds the declared artifacts; `find` reads `sys_metadata` at boot. */ + ql: any; + /** The kernel `metadata` service the catalog reads (`registerInMemory`, `get`, `unregister`). */ + metadata?: any; + logger?: SecurityCatalogEchoLogger; +} + +/** What one echo did. */ +export type SecurityCatalogEchoOutcome = 'registered' | 'healed' | 'dropped' | 'skipped'; + +const isEchoType = (type: unknown): type is SecurityCatalogEchoType => + type === 'permission' || type === 'position'; + +/** Strip layered-read / registry decorations so an echoed body is clean. */ +export function stripDecorations(body: any): any { + if (!body || typeof body !== 'object') return body; + const { _packageId, _provenance, _diagnostics, _lock, _lockReason, _lockSource, [ENV_PROJECTION_MARKER]: _mark, ...clean } = body; + return clean; +} + +/** True when a layered-read layer or a metadata-service item is our own echo, not a real artifact. */ +export const isProjectionEcho = (v: any): boolean => + !!(v && typeof v === 'object' && (v as any)[ENV_PROJECTION_MARKER]); + +/** + * The declared (artifact) body for `name` in the engine registry, or `null`. + * Echoes and stored rows are not artifacts: a registry heal can re-register + * the metadata service's view (our marked copy) as a plain item, and a host + * kernel's hydrator registers stored rows wearing `_provenance: 'org'`. + */ +function readDeclaredBody(ql: any, type: SecurityCatalogEchoType, name: string): any { + try { + const items = ql?.registry?.listItems?.(type) ?? []; + for (const body of items) { + if (body?.name !== name || body?._packageId === 'sys_metadata') continue; + if (isProjectionEcho(body) || isTenantAuthored(body)) continue; + return body; + } + } catch { /* no readable registry: nothing declared */ } + return null; +} + +/** + * The package the stored row a by-name read SERVES for `name` is bound to: + * the `_packageId` the protocol's single-item read states from that row's + * `package_id` column. `undefined` when the row is bound to no package, or the + * protocol has no single-item read. A read that fails is not caught here. + */ +export async function readServedPackageBinding( + protocol: any, + name: string, + type: SecurityCatalogEchoType = 'permission', +): Promise { + if (typeof protocol?.getMetaItem !== 'function') return undefined; + const served = await protocol.getMetaItem({ type, name }); + const packageId = served?.item?._packageId; + return typeof packageId === 'string' && packageId !== '' ? packageId : undefined; +} + +/** + * Echo one landed mutation. Reads the environment-wide overlay through the + * protocol's layered read (ADR-0131 D6: no organization is part of a metadata + * read) and registers it, or heals/drops our echo when there is none. + */ +export async function echoSecurityCatalogMutation( + protocol: any, + deps: SecurityCatalogEchoDeps, + evt: { type?: string; name?: string; state?: string } | null | undefined, +): Promise { + const type = evt?.type; + const name = evt?.name; + if (!isEchoType(type) || !name || evt?.state === 'draft') return 'skipped'; + const { ql, metadata, logger } = deps; + if (!metadata || typeof metadata.registerInMemory !== 'function') return 'skipped'; + if (typeof protocol?.getMetaItemLayered !== 'function') return 'skipped'; + + let overlay: any = null; + let envelope: unknown; + if (evt?.state !== 'deleted') { + const layered = await protocol.getMetaItemLayered({ type, name }); + // An envelope is told by its layer keys; anything else carries no overlay + // this module can tell apart from an artifact, so it is not echoed. + if (layered && typeof layered === 'object' + && ('effective' in layered || 'overlay' in layered || 'code' in layered)) { + envelope = layered; + overlay = layered.overlay ?? null; + } + } + + if (!overlay?.name) { + // Overlay gone: heal an echo of ours back to the declared body, or drop it. + const current = typeof metadata.get === 'function' ? await metadata.get(type, name) : undefined; + if (!isProjectionEcho(current)) return 'skipped'; + const declared = readDeclaredBody(ql, type, name); + if (declared) { + metadata.registerInMemory(type, name, stripDecorations(declared)); + return 'healed'; + } + if (typeof metadata.unregister === 'function') { + // unregister() also asks writable loaders to delete — the stored row is + // already gone, so this is an in-memory removal. + await metadata.unregister(type, name); + } + return 'dropped'; + } + + const tenantAuthored = type === 'permission' + ? classifyPackagedPermissionSet(name, ql, { status: 'read', envelope }).status === 'org' + : readDeclaredBody(ql, type, name) === null; + let ownerPackageId: string | undefined; + if (tenantAuthored) { + try { + ownerPackageId = await readServedPackageBinding(protocol, name, type); + } catch (e) { + // Functional, not durability: nothing persisted is lost and nothing + // grants differently — the echo is registered unowned, so it still + // resolves and enforces the stored body. What is missing is said once, + // with what restores it. + logger?.warn( + `[security] the package binding of this ${type} could not be read, so its security-catalog echo ` + + 'names no owning package: the catalog serves it as unowned, and an `isDefault: true` permission ' + + 'set records no audience-binding suggestion (ADR-0090 D5), until the name is echoed again — ' + + 'the next save, publish or boot re-reads the binding. Enforcement is unaffected.', + { type, name, error: String((e as Error)?.message ?? e) }, + ); + } + } + metadata.registerInMemory(type, name, { + ...stripDecorations(overlay), + ...(tenantAuthored ? { _provenance: 'org' } : {}), + ...(tenantAuthored && ownerPackageId !== undefined ? { _packageId: ownerPackageId } : {}), + [ENV_PROJECTION_MARKER]: true, + }); + return 'registered'; +} + +/** + * Register the echo as the protocol's awaited mutation projector for + * `permission` and `position`. Returns `true` when wired; `false` on a + * protocol without the seam (no environment door to echo). + * + * ⚠️ One projector per type on the protocol: a second registration replaces + * the first. No other plugin registers one for these two types. + */ +export function registerSecurityCatalogEcho(protocol: any, deps: SecurityCatalogEchoDeps): boolean { + if (typeof protocol?.registerMutationProjector !== 'function') return false; + for (const type of SECURITY_CATALOG_ECHO_TYPES) { + protocol.registerMutationProjector(type, async (evt: any) => { + await echoSecurityCatalogMutation(protocol, deps, evt); + }); + } + return true; +} + +/** + * The boot pass: echo every stored, active, environment-wide overlay of both + * types. The metadata service is in memory, so without this a restart serves + * none of them until each is saved again. Returns the number registered. + */ +export async function echoStoredSecurityCatalogOverlays( + protocol: any, + deps: SecurityCatalogEchoDeps, +): Promise { + const { ql, logger } = deps; + if (typeof ql?.find !== 'function' || typeof protocol?.getMetaItemLayered !== 'function') return 0; + let registered = 0; + for (const type of SECURITY_CATALOG_ECHO_TYPES) { + const names = new Set(); + // The plural spelling is a legacy `type` column value the protocol still reads. + for (const stored of [type, `${type}s`]) { + let rows: any[]; + try { + const found = await ql.find('sys_metadata', { where: { type: stored, state: 'active' } }, { context: seedCtx() }); + rows = Array.isArray(found) ? found : []; + } catch (e) { + logger?.warn( + `[security] stored ${type} overlays could not be read at boot, so none is echoed into the security ` + + 'catalog: on an environment kernel each one stays unresolved — it grants nothing — until it is ' + + 'saved again or the kernel restarts with a readable metadata table.', + { type: stored, error: String((e as Error)?.message ?? e) }, + ); + continue; + } + for (const row of rows) { + if ((row?.organization_id ?? null) !== null || !row?.name) continue; // env-wide overlays only + names.add(String(row.name)); + } + } + for (const name of names) { + try { + if (await echoSecurityCatalogMutation(protocol, deps, { type, name, state: 'active' }) === 'registered') { + registered += 1; + } + } catch (e) { + logger?.warn(`[security] the security-catalog echo of a stored ${type} failed at boot; it stays unresolved until it is saved again`, { + type, name, error: String((e as Error)?.message ?? e), + }); + } + } + } + return registered; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 691eeef3992..4ab801c9a93 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -59,6 +59,7 @@ import { applyManagedWriteDenies } from './managed-object-write-denies.js'; import { reportPackagedPermissionSetOverlays } from './packaged-permission-set-overlay-detection.js'; import { registerObjectPostureGate } from './object-posture-gate.js'; import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; +import { registerSecurityCatalogEcho, echoStoredSecurityCatalogOverlays } from './security-catalog-echo.js'; import { reconcileAudienceBindingSuggestions, listAudienceBindingSuggestions, @@ -4349,8 +4350,9 @@ export class SecurityPlugin implements Plugin { // retired, so no `sys_position`, `sys_permission_set` or `sys_capability` // write reaches the engine at all. A position, permission set or // capability is written through the metadata door alone, which registers - // it in the security catalog itself and, under a wall, refuses a tenant - // with the same verdict (`walledCatalogWriteVerdict`). + // it in the security catalog (on an environment kernel through the echo + // `runBootstrap` wires, `security-catalog-echo.ts`) and, under a wall, + // refuses a tenant with the same verdict (`walledCatalogWriteVerdict`). // Defer platform admin bootstrap until all plugins finish starting — // sys_user / sys_user_permission_set objects must be registered (by @@ -4362,6 +4364,11 @@ export class SecurityPlugin implements Plugin { // [#22801 → A] Same once-guard for the `permission` mutation listener // that re-derives `everyone` (registered in `runBootstrap`). let everyoneMutationWired = false; + // The security catalog's echo is wired once and its boot pass runs once, + // though runBootstrap re-runs (e.g. after the first user insert): the + // projector replaces idempotently, and the pass would only repeat work. + let catalogEchoWired = false; + let catalogEchoBooted = false; // [#3325 / ADR-0092] Union the better-auth managed-object write denies into // the default sets from the LIVE registry, replacing the hand-maintained // baseline as the source of truth (a newly-declared identity table is then @@ -4446,9 +4453,30 @@ export class SecurityPlugin implements Plugin { }, ); } - // [ADR-0131 D3, C3 stage 8] No `permission` mutation projector: the - // metadata door's own registry write-through puts a saved set in the - // security catalog, and no row mirrors it. + // [ADR-0131 D3] The security catalog's echo: the awaited mutation + // projector for `permission` and `position`, then one pass over the + // stored env-wide overlays. On an ENVIRONMENT kernel the metadata + // door registers no stored row in the engine registry, so the + // catalog finds a metadata-door set or position only in the + // metadata service, and this is what puts it there. It writes no + // row (C3 stage 8 retired them) and decides nothing. Before the + // suggestion reconcile below, which reads the catalog. See + // `security-catalog-echo.ts`. + if (!catalogEchoWired) { + catalogEchoWired = registerSecurityCatalogEcho(protocol, { + ql, metadata: this.metadata, logger: ctx.logger, + }); + } + if (catalogEchoWired && !catalogEchoBooted) { + catalogEchoBooted = true; + try { + await echoStoredSecurityCatalogOverlays(protocol, { + ql, metadata: this.metadata, logger: ctx.logger, + }); + } catch (e) { + ctx.logger.warn('[security] the security-catalog echo of stored overlays failed at boot', { error: (e as Error).message }); + } + } // [#3050] OWD posture authoring gate — pre-persistence veto on // runtime-authored `object` bodies: env-tighten-only over packaged // declarations (ADR-0086 D1) + external ≤ internal (ADR-0090 D11). From 8e21f2115b367c99c7ca2c67cf220b8cedc64bc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:00:06 +0000 Subject: [PATCH 2/4] test(plugin-security): the engine-double ledger follows the echo suite's rename; the boot-pass find double holds its bound Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- .../src/security-catalog-echo.test.ts | 6 ++-- scripts/engine-double-contract.pinned.json | 30 +++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/plugins/plugin-security/src/security-catalog-echo.test.ts b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts index 573e74069a0..4776f0cc83f 100644 --- a/packages/plugins/plugin-security/src/security-catalog-echo.test.ts +++ b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts @@ -450,8 +450,10 @@ describe('the boot pass echoes every stored env-wide overlay, and only those', ( ]; const engine = { registry: { listItems: () => [] }, - find: async (object: string, query: { where: Row }) => - (object === 'sys_metadata' ? rows.filter((row) => matches(row, query.where)) : []), + find: async (object: string, query: { where: Row; limit?: number }) => { + const hits = object === 'sys_metadata' ? rows.filter((row) => matches(row, query.where)) : []; + return typeof query?.limit === 'number' ? hits.slice(0, query.limit) : hits; + }, }; const overlays: Record = {}; for (const row of rows) { diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 2e18773f500..f6886570286 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3391,21 +3391,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/permission-set-echo-package-owner.test.ts", - "verb": "delete", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/permission-set-echo-package-owner.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/permission-set-echo-package-owner.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", "verb": "findOne", @@ -3461,6 +3446,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/security-catalog-echo.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-security/src/security-catalog-echo.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-security/src/security-catalog-echo.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/security-denial-user-copy.test.ts", "verb": "delete", From 2d631dd784225772052259dc99ed6349ab422e84 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:06:49 +0000 Subject: [PATCH 3/4] fix(plugin-security): the security-catalog echo skips a name the engine registry already serves A host kernel hydrates every stored row into the engine registry, so an echo there is never read by the catalog, and a delete's registry heal re-registered it as a plain registry item: a deleted set, or an uninstalled package's set, went on being served. The echo now registers only a name the registry does not serve (the environment-kernel shape), and a delete also removes a copy of our echo that leaked into the registry. The boot test gains a delete and an uninstall leg on both kernels. Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- ...atalog-echo-env-kernel.integration.test.ts | 26 ++++- .../src/security-catalog-echo.test.ts | 81 +++++++++------- .../src/security-catalog-echo.ts | 94 ++++++++++++------- 3 files changed, 132 insertions(+), 69 deletions(-) diff --git a/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts index 03ef149e509..9166ac984a1 100644 --- a/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts +++ b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts @@ -151,7 +151,15 @@ function scenario(kind: Kind) { ((await state.ql.find('sys_audience_binding_suggestion', { where: { permission_set_name: PKG_SET } }, SYS)) as any[]) .filter((row) => row.status === 'pending'); - return { dir, dbFile, state, open, close, author, resolve, pendingSuggestions }; + /** Delete the no-package set, then uninstall the runtime package: neither may still be served. */ + const retire = async () => { + const { protocol } = state as Required; + await protocol.deleteMetaItem({ type: 'permission', name: LOOSE_SET }); + const uninstall = await protocol.deletePackage({ packageId: RUNTIME_PKG }); + expect(uninstall, JSON.stringify(uninstall)).toBeTruthy(); + }; + + return { dir, dbFile, state, open, close, author, retire, resolve, pendingSuggestions }; } describe('environment kernel: a metadata-door permission set or position is in the security catalog', () => { @@ -215,6 +223,16 @@ describe('environment kernel: a metadata-door permission set or position is in t expect(await readAs(s.state.ql, USERS.position, s.state.org!)).toBe('granted'); expect(await s.pendingSuggestions()).toHaveLength(1); }, 180_000); + + it('a deleted set and an uninstalled package\'s set are no longer served, and grant nothing', async () => { + await s.retire(); + expect(await s.resolve('permission', LOOSE_SET)).toBeUndefined(); + expect(await s.resolve('permission', PKG_SET)).toBeUndefined(); + expect(await readAs(s.state.ql, USERS.loose, s.state.org!)).toBe('refused'); + expect(await readAs(s.state.ql, USERS.pkg, s.state.org!)).toBe('refused'); + // Control: the position, untouched by either, still resolves. + expect(await s.resolve('position', POSITION)).toMatchObject({ source: 'metadata' }); + }, 60_000); }); describe('control — host kernel: the same writes resolve from the engine registry', () => { @@ -238,4 +256,10 @@ describe('control — host kernel: the same writes resolve from the engine regis expect(await readAs(s.state.ql, USERS.loose, s.state.org!)).toBe('granted'); expect(await readAs(s.state.ql, USERS.position, s.state.org!)).toBe('granted'); }); + + it('a deleted set and an uninstalled package\'s set are no longer served', async () => { + await s.retire(); + expect(await s.resolve('permission', LOOSE_SET)).toBeUndefined(); + expect(await s.resolve('permission', PKG_SET)).toBeUndefined(); + }, 60_000); }); diff --git a/packages/plugins/plugin-security/src/security-catalog-echo.test.ts b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts index 4776f0cc83f..8c34e0c1e23 100644 --- a/packages/plugins/plugin-security/src/security-catalog-echo.test.ts +++ b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts @@ -38,7 +38,11 @@ import { assertEngineUpdateDispatch, } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { echoSecurityCatalogMutation, echoStoredSecurityCatalogOverlays, registerSecurityCatalogEcho } from './security-catalog-echo.js'; +import { + echoSecurityCatalogMutation, + echoStoredSecurityCatalogOverlays, + registerSecurityCatalogEcho, +} from './security-catalog-echo.js'; import { syncAudienceBindingSuggestions } from './suggested-audience-bindings.js'; import { declareEveryoneBaseline } from './builtin-positions.js'; import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; @@ -335,9 +339,8 @@ describe('[#21789] the echo states the lock\'s verdict as its provenance', () => const body = (over: Row = {}) => ({ name: 'organization_admin', label: 'Organization Administrator', objects: {}, ...over }); it('the echo of a set no code package ships says so: `_provenance: \'org\'`', async () => { - // The registry holds the stored row the way a host kernel's hydrator - // registers it — a runtime package id stamped on, tenant provenance over it. - const engine = engineDeclaring({ permission: [{ ...body(), _packageId: 'app.crm_workspace', _provenance: 'org' }] }); + // An environment kernel: the registry holds no stored row. + const engine = engineDeclaring(); const metadata = makeMetadataService(); const protocol = stubProtocol({ 'permission/organization_admin': { body: body() } }); @@ -346,40 +349,53 @@ describe('[#21789] the echo states the lock\'s verdict as its provenance', () => expect({ marker: entry?._envProjection, provenance: entry?._provenance }).toEqual({ marker: true, provenance: 'org' }); }); - it('control: the echo of a legacy overlay of a code-shipped set does NOT claim tenant provenance, nor an owner', async () => { - const shipped = { ...body({ systemPermissions: ['declared.only'] }), _packageId: 'com.example.crm', _provenance: 'package' }; - const engine = engineDeclaring({ permission: [shipped] }); + it('a set the engine registry already serves is not echoed: a host kernel\'s stored row, a declared artifact', async () => { + const hydrated = { ...body(), _packageId: 'app.crm_workspace', _provenance: 'org' }; + const shipped = { ...body({ name: 'crm_readonly' }), _packageId: 'com.example.crm', _provenance: 'package' }; + const engine = { ...engineDeclaring({ permission: [hydrated, shipped] }) } as any; + engine.registry.getItem = (type: string, name: string) => engine.registry.listItems(type).find((i: Row) => i.name === name); const metadata = makeMetadataService(); - const protocol = stubProtocol( - { 'permission/organization_admin': { body: body({ systemPermissions: ['overlaid'] }), packageId: 'com.example.crm' } }, - { 'permission/organization_admin': shipped }, - ); + const protocol = stubProtocol({ + 'permission/organization_admin': { body: body(), packageId: 'app.crm_workspace' }, + 'permission/crm_readonly': { body: body({ name: 'crm_readonly', systemPermissions: ['overlaid'] }) }, + }); - await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' }); - const entry = metadata.store.get('organization_admin'); - expect(entry?.systemPermissions, 'precondition: the overlay was echoed').toEqual(['overlaid']); - expect({ marker: entry?._envProjection, provenance: entry?._provenance ?? null, owner: entry?._packageId ?? null }) - .toEqual({ marker: true, provenance: null, owner: null }); + expect(await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' })).toBe('skipped'); + expect(await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'crm_readonly', state: 'active' })).toBe('skipped'); + expect(metadata.store.size).toBe(0); }); }); describe('the echo follows the overlay: healed, dropped, skipped', () => { const body = (over: Row = {}) => ({ name: 'ops_reader', label: 'Ops reader', objects: {}, ...over }); - it('a delete over a DECLARED set heals the echo back to the declared body, unmarked', async () => { - const declared = body({ systemPermissions: ['declared.only'] }); - const engine = engineDeclaring({ permission: [declared] }); + it('an echo of ours is dropped once the engine registry serves the name, and a copy leaked into the registry goes too', async () => { + const items: Row[] = []; + const registry = { + listItems: (type: string) => (type === 'permission' ? items : []), + getItem: (_type: string, name: string) => items.find((i) => i.name === name), + unregisterItem: (_type: string, name: string) => { const i = items.findIndex((x) => x.name === name); if (i >= 0) items.splice(i, 1); }, + }; + const engine = { registry, find: async () => [] }; const metadata = makeMetadataService(); - const overlays: Record = { 'permission/ops_reader': { body: body({ systemPermissions: ['overlaid'] }) } }; - const protocol = stubProtocol(overlays, { 'permission/ops_reader': declared }); - await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'ops_reader', state: 'active' }); - expect(metadata.store.get('ops_reader')?.systemPermissions).toEqual(['overlaid']); + const overlays: Record = { 'permission/ops_reader': { body: body() } }; + const protocol = stubProtocol(overlays); + expect(await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'ops_reader', state: 'active' })).toBe('registered'); + // A registry heal re-registered the metadata service's view (our echo). + items.push({ ...metadata.store.get('ops_reader') }); delete overlays['permission/ops_reader']; - expect(await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'ops_reader', state: 'deleted' })).toBe('healed'); - const healed = metadata.store.get('ops_reader'); - expect(healed?.systemPermissions).toEqual(['declared.only']); - expect(healed?._envProjection).toBeUndefined(); + expect(await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'ops_reader', state: 'deleted' })).toBe('dropped'); + expect(metadata.store.has('ops_reader')).toBe(false); + expect(items).toEqual([]); + + // A real artifact arriving later is what the registry serves; a stale echo is dropped. + metadata.registerInMemory('permission', 'ops_reader', { ...body(), _envProjection: true }); + items.push({ ...body(), _packageId: 'com.example.ops', _provenance: 'package' }); + overlays['permission/ops_reader'] = { body: body() }; + expect(await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'ops_reader', state: 'active' })).toBe('skipped'); + expect(metadata.store.has('ops_reader')).toBe(false); + expect(items).toHaveLength(1); }); it('a delete that leaves no body drops the echo', async () => { @@ -423,14 +439,14 @@ describe('positions are echoed too', () => { expect(metadata.store.size, 'nothing lands under permission').toBe(0); }); - it('control: an overlay of a declared position claims no tenant provenance', async () => { + it('control: a position the engine registry declares is not echoed', async () => { const declared = { ...position({ permissionSets: [] }), _packageId: CODE_PKG, _provenance: 'package' }; const metadata = makeMetadataService(); const protocol = stubProtocol({ 'position/field_lead': { body: position(), packageId: CODE_PKG } }, { 'position/field_lead': declared }); - await echoSecurityCatalogMutation(protocol, { ql: engineDeclaring({ position: [declared] }), metadata }, { type: 'position', name: 'field_lead', state: 'active' }); - const entry = metadata.of('position').get('field_lead'); - expect(entry?.permissionSets, 'precondition: the overlay was echoed').toEqual(['crm_readonly']); - expect({ provenance: entry?._provenance ?? null, owner: entry?._packageId ?? null }).toEqual({ provenance: null, owner: null }); + const engine = engineDeclaring({ position: [declared] }) as any; + engine.registry.getItem = (type: string, name: string) => engine.registry.listItems(type).find((i: Row) => i.name === name); + expect(await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'position', name: 'field_lead', state: 'active' })).toBe('skipped'); + expect(metadata.of('position').size).toBe(0); }); it('a position saved through the real environment protocol resolves in the catalog', async () => { @@ -478,3 +494,4 @@ describe('the boot pass echoes every stored env-wide overlay, and only those', ( expect(warnings[0]).toContain('grants nothing'); }); }); + diff --git a/packages/plugins/plugin-security/src/security-catalog-echo.ts b/packages/plugins/plugin-security/src/security-catalog-echo.ts index eb119d9437b..efaf2e29591 100644 --- a/packages/plugins/plugin-security/src/security-catalog-echo.ts +++ b/packages/plugins/plugin-security/src/security-catalog-echo.ts @@ -48,18 +48,25 @@ * client reads "a code package ships this". For a permission set the * verdict is the packaged-permission-set lock's, the classifier both write * doors ask, so the reported and the enforced state are one judgment. For - * a position it is "no artifact in the engine registry declares the name". + * a position it always holds, since only a name no artifact declares is + * echoed (below). * - `_packageId` (#23233): the package the stored row is bound to, as the * protocol's by-name read serves it. The catalog takes an item's owner * from `_packageId` alone, and the suggestion collector skips an unowned * `isDefault: true` set (ADR-0090 D5: packages suggest, admins confirm). * The owner is carried, never derived. * - * When the overlay is gone, an echo of ours is healed back to the declared - * body the engine registry holds, or dropped. + * ## What is not echoed + * + * A name the engine registry already serves: every stored row on a host + * kernel (its metadata door hydrates them there), and a declared artifact on + * any kernel. The catalog reads the registry first, so an echo of those would + * never be read — and a second copy is what a delete's registry heal or a + * package uninstall would strand. When the overlay is gone our echo is + * dropped; a package uninstall deletes each of its rows through + * `deleteMetaItem`, so the same projector drops those. */ -import { isTenantAuthored } from '@objectstack/metadata-core'; import { seedCtx } from './organization-scope.js'; import { ENV_PROJECTION_MARKER, @@ -83,8 +90,36 @@ export interface SecurityCatalogEchoDeps { logger?: SecurityCatalogEchoLogger; } +/** + * The item the engine registry serves for `name`, unless it is our own echo + * (a registry heal can re-register the metadata service's view there). + */ +function registryServes(ql: any, type: SecurityCatalogEchoType, name: string): boolean { + try { + const item = ql?.registry?.getItem?.(type, name); + return !!item && typeof item === 'object' && !isProjectionEcho(item); + } catch { + return false; + } +} + +/** Remove our echo of `name` from the metadata service, and from the engine registry if it leaked there. */ +async function dropEcho(ql: any, metadata: any, type: SecurityCatalogEchoType, name: string): Promise { + if (typeof metadata?.unregister === 'function') { + // unregister() also asks writable loaders to delete — the stored row is + // already gone, so this is an in-memory removal. + await metadata.unregister(type, name); + } + try { + const leaked = ql?.registry?.getItem?.(type, name); + if (isProjectionEcho(leaked) && typeof ql?.registry?.unregisterItem === 'function') { + ql.registry.unregisterItem(type, name); + } + } catch { /* nothing leaked, or nothing to remove it from */ } +} + /** What one echo did. */ -export type SecurityCatalogEchoOutcome = 'registered' | 'healed' | 'dropped' | 'skipped'; +export type SecurityCatalogEchoOutcome = 'registered' | 'dropped' | 'skipped'; const isEchoType = (type: unknown): type is SecurityCatalogEchoType => type === 'permission' || type === 'position'; @@ -100,24 +135,6 @@ export function stripDecorations(body: any): any { export const isProjectionEcho = (v: any): boolean => !!(v && typeof v === 'object' && (v as any)[ENV_PROJECTION_MARKER]); -/** - * The declared (artifact) body for `name` in the engine registry, or `null`. - * Echoes and stored rows are not artifacts: a registry heal can re-register - * the metadata service's view (our marked copy) as a plain item, and a host - * kernel's hydrator registers stored rows wearing `_provenance: 'org'`. - */ -function readDeclaredBody(ql: any, type: SecurityCatalogEchoType, name: string): any { - try { - const items = ql?.registry?.listItems?.(type) ?? []; - for (const body of items) { - if (body?.name !== name || body?._packageId === 'sys_metadata') continue; - if (isProjectionEcho(body) || isTenantAuthored(body)) continue; - return body; - } - } catch { /* no readable registry: nothing declared */ } - return null; -} - /** * The package the stored row a by-name read SERVES for `name` is bound to: * the `_packageId` the protocol's single-item read states from that row's @@ -165,26 +182,31 @@ export async function echoSecurityCatalogMutation( } } + const current = typeof metadata.get === 'function' ? await metadata.get(type, name) : undefined; if (!overlay?.name) { - // Overlay gone: heal an echo of ours back to the declared body, or drop it. - const current = typeof metadata.get === 'function' ? await metadata.get(type, name) : undefined; + // Overlay gone: drop an echo of ours. A declared artifact of the name is + // in the engine registry, which the catalog reads first. if (!isProjectionEcho(current)) return 'skipped'; - const declared = readDeclaredBody(ql, type, name); - if (declared) { - metadata.registerInMemory(type, name, stripDecorations(declared)); - return 'healed'; - } - if (typeof metadata.unregister === 'function') { - // unregister() also asks writable loaders to delete — the stored row is - // already gone, so this is an in-memory removal. - await metadata.unregister(type, name); - } + await dropEcho(ql, metadata, type, name); return 'dropped'; } + // The engine registry already serves this name — a host kernel hydrates + // every stored row there, and a declared artifact is there on every kernel — + // so the catalog never reaches the metadata service for it, and an echo + // would only be a second copy for a delete or an uninstall to strand. + if (registryServes(ql, type, name)) { + if (isProjectionEcho(current)) await dropEcho(ql, metadata, type, name); + return 'skipped'; + } + + // The registry serves nothing for the name, so no artifact declares it: a + // position here is tenant-authored. A permission set asks the lock, which + // also reads the layered read's `code` layer and answers `unknown` when it + // cannot tell. const tenantAuthored = type === 'permission' ? classifyPackagedPermissionSet(name, ql, { status: 'read', envelope }).status === 'org' - : readDeclaredBody(ql, type, name) === null; + : true; let ownerPackageId: string | undefined; if (tenantAuthored) { try { From 22813f7459c2cce0bf4853fbe31a5bd33897aad5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 20:59:16 +0000 Subject: [PATCH 4/4] fix(plugin-security): the security-catalog echo never overwrites a declaration the metadata service holds, and removes nothing while the stored row exists A name the kernel's metadata service already holds as something other than our echo (a stack-declared position, which a composition can declare in the metadata service only) is not echoed: the echo would overwrite the declaration, and the overlay's delete would then remove it until restart. The boot pass takes the same path. On a name the engine registry serves, an earlier echo is now left in place instead of being dropped: the metadata service's one removal, unregister, also deletes through its writable datasource loaders and announces a deleted event, which is wrong while the stored row still exists. The overlay's delete drops it. The module doc no longer claims every echoed position is tenant-authored because no artifact declares it. The boot test gains a stack position whose name a stored row took before the stack declared it. Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- ...atalog-echo-env-kernel.integration.test.ts | 66 ++++++++++++++++--- .../src/security-catalog-echo.test.ts | 38 ++++++++++- .../src/security-catalog-echo.ts | 55 ++++++++++------ 3 files changed, 129 insertions(+), 30 deletions(-) diff --git a/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts index 9166ac984a1..9213ccaea82 100644 --- a/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts +++ b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts @@ -60,25 +60,37 @@ const LOOSE_SET = 'echo_loose_reader'; /** A position saved into no package, naming the set the stack declares. */ const POSITION = 'echo_position'; const DECLARED_SET = 'echo_declared_reader'; +/** A position the STACK declares (held by the metadata service only), naming the declared set. */ +const STACK_POSITION = 'echo_stack_position'; const stack: any = { manifest: { id: 'com.example.catalog_echo', name: 'catalog_echo', version: '0.0.1', type: 'app' }, objects: [{ name: 'echo_doc', label: 'Echo Doc', fields: { title: { type: 'text', label: 'Title' } } }], permissions: [{ name: DECLARED_SET, label: 'Declared reader', objects: { echo_doc: { allowRead: true } } }], + positions: [{ name: STACK_POSITION, label: 'Stack position', permissionSets: [DECLARED_SET] }], }; +/** + * The same app before it declared {@link STACK_POSITION}. The metadata door + * refuses a save under a name the running stack declares (the item is + * artifact-backed), so the only way a stored row and a stack declaration of + * one position name meet is in this order: the row is stored first, and a + * later deploy of the stack declares the name. + */ +const stackBeforePosition: any = { ...stack, positions: [] }; + type Kind = 'environment' | 'host'; -async function boot(kind: Kind, dbFile: string, rootDir: string): Promise { +async function boot(kind: Kind, dbFile: string, rootDir: string, app: any): Promise { const kernel = new ObjectKernel(); await kernel.use(new ObjectQLPlugin(kind === 'environment' ? { environmentId: ENV, registerProtocol: false } : {}) as any); await kernel.use(new (DefaultDatasourcePlugin as any)({ driver: 'sqlite', config: { filename: dbFile } }, { dev: false })); if (kind === 'environment') await kernel.use(createMetadataProtocolPlugin({ environmentId: ENV }) as any); await kernel.use(new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' }, rootDir }) as any); - await kernel.use(new AppPlugin(stack) as any); + await kernel.use(new AppPlugin(app) as any); await kernel.use(new PlatformObjectsPlugin() as any); await kernel.use(new AuthPlugin({ secret: 'catalog-echo-secret-catalog-echo-secret' }) as any); - await kernel.use(new SecurityPlugin(appSecurityPluginOptions(stack)) as any); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(app)) as any); await kernel.bootstrap(); return kernel; } @@ -99,7 +111,9 @@ async function readAs(ql: any, userId: string, org: string): Promise<'granted' | } } -const USERS = { pkg: 'usr_echo_pkg', loose: 'usr_echo_loose', position: 'usr_echo_position' } as const; +const USERS = { + pkg: 'usr_echo_pkg', loose: 'usr_echo_loose', position: 'usr_echo_position', stackPosition: 'usr_echo_stack_position', +} as const; /** * One kind's scenario: author the two sets and the position through the @@ -111,8 +125,8 @@ function scenario(kind: Kind) { const dbFile = join(dir, 'echo.db'); const state: { kernel?: any; ql?: any; protocol?: any; org?: string; beforeGrant?: string } = {}; - const open = async () => { - state.kernel = await boot(kind, dbFile, dir); + const open = async (app: any = stack) => { + state.kernel = await boot(kind, dbFile, dir, app); state.ql = await state.kernel.getServiceAsync('objectql'); state.protocol = await state.kernel.getServiceAsync('protocol'); const orgs = (await state.ql.find('sys_organization', { where: {} }, SYS)) as any[]; @@ -134,6 +148,16 @@ function scenario(kind: Kind) { await protocol.saveMetaItem({ type: 'position', name: POSITION, item: { name: POSITION, label: 'Echo position', permissionSets: [DECLARED_SET] }, }); + if (kind === 'environment') { + // A metadata-door row under the name the stack declares on the NEXT + // deploy (see `stackBeforePosition`), binding nothing. After that deploy + // the echo must not serve it over the stack's declaration. (A host + // kernel serves the stored row from its registry on every tree; that is + // not this file's question.) + await protocol.saveMetaItem({ + type: 'position', name: STACK_POSITION, item: { name: STACK_POSITION, label: 'Overlay', permissionSets: [] }, + }); + } await ql.insert('echo_doc', { id: 'doc_1', title: 'only a grant reads this', organization_id: org }, SYS); for (const id of Object.values(USERS)) { @@ -144,6 +168,22 @@ function scenario(kind: Kind) { await ql.insert('sys_user_permission_set', { id: 'ups_pkg', user_id: USERS.pkg, permission_set: PKG_SET, organization_id: org }, SYS); await ql.insert('sys_user_permission_set', { id: 'ups_loose', user_id: USERS.loose, permission_set: LOOSE_SET, organization_id: org }, SYS); await ql.insert('sys_user_position', { id: 'upos_1', user_id: USERS.position, position: POSITION, organization_id: org }, SYS); + await ql.insert('sys_user_position', { id: 'upos_2', user_id: USERS.stackPosition, position: STACK_POSITION, organization_id: org }, SYS); + }; + + /** + * The stack's declaration of {@link STACK_POSITION} is what the catalog + * serves, and it grants. Measured on this composition: `AppPlugin` puts + * the declaration in the engine registry too, so the catalog answers + * `source: 'registry'`; the source is not asserted, because a composition + * that declares a stack's positions in the metadata service only is the + * shape `security-catalog-echo.test.ts` pins. + */ + const expectStackPositionDeclared = async () => { + expect(await resolve('position', STACK_POSITION)).toMatchObject({ + definition: { label: 'Stack position', permissionSets: [DECLARED_SET] }, + }); + expect(await readAs(state.ql, USERS.stackPosition, state.org!)).toBe('granted'); }; const resolve = (type: 'permission' | 'position', name: string) => securityCatalogReaderOf(state.ql)!.resolve(type, name); @@ -159,14 +199,14 @@ function scenario(kind: Kind) { expect(uninstall, JSON.stringify(uninstall)).toBeTruthy(); }; - return { dir, dbFile, state, open, close, author, retire, resolve, pendingSuggestions }; + return { dir, dbFile, state, open, close, author, retire, resolve, pendingSuggestions, expectStackPositionDeclared }; } describe('environment kernel: a metadata-door permission set or position is in the security catalog', () => { const s = scenario('environment'); beforeAll(async () => { - await s.open(); + await s.open(stackBeforePosition); await s.author(); }, 180_000); afterAll(async () => { @@ -222,6 +262,9 @@ describe('environment kernel: a metadata-door permission set or position is in t expect(await readAs(s.state.ql, USERS.loose, s.state.org!)).toBe('granted'); expect(await readAs(s.state.ql, USERS.position, s.state.org!)).toBe('granted'); expect(await s.pendingSuggestions()).toHaveLength(1); + // The redeploy declares the stack position the stored row is named after: + // the boot pass does not echo the row over the declaration. + await s.expectStackPositionDeclared(); }, 180_000); it('a deleted set and an uninstalled package\'s set are no longer served, and grant nothing', async () => { @@ -232,6 +275,13 @@ describe('environment kernel: a metadata-door permission set or position is in t expect(await readAs(s.state.ql, USERS.pkg, s.state.org!)).toBe('refused'); // Control: the position, untouched by either, still resolves. expect(await s.resolve('position', POSITION)).toMatchObject({ source: 'metadata' }); + // The stored row under the stack position's name is removed too: the + // declaration is still what is served. + const stored = await s.state.protocol.deleteMetaItem({ type: 'position', name: STACK_POSITION }).then( + () => 'deleted', (e: any) => `refused: ${String(e?.message ?? e).slice(0, 80)}`, + ); + expect(stored).toBe('deleted'); + await s.expectStackPositionDeclared(); }, 60_000); }); diff --git a/packages/plugins/plugin-security/src/security-catalog-echo.test.ts b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts index 8c34e0c1e23..488f7cc6160 100644 --- a/packages/plugins/plugin-security/src/security-catalog-echo.test.ts +++ b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts @@ -389,13 +389,23 @@ describe('the echo follows the overlay: healed, dropped, skipped', () => { expect(metadata.store.has('ops_reader')).toBe(false); expect(items).toEqual([]); - // A real artifact arriving later is what the registry serves; a stale echo is dropped. + // A real artifact arriving later is what the registry serves. The echo + // registered before it is left alone while the stored row exists (the + // service's removal would delete through its loaders); the overlay's + // delete drops it. metadata.registerInMemory('permission', 'ops_reader', { ...body(), _envProjection: true }); items.push({ ...body(), _packageId: 'com.example.ops', _provenance: 'package' }); overlays['permission/ops_reader'] = { body: body() }; + const unregistered: string[] = []; + const unregister = metadata.unregister; + metadata.unregister = (type: string, name: string) => { unregistered.push(`${type}/${name}`); unregister(type, name); }; expect(await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'ops_reader', state: 'active' })).toBe('skipped'); - expect(metadata.store.has('ops_reader')).toBe(false); + expect(unregistered, 'no removal while the stored row exists').toEqual([]); expect(items).toHaveLength(1); + delete overlays['permission/ops_reader']; + expect(await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: 'ops_reader', state: 'deleted' })).toBe('dropped'); + expect(unregistered).toEqual(['permission/ops_reader']); + expect(items, 'the artifact stays').toHaveLength(1); }); it('a delete that leaves no body drops the echo', async () => { @@ -449,6 +459,30 @@ describe('positions are echoed too', () => { expect(metadata.of('position').size).toBe(0); }); + it('a STACK-declared position (held by the metadata service only) is neither shadowed by an overlay of its name nor removed by its delete', async () => { + // The way `AppPlugin` declares a stack's positions: in the metadata + // service, not the engine registry, so the registry check cannot see it. + const declared = position({ permissionSets: [CODE_SET] }); + const metadata = makeMetadataService(); + metadata.registerInMemory('position', 'field_lead', declared); + const overlays: Record = { + 'position/field_lead': { body: position({ permissionSets: ['something_else'] }) }, + }; + const protocol = stubProtocol(overlays); + const deps = { ql: engineDeclaring(), metadata }; + + expect(await echoSecurityCatalogMutation(protocol, deps, { type: 'position', name: 'field_lead', state: 'active' })).toBe('skipped'); + expect(metadata.of('position').get('field_lead')).toBe(declared); + // The boot pass takes the same path. + const engine = { ...engineDeclaring(), find: async () => [{ type: 'position', name: 'field_lead', state: 'active', organization_id: null }] }; + expect(await echoStoredSecurityCatalogOverlays(protocol, { ql: engine, metadata })).toBe(0); + expect(metadata.of('position').get('field_lead')).toBe(declared); + + delete overlays['position/field_lead']; + expect(await echoSecurityCatalogMutation(protocol, deps, { type: 'position', name: 'field_lead', state: 'deleted' })).toBe('skipped'); + expect(metadata.of('position').get('field_lead')).toBe(declared); + }); + it('a position saved through the real environment protocol resolves in the catalog', async () => { const { protocol, catalog } = boot(); await protocol.saveMetaItem({ type: 'position', name: 'field_lead', item: position({ permissionSets: [CODE_SET] }) }); diff --git a/packages/plugins/plugin-security/src/security-catalog-echo.ts b/packages/plugins/plugin-security/src/security-catalog-echo.ts index efaf2e29591..09e70d543a2 100644 --- a/packages/plugins/plugin-security/src/security-catalog-echo.ts +++ b/packages/plugins/plugin-security/src/security-catalog-echo.ts @@ -48,8 +48,10 @@ * client reads "a code package ships this". For a permission set the * verdict is the packaged-permission-set lock's, the classifier both write * doors ask, so the reported and the enforced state are one judgment. For - * a position it always holds, since only a name no artifact declares is - * echoed (below). + * a position it is "no declaration of the name is served": the echo is + * only registered when neither the engine registry nor the metadata + * service holds one (below), and a stored position row is otherwise + * tenant-authored, as a host kernel's hydrator stamps every stored row. * - `_packageId` (#23233): the package the stored row is bound to, as the * protocol's by-name read serves it. The catalog takes an item's owner * from `_packageId` alone, and the suggestion collector skips an unowned @@ -58,13 +60,23 @@ * * ## What is not echoed * - * A name the engine registry already serves: every stored row on a host - * kernel (its metadata door hydrates them there), and a declared artifact on - * any kernel. The catalog reads the registry first, so an echo of those would - * never be read — and a second copy is what a delete's registry heal or a - * package uninstall would strand. When the overlay is gone our echo is - * dropped; a package uninstall deletes each of its rows through - * `deleteMetaItem`, so the same projector drops those. + * - A name the engine registry already serves: every stored row on a host + * kernel (its metadata door hydrates them there), and a package's declared + * artifact on any kernel. The catalog reads the registry first, so an echo + * of those would never be read — and a second copy is what a delete's + * registry heal would strand. + * - A name the metadata service already holds as something other than our + * echo: a STACK-declared position (or set), which `AppPlugin` registers in + * the metadata service only, so the registry check cannot see it. An echo + * would overwrite that declaration with the stored row, and the overlay's + * delete would then remove the declaration until restart. + * + * When the overlay is gone our echo is dropped; a package uninstall deletes + * each of its rows through `deleteMetaItem`, so the same projector drops + * those. Nothing else ever removes a metadata-service entry: the service's + * one removal, `unregister`, also deletes through its writable `datasource:` + * loaders and announces a `deleted` event, so it is called only once the + * stored row is gone. */ import { seedCtx } from './organization-scope.js'; @@ -192,18 +204,21 @@ export async function echoSecurityCatalogMutation( } // The engine registry already serves this name — a host kernel hydrates - // every stored row there, and a declared artifact is there on every kernel — - // so the catalog never reaches the metadata service for it, and an echo - // would only be a second copy for a delete or an uninstall to strand. - if (registryServes(ql, type, name)) { - if (isProjectionEcho(current)) await dropEcho(ql, metadata, type, name); - return 'skipped'; - } + // every stored row there, and a package's artifact is there on every + // kernel — so the catalog never reaches the metadata service for it. Skip, + // and leave an echo of ours (registered before the artifact arrived) in + // place: it is not read, and dropping it would delete through the + // service's writable loaders while the stored row still exists. The + // overlay's delete drops it. + if (registryServes(ql, type, name)) return 'skipped'; + + // The metadata service holds a declaration of this name — a stack-declared + // position lives only there. Never echo over it: see the module doc. + if (current !== undefined && current !== null && !isProjectionEcho(current)) return 'skipped'; - // The registry serves nothing for the name, so no artifact declares it: a - // position here is tenant-authored. A permission set asks the lock, which - // also reads the layered read's `code` layer and answers `unknown` when it - // cannot tell. + // No declaration of the name is served. A permission set asks the lock, + // which also reads the layered read's `code` layer and answers `unknown` + // when it cannot tell; a stored position is tenant-authored. const tenantAuthored = type === 'permission' ? classifyPackagedPermissionSet(name, ql, { status: 'read', envelope }).status === 'org' : true;