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..9213ccaea82 --- /dev/null +++ b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts @@ -0,0 +1,315 @@ +// 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'; +/** 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, 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(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(app)) 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', stackPosition: 'usr_echo_stack_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 (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[]; + 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] }, + }); + 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)) { + 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); + 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); + 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'); + + /** 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, 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(stackBeforePosition); + 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); + // 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 () => { + 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' }); + // 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); +}); + +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'); + }); + + 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/permission-set-echo-package-owner.test.ts b/packages/plugins/plugin-security/src/permission-set-echo-package-owner.test.ts deleted file mode 100644 index 196ebe1fdc9..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-echo-package-owner.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -// 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 - * 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. - * - * ## The topology, and why it is the one under test - * - * A REAL `ObjectStackProtocolImplementation` (aliased to the producer's source - * — see `vitest.config.ts`) over a fake engine, constructed with an - * `environmentId`: an ENVIRONMENT kernel, the shape a hosted tenant runs. On - * that topology the protocol hydrates no stored row into the engine registry - * (`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 - * (`ownerOf`, `@objectstack/core`); the echo used to arrive without one, so - * the set was served unowned and the suggestion collector skipped it. - * - * The publish is the real package door (`publishPackageDrafts`) over a draft - * saved into the runtime package, with no `packageId` in the body — the input - * the card measured on a tenant kernel. The catalog is the real - * `createSecurityCatalogReader`, and the suggestion pass is the real - * `syncAudienceBindingSuggestions`. - */ - -import { describe, it, expect } from 'vitest'; -import { bindSecurityCatalogReader, createSecurityCatalogReader } from '@objectstack/core'; -import { - assertEngineDeleteDispatch, - assertEngineFindOnePredicate, - assertEngineUpdateDispatch, -} from '@objectstack/metadata-core'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { projectPermissionMutation, registerPermissionSetProjection } from './permission-set-projection.js'; -import { syncAudienceBindingSuggestions } from './suggested-audience-bindings.js'; -import { declareEveryoneBaseline } from './builtin-positions.js'; -import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; - -/** A writable runtime package — never installed as code, so nothing ships its sets. */ -const RUNTIME_PKG = 'app.crm_workspace'; -/** The runtime package's default set: `isDefault: true`, no `packageId` in the body. */ -const RUNTIME_SET = 'workspace_member_default'; -/** An installed code package, and the default set its artifact ships. */ -const CODE_PKG = 'com.example.crm'; -const CODE_SET = 'crm_readonly'; -const SECURITY_PACKAGE = 'com.objectstack.plugin-security'; -const DEFAULT_ORG = 'org_default'; - -type Row = Record; - -/** Does `row` satisfy `where`? Equality (null matches absent), `$in`, and `$and`/`$or`. */ -function matches(row: Row, where: Record | undefined): boolean { - return Object.entries(where ?? {}).every(([key, want]) => { - if (key === '$and') return (want as any[]).every((w) => matches(row, w)); - if (key === '$or') return (want as any[]).some((w) => matches(row, w)); - if (want && typeof want === 'object' && !Array.isArray(want)) { - if (Array.isArray(want.$in)) return want.$in.includes(row[key]); - if ('$ne' in want) return (row[key] ?? null) !== want.$ne; - throw new Error(`fake engine: unsupported operator on '${key}': ${Object.keys(want).join(',')}`); - } - return want === null ? (row[key] ?? null) === null : row[key] === want; - }); -} - -/** - * The fake engine, which is also the `ql` the security plugin projects - * through. Its engine registry holds the code package's artifact and the - * positions; on an environment kernel no stored row is hydrated into it, so - * `registered` records anything else that tries to land there. - */ -function makeEngine() { - const tables: Record = { - sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], - }; - const codeArtifacts: Row[] = [ - { name: CODE_SET, label: 'CRM read-only', isDefault: true, objects: {}, _packageId: CODE_PKG, _provenance: 'package' }, - ]; - const positions = new Map(); - const registered: Array<{ type: string; name: unknown }> = []; - const registry = { - registered, - listItems: (type: string) => (type === 'permission' ? codeArtifacts : type === 'position' ? [...positions.values()] : []), - getItem: (type: string, name: string) => registry.listItems(type).find((item) => item.name === name), - getArtifactItem: (type: string, name: string) => - (type === 'permission' ? codeArtifacts.find((item) => item.name === name) : undefined), - isPackageDisabled: () => false, - getPackage: () => undefined, - getAllPackages: () => [], - registerItem: (type: string, item: Row, _key: string, packageId?: string) => { - if (type === 'position') positions.set(String(item.name), { ...item, _packageId: packageId }); - else registered.push({ type, name: item?.name }); - }, - registerObject: () => {}, - unregisterItem: () => {}, - }; - let seq = 0; - const table = (name: string) => (tables[name] ??= []); - const engine = { - tables, - registry, - async find(object: string, query?: { where?: Record; limit?: number }) { - const rows = table(object).filter((row) => matches(row, query?.where)); - return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; - }, - async findOne(object: string, query: { where?: Record }) { - assertEngineFindOnePredicate(object, query); - return table(object).find((row) => matches(row, query?.where)) ?? null; - }, - async insert(object: string, data: Row, options?: { context?: { tenantId?: string } }) { - const row: Row = { id: data.id ?? `${object}_${++seq}`, ...data }; - // A tenant-scoped insert is stamped with the context's organization, as the driver does. - if (object === 'sys_audience_binding_suggestion') row.organization_id = options?.context?.tenantId ?? null; - table(object).push(row); - return { ...row }; - }, - async update(object: string, data: Row, options?: { where?: Record; multi?: boolean }) { - const dispatch = assertEngineUpdateDispatch(data, options); - const { id: _id, ...patch } = data; - const hits = dispatch.kind === 'by-id' - ? table(object).filter((row) => row.id === (data.id ?? options?.where?.id)) - : table(object).filter((row) => matches(row, options?.where)); - for (const row of hits) Object.assign(row, patch); - return hits.length; - }, - async delete(object: string, options?: { where?: Record; multi?: boolean }) { - const dispatch = assertEngineDeleteDispatch(options); - const rows = table(object); - const doomed = dispatch.kind === 'by-id' - ? rows.filter((row) => row.id === options?.where?.id) - : rows.filter((row) => matches(row, options?.where)); - for (const row of doomed) rows.splice(rows.indexOf(row), 1); - return doomed.length; - }, - async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { - return cb(undefined, { owned: true }); - }, - }; - return engine; -} - -/** The kernel `metadata` service as the projection and the catalog use it: registry-first, in memory. */ -function makeMetadataService() { - const store = new Map(); - 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); }, - }; -} - -/** One environment kernel: the protocol, the projection wired as the security plugin wires it, the catalog bound. */ -function boot() { - const engine = makeEngine(); - const metadata = makeMetadataService(); - const warnings: Array<{ message: string; meta?: Record }> = []; - const logger = { warn: (message: string, meta?: Record) => { warnings.push({ message, meta }); } }; - const protocol = new ObjectStackProtocolImplementation( - engine as never, - () => new Map([['metadata', metadata]]), - 'env_tenant', // an ENVIRONMENT kernel — see the module doc - ) as any; - expect(registerPermissionSetProjection(protocol, { ql: engine, metadata, logger })).toBe(true); - const catalog = createSecurityCatalogReader({ registry: engine.registry, metadata }); - bindSecurityCatalogReader(engine, catalog); - declareEveryoneBaseline(engine.registry, SECURITY_PACKAGE, ['member_default']); - return { engine, metadata, protocol, catalog, warnings }; -} - -/** Publish `body` into `packageId` through the package door: a draft, then the batch publish. */ -async function publishThroughPackageDoor(protocol: any, packageId: string, body: Row) { - await protocol.saveMetaItem({ type: 'permission', name: body.name, item: body, packageId, mode: 'draft' }); - const published = await protocol.publishPackageDrafts({ packageId, actor: 'system' }); - expect(published, JSON.stringify(published)).toMatchObject({ outcome: 'published', failed: [] }); -} - -const suggestionRows = (engine: ReturnType, set: string) => - (engine.tables.sys_audience_binding_suggestion ?? []).filter((row) => row.permission_set_name === set); - -const runtimeDefault = () => ({ name: RUNTIME_SET, label: 'Workspace member', isDefault: true, objects: {} }); - -describe('#23233 — a runtime package\'s isDefault set, published without a body packageId', () => { - it('records ONE pending everyone suggestion, under the package the publish door bound it to', async () => { - const { engine, protocol } = boot(); - const body = runtimeDefault(); - expect(body, 'precondition: the body names no package').not.toHaveProperty('packageId'); - - await publishThroughPackageDoor(protocol, RUNTIME_PKG, body); - // Anti-vacuity: the topology under test — the engine registry holds no - // stored row, so the catalog can only find the set in the metadata service. - expect(engine.registry.getItem('permission', RUNTIME_SET)).toBeUndefined(); - expect(engine.registry.registered.filter((r) => r.name === RUNTIME_SET)).toEqual([]); - - await syncAudienceBindingSuggestions(engine); - - expect(suggestionRows(engine, RUNTIME_SET)).toEqual([ - expect.objectContaining({ - package_id: RUNTIME_PKG, anchor: 'everyone', status: 'pending', deployment_decision: false, - organization_id: DEFAULT_ORG, - }), - ]); - }); - - it('the security catalog and the protocol\'s by-name read name the same owner', async () => { - const { protocol, catalog } = boot(); - await publishThroughPackageDoor(protocol, RUNTIME_PKG, runtimeDefault()); - - const served = await protocol.getMetaItem({ type: 'permission', name: RUNTIME_SET }); - const resolved = await catalog.resolve('permission', RUNTIME_SET); - const listed = (await catalog.list('permission')).find((entry) => entry.name === RUNTIME_SET); - - expect(served.item._packageId).toBe(RUNTIME_PKG); - // The catalog read the echo (anti-vacuity), and the echo names the owner. - expect(resolved).toMatchObject({ source: 'metadata', packageId: RUNTIME_PKG }); - expect(listed).toMatchObject({ source: 'metadata', packageId: RUNTIME_PKG }); - expect(resolved?.definition).toMatchObject({ _provenance: 'org', isDefault: true }); - }); -}); - -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`. - 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()); - - const layered = await protocol.getMetaItemLayered({ type: 'permission', name: RUNTIME_SET }); - // Anti-vacuity: the layered read's code layer IS the echo, and it carries the owner. - expect(layered.code).toMatchObject({ _packageId: RUNTIME_PKG, _envProjection: true }); - expect(classifyPackagedPermissionSet(RUNTIME_SET, engine, { status: 'read', envelope: layered })).toEqual({ status: 'org' }); - expect(layered).toMatchObject({ lock: 'none', editable: true, deletable: true }); - - await protocol.saveMetaItem({ - 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(); - }); -}); - -describe('#23233 — controls: the owner is carried, never invented', () => { - it('a code package\'s default set is unchanged: the catalog serves its artifact, its owner and its suggestion', async () => { - const { engine, metadata, catalog } = boot(); - - await syncAudienceBindingSuggestions(engine); - - expect(suggestionRows(engine, CODE_SET)).toEqual([ - 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. - expect(metadata.store.has(CODE_SET)).toBe(false); - }); - - it('a set saved with no package stays unowned — no binding, no owner, no suggestion (ADR-0086 D3)', async () => { - const { engine, protocol, catalog } = boot(); - await protocol.saveMetaItem({ type: 'permission', name: 'org_member_default', item: { ...runtimeDefault(), name: 'org_member_default' } }); - - expect((await protocol.getMetaItem({ type: 'permission', name: 'org_member_default' })).item._packageId).toBeUndefined(); - const resolved = await catalog.resolve('permission', 'org_member_default'); - expect(resolved).toMatchObject({ source: 'metadata' }); - expect(resolved).not.toHaveProperty('packageId'); - - await syncAudienceBindingSuggestions(engine); - expect(suggestionRows(engine, 'org_member_default')).toEqual([]); - }); - - it('a binding read that fails leaves the echo registered and unowned, as before, and says so once', async () => { - const { engine, metadata, warnings } = boot(); - // The layered read answers an overlay; the by-name read is refused. - const protocol = { - getMetaItemLayered: async () => ({ type: 'permission', name: RUNTIME_SET, code: null, overlay: runtimeDefault(), effective: runtimeDefault() }), - 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 }) } }, { - type: 'permission', name: RUNTIME_SET, state: 'active', - }); - - expect(out?.seeded).toBe(1); - 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); - }); -}); 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/security-catalog-echo.test.ts b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts new file mode 100644 index 00000000000..488f7cc6160 --- /dev/null +++ b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts @@ -0,0 +1,531 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * 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. + * + * ## The topology, and why it is the one under test + * + * A REAL `ObjectStackProtocolImplementation` (aliased to the producer's source + * — see `vitest.config.ts`) over a fake engine, constructed with an + * `environmentId`: an ENVIRONMENT kernel, the shape a hosted tenant runs. On + * that topology the protocol hydrates no stored row into the engine registry + * (`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 + * 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. + * + * The publish is the real package door (`publishPackageDrafts`) over a draft + * saved into the runtime package, with no `packageId` in the body — the input + * the card measured on a tenant kernel. The catalog is the real + * `createSecurityCatalogReader`, and the suggestion pass is the real + * `syncAudienceBindingSuggestions`. + */ + +import { describe, it, expect } from 'vitest'; +import { bindSecurityCatalogReader, createSecurityCatalogReader } from '@objectstack/core'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, +} from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +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'; + +/** A writable runtime package — never installed as code, so nothing ships its sets. */ +const RUNTIME_PKG = 'app.crm_workspace'; +/** The runtime package's default set: `isDefault: true`, no `packageId` in the body. */ +const RUNTIME_SET = 'workspace_member_default'; +/** An installed code package, and the default set its artifact ships. */ +const CODE_PKG = 'com.example.crm'; +const CODE_SET = 'crm_readonly'; +const SECURITY_PACKAGE = 'com.objectstack.plugin-security'; +const DEFAULT_ORG = 'org_default'; + +type Row = Record; + +/** Does `row` satisfy `where`? Equality (null matches absent), `$in`, and `$and`/`$or`. */ +function matches(row: Row, where: Record | undefined): boolean { + return Object.entries(where ?? {}).every(([key, want]) => { + if (key === '$and') return (want as any[]).every((w) => matches(row, w)); + if (key === '$or') return (want as any[]).some((w) => matches(row, w)); + if (want && typeof want === 'object' && !Array.isArray(want)) { + if (Array.isArray(want.$in)) return want.$in.includes(row[key]); + if ('$ne' in want) return (row[key] ?? null) !== want.$ne; + throw new Error(`fake engine: unsupported operator on '${key}': ${Object.keys(want).join(',')}`); + } + return want === null ? (row[key] ?? null) === null : row[key] === want; + }); +} + +/** + * The fake engine, which is also the `ql` the security plugin projects + * through. Its engine registry holds the code package's artifact and the + * positions; on an environment kernel no stored row is hydrated into it, so + * `registered` records anything else that tries to land there. + */ +function makeEngine() { + const tables: Record = { + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], + }; + const codeArtifacts: Row[] = [ + { name: CODE_SET, label: 'CRM read-only', isDefault: true, objects: {}, _packageId: CODE_PKG, _provenance: 'package' }, + ]; + const positions = new Map(); + const registered: Array<{ type: string; name: unknown }> = []; + const registry = { + registered, + listItems: (type: string) => (type === 'permission' ? codeArtifacts : type === 'position' ? [...positions.values()] : []), + getItem: (type: string, name: string) => registry.listItems(type).find((item) => item.name === name), + getArtifactItem: (type: string, name: string) => + (type === 'permission' ? codeArtifacts.find((item) => item.name === name) : undefined), + isPackageDisabled: () => false, + getPackage: () => undefined, + getAllPackages: () => [], + registerItem: (type: string, item: Row, _key: string, packageId?: string) => { + if (type === 'position') positions.set(String(item.name), { ...item, _packageId: packageId }); + else registered.push({ type, name: item?.name }); + }, + registerObject: () => {}, + unregisterItem: () => {}, + }; + let seq = 0; + const table = (name: string) => (tables[name] ??= []); + const engine = { + tables, + registry, + async find(object: string, query?: { where?: Record; limit?: number }) { + const rows = table(object).filter((row) => matches(row, query?.where)); + return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; + }, + async findOne(object: string, query: { where?: Record }) { + assertEngineFindOnePredicate(object, query); + return table(object).find((row) => matches(row, query?.where)) ?? null; + }, + async insert(object: string, data: Row, options?: { context?: { tenantId?: string } }) { + const row: Row = { id: data.id ?? `${object}_${++seq}`, ...data }; + // A tenant-scoped insert is stamped with the context's organization, as the driver does. + if (object === 'sys_audience_binding_suggestion') row.organization_id = options?.context?.tenantId ?? null; + table(object).push(row); + return { ...row }; + }, + async update(object: string, data: Row, options?: { where?: Record; multi?: boolean }) { + const dispatch = assertEngineUpdateDispatch(data, options); + const { id: _id, ...patch } = data; + const hits = dispatch.kind === 'by-id' + ? table(object).filter((row) => row.id === (data.id ?? options?.where?.id)) + : table(object).filter((row) => matches(row, options?.where)); + for (const row of hits) Object.assign(row, patch); + return hits.length; + }, + async delete(object: string, options?: { where?: Record; multi?: boolean }) { + const dispatch = assertEngineDeleteDispatch(options); + const rows = table(object); + const doomed = dispatch.kind === 'by-id' + ? rows.filter((row) => row.id === options?.where?.id) + : rows.filter((row) => matches(row, options?.where)); + for (const row of doomed) rows.splice(rows.indexOf(row), 1); + return doomed.length; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + }; + return engine; +} + +/** The kernel `metadata` service as the echo and the catalog use it: registry-first, in memory, keyed by type. */ +function makeMetadataService() { + const byType = new Map>(); + const of = (type: string) => byType.get(type) ?? byType.set(type, new Map()).get(type)!; + return { + /** 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 echo wired as the security plugin wires it, the catalog bound. */ +function boot() { + const engine = makeEngine(); + const metadata = makeMetadataService(); + const warnings: Array<{ message: string; meta?: Record }> = []; + const logger = { warn: (message: string, meta?: Record) => { warnings.push({ message, meta }); } }; + const protocol = new ObjectStackProtocolImplementation( + engine as never, + () => new Map([['metadata', metadata]]), + 'env_tenant', // an ENVIRONMENT kernel — see the module doc + ) as any; + 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']); + return { engine, metadata, protocol, catalog, warnings }; +} + +/** Publish `body` into `packageId` through the package door: a draft, then the batch publish. */ +async function publishThroughPackageDoor(protocol: any, packageId: string, body: Row) { + await protocol.saveMetaItem({ type: 'permission', name: body.name, item: body, packageId, mode: 'draft' }); + const published = await protocol.publishPackageDrafts({ packageId, actor: 'system' }); + expect(published, JSON.stringify(published)).toMatchObject({ outcome: 'published', failed: [] }); +} + +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', () => { + it('records ONE pending everyone suggestion, under the package the publish door bound it to', async () => { + const { engine, protocol } = boot(); + const body = runtimeDefault(); + expect(body, 'precondition: the body names no package').not.toHaveProperty('packageId'); + + await publishThroughPackageDoor(protocol, RUNTIME_PKG, body); + // Anti-vacuity: the topology under test — the engine registry holds no + // stored row, so the catalog can only find the set in the metadata service. + expect(engine.registry.getItem('permission', RUNTIME_SET)).toBeUndefined(); + expect(engine.registry.registered.filter((r) => r.name === RUNTIME_SET)).toEqual([]); + + await syncAudienceBindingSuggestions(engine); + + expect(suggestionRows(engine, RUNTIME_SET)).toEqual([ + expect.objectContaining({ + package_id: RUNTIME_PKG, anchor: 'everyone', status: 'pending', deployment_decision: false, + organization_id: DEFAULT_ORG, + }), + ]); + }); + + it('the security catalog and the protocol\'s by-name read name the same owner', async () => { + const { protocol, catalog } = boot(); + await publishThroughPackageDoor(protocol, RUNTIME_PKG, runtimeDefault()); + + const served = await protocol.getMetaItem({ type: 'permission', name: RUNTIME_SET }); + const resolved = await catalog.resolve('permission', RUNTIME_SET); + const listed = (await catalog.list('permission')).find((entry) => entry.name === RUNTIME_SET); + + expect(served.item._packageId).toBe(RUNTIME_PKG); + // The catalog read the echo (anti-vacuity), and the echo names the owner. + expect(resolved).toMatchObject({ source: 'metadata', packageId: RUNTIME_PKG }); + expect(listed).toMatchObject({ source: 'metadata', packageId: RUNTIME_PKG }); + expect(resolved?.definition).toMatchObject({ _provenance: 'org', isDefault: true }); + }); +}); + +describe('#23233 — the edit surface does not move', () => { + // 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()); + + const layered = await protocol.getMetaItemLayered({ type: 'permission', name: RUNTIME_SET }); + // Anti-vacuity: the layered read's code layer IS the echo, and it carries the owner. + expect(layered.code).toMatchObject({ _packageId: RUNTIME_PKG, _envProjection: true }); + expect(classifyPackagedPermissionSet(RUNTIME_SET, engine, { status: 'read', envelope: layered })).toEqual({ status: 'org' }); + expect(layered).toMatchObject({ lock: 'none', editable: true, deletable: true }); + + await protocol.saveMetaItem({ + 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 }); + // 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([]); + }); +}); + +describe('#23233 — controls: the owner is carried, never invented', () => { + it('a code package\'s default set is unchanged: the catalog serves its artifact, its owner and its suggestion', async () => { + const { engine, metadata, catalog } = boot(); + + await syncAudienceBindingSuggestions(engine); + + expect(suggestionRows(engine, CODE_SET)).toEqual([ + 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 no echo of it was registered at all. + expect(metadata.store.has(CODE_SET)).toBe(false); + }); + + it('a set saved with no package stays unowned — no binding, no owner, no suggestion (ADR-0086 D3)', async () => { + const { engine, protocol, catalog } = boot(); + await protocol.saveMetaItem({ type: 'permission', name: 'org_member_default', item: { ...runtimeDefault(), name: 'org_member_default' } }); + + expect((await protocol.getMetaItem({ type: 'permission', name: 'org_member_default' })).item._packageId).toBeUndefined(); + const resolved = await catalog.resolve('permission', 'org_member_default'); + expect(resolved).toMatchObject({ source: 'metadata' }); + expect(resolved).not.toHaveProperty('packageId'); + + await syncAudienceBindingSuggestions(engine); + expect(suggestionRows(engine, 'org_member_default')).toEqual([]); + }); + + it('a binding read that fails leaves the echo registered and unowned, as before, and says so once', async () => { + const { engine, metadata, warnings } = boot(); + // The layered read answers an overlay; the by-name read is refused. + const protocol = { + getMetaItemLayered: async () => ({ type: 'permission', name: RUNTIME_SET, code: null, overlay: runtimeDefault(), effective: runtimeDefault() }), + getMetaItem: async () => { throw new Error('metadata store unavailable'); }, + }; + + 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).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 () => { + // An environment kernel: the registry holds no stored row. + const engine = engineDeclaring(); + 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('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(), packageId: 'app.crm_workspace' }, + 'permission/crm_readonly': { body: body({ name: 'crm_readonly', systemPermissions: ['overlaid'] }) }, + }); + + 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('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() } }; + 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('dropped'); + expect(metadata.store.has('ops_reader')).toBe(false); + expect(items).toEqual([]); + + // 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(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 () => { + 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: 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 }); + 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 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] }) }); + 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; 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) { + 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..09e70d543a2 --- /dev/null +++ b/packages/plugins/plugin-security/src/security-catalog-echo.ts @@ -0,0 +1,317 @@ +// 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 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 + * `isDefault: true` set (ADR-0090 D5: packages suggest, admins confirm). + * The owner is carried, never derived. + * + * ## 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 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'; +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; +} + +/** + * 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' | '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 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; + } + } + + const current = typeof metadata.get === 'function' ? await metadata.get(type, name) : undefined; + if (!overlay?.name) { + // 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'; + 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 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'; + + // 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; + 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 32d05057f48..bd27dd10254 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, @@ -4360,8 +4361,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 @@ -4373,6 +4375,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 @@ -4457,9 +4464,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). diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 145af6a9994..41ab95df765 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3396,21 +3396,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", @@ -3466,6 +3451,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",