diff --git a/.changeset/15196-core-security-catalog-read.md b/.changeset/15196-core-security-catalog-read.md new file mode 100644 index 00000000000..64c69aafb9a --- /dev/null +++ b/.changeset/15196-core-security-catalog-read.md @@ -0,0 +1,14 @@ +--- +"@objectstack/core": minor +--- + +feat(core): one by-name read of the security catalog (`createSecurityCatalogReader`) + +Clause-②: yes (widening) + +- **What is new.** `createSecurityCatalogReader({ registry, metadata })` returns a reader with two members: `resolve(type, name)`, the definition a position, permission set or capability name resolves to (or `undefined`), and `list(type)`, one entry per name. `type` is `'position' | 'permission' | 'capability'`. Each entry is `{ type, name, definition, source, packageId? }`. The types `SecurityCatalogType`, `SecurityCatalogSourceName`, `SecurityCatalogRegistry`, `SecurityCatalogMetadataService`, `SecurityCatalogSources`, `SecurityCatalogEntry` and `SecurityCatalogReader` are exported with it. +- **Where it reads.** ObjectQL's `SchemaRegistry` (`engine.registry`) first, then the kernel `metadata` service for the names the registry does not hold. Neither holds the whole catalog: the engine registry carries the platform's own permission sets and every package manifest's catalog items but no stack-declared position, and the metadata service carries the stack-declared positions but not the platform's permission sets. Both are required; construction refuses a missing one. +- **A name two packages ship** resolves the way the registry's by-name read does today: a stored override first, else the first-registered package's body. +- **What it does not answer.** Whether an item is in effect: the row `active` flag stays the authority, and no definition carries it. The position → permission-set binding. Organization scope: the catalog is environment-level. +- **Failures are loud.** A reader that throws, or a metadata read that lost a loader and found nothing, raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) instead of answering "no such item". A definition owned by a disabled package answers neither member. +- Nothing calls the reader yet; no grant changes. diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 5cf405b01f2..07851fdeec2 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -219,6 +219,21 @@ export { isGrantActive, isGrantExpired, type GrantValidityWindow } from './grant // enforces it and the break-glass guard that simulates a write to it. export { isRowActive, type ActivatableRow } from './row-active.js'; +// ADR-0131 D2–D4 — the ONE by-name read of the security catalog (positions, +// permission sets, capabilities) over the engine registry and the metadata +// service. It says a definition EXISTS under a name, never that it is in +// effect: the row `active` flag above stays the authority for that. +export { + createSecurityCatalogReader, + type SecurityCatalogType, + type SecurityCatalogSourceName, + type SecurityCatalogRegistry, + type SecurityCatalogMetadataService, + type SecurityCatalogSources, + type SecurityCatalogEntry, + type SecurityCatalogReader, +} from './security-catalog.js'; + // [commit f8eb73601] The measured read surface of the administrator derivation — the // single source `plugin-auth`'s break-glass standing-key lists correspond to. export { diff --git a/packages/core/src/security/security-catalog.test.ts b/packages/core/src/security/security-catalog.test.ts new file mode 100644 index 00000000000..716b794eb0c --- /dev/null +++ b/packages/core/src/security/security-catalog.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + createSecurityCatalogReader, + type SecurityCatalogMetadataService, + type SecurityCatalogRegistry, +} from './security-catalog.js'; +import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js'; + +/** + * ADR-0131 D2–D4 — the catalog read's own rules, over stand-in readers. + * + * What the real readers answer (the registry's by-name precedence for a name + * two packages ship, the sets a booted showcase holds) is pinned against the + * real readers elsewhere: `packages/objectql/src/security-catalog-shared-name.test.ts` + * and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`. + * Here: the read order, the union, the disabled-package rule, and that a read + * which did not happen is never reported as "no such item". + */ + +type Def = Record; + +interface RegistryOptions { + disabled?: readonly string[]; + getItem?: (type: string, name: string) => unknown; + listItems?: (type: string) => readonly unknown[]; +} + +/** First-registered-wins by name, and a list that hides disabled packages — the registry's own two rules. */ +function registry(items: Record, opts: RegistryOptions = {}): SecurityCatalogRegistry { + const disabled = new Set(opts.disabled ?? []); + const isPackageDisabled = (pkg?: string) => (pkg ? disabled.has(pkg) : false); + return { + getItem: opts.getItem ?? ((type, name) => (items[type] ?? []).find((d) => d.name === name)), + listItems: + opts.listItems + ?? ((type) => (items[type] ?? []).filter((d) => !isPackageDisabled(d._packageId as string | undefined))), + isPackageDisabled, + }; +} + +function metadata(items: Record, over: Partial = {}): SecurityCatalogMetadataService { + return { + async get(type, name) { + return (items[type] ?? []).find((d) => d.name === name); + }, + async list(type) { + return items[type] ?? []; + }, + ...over, + }; +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + throw new Error('expected the read to reject, and it resolved'); +} + +describe('security catalog read — the read order and the union', () => { + it('a name both readers hold is answered by the engine registry', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ permission: [{ name: 'sales', label: 'from the registry', _packageId: 'com.acme' }] }), + metadata: metadata({ permission: [{ name: 'sales', label: 'from the metadata service' }] }), + }); + const entry = await reader.resolve('permission', 'sales'); + expect(entry?.source).toBe('registry'); + expect(entry?.definition.label).toBe('from the registry'); + expect(entry?.packageId).toBe('com.acme'); + }); + + it('a name only the metadata service holds is answered by it (stack-declared positions)', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({}), + metadata: metadata({ position: [{ name: 'manager', label: 'Manager' }] }), + }); + const entry = await reader.resolve('position', 'manager'); + expect(entry).toMatchObject({ type: 'position', name: 'manager', source: 'metadata' }); + expect(entry?.packageId).toBeUndefined(); + }); + + it('a name neither reader holds resolves to nothing and is not listed', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ capability: [{ name: 'export_data' }] }), + metadata: metadata({ capability: [{ name: 'export_data' }] }), + }); + expect(await reader.resolve('capability', 'nobody_declared_this')).toBeUndefined(); + expect((await reader.list('capability')).map((e) => e.name)).toEqual(['export_data']); + }); + + it('list gives one entry per name across both readers, each the entry resolve answers', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ + permission: [ + { name: 'admin_full_access', _packageId: 'com.objectstack.plugin-security' }, + { name: 'shared', label: 'registry body', _packageId: 'com.acme' }, + ], + }), + metadata: metadata({ + permission: [ + { name: 'shared', label: 'metadata body', _packageId: 'com.acme' }, + { name: 'declared_only', label: 'metadata only' }, + ], + }), + }); + const listed = await reader.list('permission'); + expect(listed.map((e) => [e.name, e.source])).toEqual([ + ['admin_full_access', 'registry'], + ['shared', 'registry'], + ['declared_only', 'metadata'], + ]); + for (const entry of listed) { + expect(await reader.resolve('permission', entry.name)).toEqual(entry); + } + }); + + it('an entry states existence only: no activation member, whatever the definition carries', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ permission: [{ name: 'sales', _packageId: 'com.acme' }] }), + metadata: metadata({}), + }); + const entry = await reader.resolve('permission', 'sales'); + expect(Object.keys(entry ?? {}).sort()).toEqual(['definition', 'name', 'packageId', 'source', 'type']); + }); +}); + +describe('security catalog read — a disabled package\'s definition answers neither read', () => { + it('the registry\'s list rule holds for the by-name read too', async () => { + const reader = createSecurityCatalogReader({ + registry: registry( + { permission: [{ name: 'vendor_set', _packageId: 'com.vendor' }, { name: 'kept', _packageId: 'com.acme' }] }, + { disabled: ['com.vendor'] }, + ), + metadata: metadata({}), + }); + expect(await reader.resolve('permission', 'vendor_set')).toBeUndefined(); + expect((await reader.list('permission')).map((e) => e.name)).toEqual(['kept']); + }); + + it('a metadata-service copy owned by the disabled package is not served either', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ permission: [{ name: 'vendor_set', _packageId: 'com.vendor' }] }, { disabled: ['com.vendor'] }), + metadata: metadata({ permission: [{ name: 'vendor_set', _packageId: 'com.vendor' }] }), + }); + expect(await reader.resolve('permission', 'vendor_set')).toBeUndefined(); + expect(await reader.list('permission')).toEqual([]); + }); +}); + +describe('security catalog read — a read that did not happen is loud, never "no such item"', () => { + const expectUnavailable = (error: unknown) => { + expect(isAuthzStoreUnavailableError(error)).toBe(true); + expect((error as { code?: unknown }).code).toBe('SERVICE_UNAVAILABLE'); + expect((error as { status?: unknown }).status).toBe(503); + }; + + it('a registry read that throws', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({}, { + getItem: () => { throw new Error('registry torn'); }, + listItems: () => { throw new Error('registry torn'); }, + }), + metadata: metadata({}), + }); + expectUnavailable(await rejection(reader.resolve('permission', 'admin_full_access'))); + expectUnavailable(await rejection(reader.list('permission'))); + }); + + it('a metadata read that throws', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({}), + metadata: metadata({}, { + get: async () => { throw new Error('loader down'); }, + list: async () => { throw new Error('loader down'); }, + }), + }); + expectUnavailable(await rejection(reader.resolve('position', 'manager'))); + expectUnavailable(await rejection(reader.list('position'))); + }); + + it('a metadata read that lost a loader and found nothing', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({}), + metadata: metadata({}, { + getDiagnosed: async () => ({ data: undefined, degraded: true, errors: ['db loader: connection refused'] }), + listDiagnosed: async () => ({ items: [{ name: 'manager' }], degraded: true, errors: ['db loader: connection refused'] }), + }), + }); + expectUnavailable(await rejection(reader.resolve('position', 'manager'))); + // A list that lost a loader is partial even when another loader answered. + expectUnavailable(await rejection(reader.list('position'))); + }); + + it('control: a clean miss is a miss, and a degraded read that found the item serves it', async () => { + const clean = createSecurityCatalogReader({ + registry: registry({}), + metadata: metadata({}, { getDiagnosed: async () => ({ data: undefined, degraded: false, errors: [] }) }), + }); + expect(await clean.resolve('position', 'manager')).toBeUndefined(); + + const found = createSecurityCatalogReader({ + registry: registry({}), + metadata: metadata({}, { getDiagnosed: async () => ({ data: { name: 'manager' }, degraded: true, errors: ['x'] }) }), + }); + expect((await found.resolve('position', 'manager'))?.name).toBe('manager'); + }); +}); + +describe('security catalog read — refusals at the edge', () => { + it('construction refuses a missing reader, naming what that reader holds', () => { + const md = metadata({}); + expect(() => createSecurityCatalogReader({ registry: undefined as never, metadata: md })).toThrow(TypeError); + expect(() => createSecurityCatalogReader({ registry: registry({}), metadata: undefined as never })).toThrow( + TypeError, + ); + expect(() => createSecurityCatalogReader({ registry: registry({}), metadata: { get: md.get } as never })).toThrow( + TypeError, + ); + }); + + it('a type outside the catalog is refused, not answered empty', async () => { + const reader = createSecurityCatalogReader({ registry: registry({}), metadata: metadata({}) }); + await expect(reader.resolve('view' as never, 'x')).rejects.toBeInstanceOf(TypeError); + await expect(reader.list('object' as never)).rejects.toBeInstanceOf(TypeError); + }); + + it('an empty or non-string name names nothing', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ permission: [{ name: 'sales' }] }), + metadata: metadata({}), + }); + expect(await reader.resolve('permission', '')).toBeUndefined(); + expect(await reader.resolve('permission', undefined as never)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/security/security-catalog.ts b/packages/core/src/security/security-catalog.ts new file mode 100644 index 00000000000..72246cf6226 --- /dev/null +++ b/packages/core/src/security/security-catalog.ts @@ -0,0 +1,359 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0131 D2–D4 — the ONE read of the security catalog: positions, permission + * sets and capabilities, looked up BY NAME in the environment registry. + * + * ## What this is, and what it is not yet + * + * ADR-0131 D3 gives the catalog one home — the environment registry, fed by + * code-declared packages and by environment metadata a metadata author saved + * (`sys_metadata`, hydrated into the same registry by `loadMetaFromDb`) — and + * D4 makes every reference to a catalog item a NAME that "resolution reads the + * registry" for. This module is that read and nothing else. It is the seam the + * later stages of the C2 execution card switch readers onto; on its own it + * changes no grant, because nothing calls it yet. + * + * ## Two in-process readers, read in a fixed order + * + * No single in-process reader holds the whole catalog today. Measured on a + * booted showcase in three postures (`single`, `single` with the Default + * Organization, and walled), at objectstack `3d9188502e`, names per reader: + * + * | type | engine registry | metadata service | metadata door | + * |--------------|-----------------|------------------|---------------| + * | `position` | 0 | 10 | 10 | + * | `permission` | 17 | 9 | 17 | + * | `capability` | 2 | 2 | 2 | + * + * - The ENGINE REGISTRY (ObjectQL's `SchemaRegistry`) carries every package + * manifest's `permissions` and `capabilities` — the platform's bootstrap + * permission sets among them (`admin_full_access`, `member_default`, …, + * which `plugin-security` ships on its own manifest) — and every + * environment-authored item hydrated from `sys_metadata`. It carries no + * stack-declared position: the engine's stack-collection list does not + * decompose `positions`. + * - The METADATA SERVICE carries the stack-declared security collections an + * app registers in memory (`positions`, `permissions`, `capabilities`) plus + * whatever its loaders hold — but not the platform's bootstrap permission + * sets. + * - The metadata DOOR (`GET /api/v1/meta/:type`) merges both with the stored + * rows, and its names equal the union of the first two in every posture + * measured. It is a serving surface (decorations, per-request row reads), + * not something an in-process resolver reads. + * + * So the reader is the union, in ONE order: the engine registry first, then the + * metadata service ({@link SECURITY_CATALOG_READ_ORDER}). A name the registry + * answers is answered by the registry — which is also where an ADR-0005 + * overlay of a packaged item lives — and the metadata service answers only the + * names the registry does not hold. ⛔ Never `metadataService.list` alone: it + * misses the platform's own permission sets, so a resolver reading it would + * fail the platform administrator anchor closed. + * + * ## A name two packages ship — today's answer, pinned until it is ruled + * + * The by-name read takes no package context, because an assignment carries + * only the name (ADR-0131 D4). So which body a name two installed packages both + * ship resolves to is decided by the registry's own by-name precedence: a + * stored override in the bare slot first (ADR-0005), else the FIRST-registered + * package's body. Measured: with no override every by-name read answers the + * first-registered package; once one package stores an override bound to + * itself, every by-name read answers that override. Whether the catalog should + * refuse a shared name instead is an open maintainer question; until it is + * ruled, the pins beside this module hold today's answer, and a change to it + * is a decision, not a refactor. + * + * ## What this read does NOT answer + * + * - ⛔ **Whether an item is in effect.** Deactivation lives on the catalog + * ROW (`sys_position.active`, `sys_permission_set.active`; standing ruling: + * the row flag stays authoritative), and no definition carries it. An entry + * here says a definition EXISTS under that name — never that it grants. A + * caller that drops the row flag because it now reads definitions would let + * a deactivated set grant again: the one widening this card must never + * open. Read the flag with `isRowActive` (`row-active.ts`) as before. + * - **The position → permission-set binding.** It stays on its junction rows + * until the position definition carries it. + * - **Organization scope.** The catalog is environment-level (ADR-0131 D3); + * this read takes no organization and never filters by one. + * + * ## The one rule it does apply: a disabled package's item is not served + * + * The registry hides every item of a disabled package from its list + * (`listItems`), and the by-name read applies the same rule, so the two reads + * of this module cannot disagree about whether a name exists: a definition + * whose `_packageId` names a disabled package answers neither. An item that + * names no package (an environment-authored row, a position registered in + * memory without an owner) has no package to be disabled. + * + * ## A read that did not happen is loud + * + * A miss and an outage are different facts with opposite security meanings + * (ADR-0110 D3). When a reader throws, or the metadata service reports a read + * that lost a loader and nothing answered, this module raises + * {@link AuthzStoreUnavailableError} instead of answering "no such item" — the + * same loud failure the resolver raises for an unreadable permission store. + */ + +import { AuthzStoreUnavailableError } from './authz-store-unavailable.js'; + +/** The three catalog types ADR-0131 D3 gives one home. */ +const SECURITY_CATALOG_TYPES = ['position', 'permission', 'capability'] as const; + +/** A security catalog type, spelled as the registry keys it. */ +export type SecurityCatalogType = (typeof SECURITY_CATALOG_TYPES)[number]; + +/** The readers, in the order a by-name read asks them (module doc). */ +const SECURITY_CATALOG_READ_ORDER = ['registry', 'metadata'] as const; + +/** Which reader answered for an entry. */ +export type SecurityCatalogSourceName = (typeof SECURITY_CATALOG_READ_ORDER)[number]; + +/** + * The engine registry, as this read uses it — ObjectQL's `SchemaRegistry` + * (`engine.registry`) satisfies it as it stands. + */ +export interface SecurityCatalogRegistry { + /** The registry's by-name read: bare-slot overlay first, else the first-registered package's item. */ + getItem(type: string, name: string): unknown; + /** Every item of a type, a disabled package's items already hidden. */ + listItems(type: string): readonly unknown[]; + /** Whether the package an item names as its owner is disabled. */ + isPackageDisabled(packageId?: string): boolean; +} + +/** + * The kernel `metadata` service, as this read uses it — the `MetadataManager` + * satisfies it as it stands. The two `*Diagnosed` members are optional; when + * present they are what lets a degraded read be told apart from a miss. + */ +export interface SecurityCatalogMetadataService { + get(type: string, name: string): unknown; + list(type: string): unknown; + getDiagnosed?(type: string, name: string): Promise<{ data?: unknown; degraded?: boolean; errors?: readonly string[] }>; + listDiagnosed?(type: string): Promise<{ items?: readonly unknown[]; degraded?: boolean; errors?: readonly string[] }>; +} + +/** Where the catalog read looks. Both readers are required: neither holds the whole catalog. */ +export interface SecurityCatalogSources { + /** ObjectQL's `SchemaRegistry` — `engine.registry`. */ + registry: SecurityCatalogRegistry; + /** The kernel `metadata` service. */ + metadata: SecurityCatalogMetadataService; +} + +/** One catalog definition, as the reader that answered holds it. */ +export interface SecurityCatalogEntry { + readonly type: SecurityCatalogType; + readonly name: string; + /** + * The definition itself — the reader's own object, shared with every other + * reader of the registry. ⛔ Never mutate it. It carries no activation + * verdict (module doc). + */ + readonly definition: Readonly>; + /** Which reader answered. */ + readonly source: SecurityCatalogSourceName; + /** The package the definition names as its owner (`_packageId`), when it names one. */ + readonly packageId?: string; +} + +/** The catalog read. Both members answer the same question, so they never disagree. */ +export interface SecurityCatalogReader { + /** The definition a name resolves to, or `undefined` when no reader holds one. */ + resolve(type: SecurityCatalogType, name: string): Promise; + /** One entry per name — each the entry {@link SecurityCatalogReader.resolve} answers for it. */ + list(type: SecurityCatalogType): Promise; +} + +type Definition = Record; + +function isDefinition(value: unknown): value is Definition { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function ownerOf(definition: Definition): string | undefined { + const owner = definition._packageId; + return typeof owner === 'string' && owner !== '' ? owner : undefined; +} + +function nameOf(definition: Definition): string | undefined { + const name = definition.name; + return typeof name === 'string' && name !== '' ? name : undefined; +} + +function assertCatalogType(type: unknown): asserts type is SecurityCatalogType { + if (!(SECURITY_CATALOG_TYPES as readonly unknown[]).includes(type)) { + throw new TypeError( + `security catalog: "${String(type)}" is not a catalog type; the catalog holds ${SECURITY_CATALOG_TYPES.join(', ')}.`, + ); + } +} + +/** The loud failure for a catalog read that did not happen (module doc). */ +function unreadable(source: SecurityCatalogSourceName, type: SecurityCatalogType, cause?: unknown): AuthzStoreUnavailableError { + return new AuthzStoreUnavailableError(`security catalog: ${source} (${type})`, cause); +} + +/** + * Build the catalog read over the two in-process readers. + * + * Refuses, at construction, a source that lacks a member the read calls — a + * read with a reader missing would answer "no such item" for every name only + * that reader holds, which is a fact it never established. + */ +export function createSecurityCatalogReader(sources: SecurityCatalogSources): SecurityCatalogReader { + const registry = sources?.registry; + const metadata = sources?.metadata; + if ( + !registry + || typeof registry.getItem !== 'function' + || typeof registry.listItems !== 'function' + || typeof registry.isPackageDisabled !== 'function' + ) { + throw new TypeError( + 'security catalog: the engine registry is required (getItem, listItems, isPackageDisabled) — ' + + 'it holds the platform permission sets and every package manifest\'s catalog items.', + ); + } + if (!metadata || typeof metadata.get !== 'function' || typeof metadata.list !== 'function') { + throw new TypeError( + 'security catalog: the metadata service is required (get, list) — ' + + 'it holds the stack-declared positions the engine registry does not.', + ); + } + + /** A definition the reader may serve: an object, named as asked, of an enabled package. */ + const servable = (value: unknown, name?: string): Definition | undefined => { + if (!isDefinition(value)) return undefined; + const own = nameOf(value); + if (own === undefined || (name !== undefined && own !== name)) return undefined; + const owner = ownerOf(value); + if (owner !== undefined && registry.isPackageDisabled(owner)) return undefined; + return value; + }; + + const entry = ( + type: SecurityCatalogType, + definition: Definition, + source: SecurityCatalogSourceName, + ): SecurityCatalogEntry => { + const owner = ownerOf(definition); + return { + type, + name: nameOf(definition) as string, + definition, + source, + ...(owner !== undefined ? { packageId: owner } : {}), + }; + }; + + const fromRegistry = (type: SecurityCatalogType, name: string): Definition | undefined => { + let found: unknown; + try { + found = registry.getItem(type, name); + } catch (error) { + throw unreadable('registry', type, error); + } + return servable(found, name); + }; + + const fromMetadata = async (type: SecurityCatalogType, name: string): Promise => { + let data: unknown; + let degraded = false; + let errors: readonly string[] = []; + try { + if (typeof metadata.getDiagnosed === 'function') { + const diagnosed = await metadata.getDiagnosed(type, name); + data = diagnosed?.data; + degraded = diagnosed?.degraded === true; + errors = Array.isArray(diagnosed?.errors) ? diagnosed.errors : []; + } else { + data = await metadata.get(type, name); + } + } catch (error) { + throw unreadable('metadata', type, error); + } + if ((data === undefined || data === null) && degraded) { + throw unreadable('metadata', type, new Error(errors.length > 0 ? errors.join('; ') : 'a metadata loader could not be read')); + } + return servable(data, name); + }; + + const listMetadata = async (type: SecurityCatalogType): Promise => { + let items: unknown; + let degraded = false; + let errors: readonly string[] = []; + try { + if (typeof metadata.listDiagnosed === 'function') { + const diagnosed = await metadata.listDiagnosed(type); + items = diagnosed?.items; + degraded = diagnosed?.degraded === true; + errors = Array.isArray(diagnosed?.errors) ? diagnosed.errors : []; + } else { + items = await metadata.list(type); + } + } catch (error) { + throw unreadable('metadata', type, error); + } + // A list that lost a loader is a PARTIAL catalog presented as a whole one. + if (degraded) { + throw unreadable('metadata', type, new Error(errors.length > 0 ? errors.join('; ') : 'a metadata loader could not be read')); + } + return Array.isArray(items) ? items : []; + }; + + return { + async resolve(type, name) { + assertCatalogType(type); + if (typeof name !== 'string' || name === '') return undefined; + const registered = fromRegistry(type, name); + if (registered) return entry(type, registered, 'registry'); + const declared = await fromMetadata(type, name); + return declared ? entry(type, declared, 'metadata') : undefined; + }, + + async list(type) { + assertCatalogType(type); + let registered: readonly unknown[]; + try { + registered = registry.listItems(type) ?? []; + } catch (error) { + throw unreadable('registry', type, error); + } + const declared = await listMetadata(type); + + // The metadata service's own body per name, read once — the answer its + // by-name read gives for a name the registry does not serve. + const declaredByName = new Map(); + for (const item of declared) { + const definition = servable(item); + if (definition && !declaredByName.has(nameOf(definition) as string)) { + declaredByName.set(nameOf(definition) as string, definition); + } + } + + const out: SecurityCatalogEntry[] = []; + const seen = new Set(); + const names = [ + ...registered.filter(isDefinition).map(nameOf), + ...declaredByName.keys(), + ]; + for (const name of names) { + if (name === undefined || seen.has(name)) continue; + seen.add(name); + // Each name gets the answer the by-name read gives it: the registry's + // own precedence first, the metadata service's body only where the + // registry serves nothing under that name. + const fromReg = fromRegistry(type, name); + if (fromReg) { + out.push(entry(type, fromReg, 'registry')); + continue; + } + const fromMeta = declaredByName.get(name); + if (fromMeta) out.push(entry(type, fromMeta, 'metadata')); + } + return out; + }, + }; +} diff --git a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts index 67f1487414b..ca9944d6b5c 100644 --- a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts +++ b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts @@ -21,6 +21,8 @@ import { describe, it, expect } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { MetadataManager } from '@objectstack/metadata'; +import { createSecurityCatalogReader } from '@objectstack/core'; import { SchemaRegistry } from './registry.js'; import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; @@ -198,3 +200,104 @@ describe('loadMetaFromDb — ADR-0048 package-scoped protection graft at boot (# expect(direct._lock).toBe('full'); }); }); + +/** + * ADR-0131 D4 — which body the security catalog read + * (`createSecurityCatalogReader`, `@objectstack/core`) resolves for a name two + * installed packages both ship. TODAY'S answer, pinned until the maintainer + * rules on shared catalog names: a change here is that ruling landing, never a + * refactor. + * + * It lives beside the #4624 cases because it is their consequence: the row a + * package stored for itself is hydrated into the bare slot, and the catalog + * read asks the registry's by-name precedence first. An assignment carries + * only the NAME (ADR-0131 D4), so there is no package context to prefer one + * package's body over another's: + * + * - no stored override: the FIRST-registered package's body; + * - an override one package stored, bound to itself and hydrated at boot: + * that override, for every caller. + * + * The metadata door's by-name read (`getMetaItem`) is asserted beside each + * answer, so the pin cannot drift from what the door serves. A name the + * registry does not hold falls to the metadata service, where two stacks + * declaring one position name share ONE in-memory slot: the later + * registration holds it. + */ +describe('security catalog read — a name two packages ship (ADR-0131 D4, today\'s answer)', () => { + /** The package whose stored override the second case hydrates. */ + const OVERRIDE_PACKAGE = PKG_B; + + /** A body each catalog type's schema accepts, so hydration reports it valid. */ + const catalogBody = (type: 'permission' | 'position', name: string, label: string) => + type === 'permission' ? { name, label, objects: {} } : { name, label }; + + function bootWithSharedName(type: 'permission' | 'position', name: string, rows: Row[]) { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + // Package A registers FIRST. + registry.registerItem(type, catalogBody(type, name, `${PKG_A} body`), 'name', PKG_A); + registry.registerItem(type, catalogBody(type, name, `${PKG_B} body`), 'name', PKG_B); + const protocol = new ObjectStackProtocolImplementation(makeEngine(registry, rows)); + const reader = createSecurityCatalogReader({ + registry, + metadata: new MetadataManager({ formats: ['json'], loaders: [] }), + }); + return { protocol, reader }; + } + + describe.each(['permission', 'position'] as const)('%s', (type) => { + const name = `shared_${type}`; + + it('no stored override: the first-registered package\'s body', async () => { + const { protocol, reader } = bootWithSharedName(type, name, []); + expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 0, errors: 0, invalid: 0 }); + + const entry = await reader.resolve(type, name); + expect(entry).toMatchObject({ name, source: 'registry', packageId: PKG_A }); + expect(entry?.definition.label).toBe(`${PKG_A} body`); + // One entry for the name, and it is the by-name answer. + expect((await reader.list(type)).filter((e) => e.name === name)).toEqual([entry]); + + const door: any = await protocol.getMetaItem({ type, name }); + expect(door.item?.label).toBe(`${PKG_A} body`); + }); + + it('an override one package stored for itself: that override, for every caller', async () => { + const rows = [ + overlayRow({ + type, + name, + package_id: OVERRIDE_PACKAGE, + metadata: JSON.stringify(catalogBody(type, name, 'stored override')), + }), + ]; + const { protocol, reader } = bootWithSharedName(type, name, rows); + expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); + + const entry = await reader.resolve(type, name); + expect(entry).toMatchObject({ name, source: 'registry', packageId: PKG_B }); + expect(entry?.definition.label).toBe('stored override'); + expect((await reader.list(type)).filter((e) => e.name === name)).toEqual([entry]); + + const door: any = await protocol.getMetaItem({ type, name }); + expect(door.item?.label).toBe('stored override'); + expect(door.item?._packageId).toBe(PKG_B); + }); + }); + + it('a position name two stacks declare: one metadata-service slot, the later registration holds it', async () => { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + const metadata = new MetadataManager({ formats: ['json'], loaders: [] }); + // What two app stacks declaring the same position name do at boot. + metadata.registerInMemory('position', 'regional_manager', { name: 'regional_manager', label: 'first stack' }); + metadata.registerInMemory('position', 'regional_manager', { name: 'regional_manager', label: 'second stack' }); + const reader = createSecurityCatalogReader({ registry, metadata }); + + const entry = await reader.resolve('position', 'regional_manager'); + expect(entry).toMatchObject({ name: 'regional_manager', source: 'metadata' }); + expect(entry?.definition.label).toBe('second stack'); + expect(await reader.list('position')).toEqual([entry]); + }); +}); diff --git a/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts b/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts new file mode 100644 index 00000000000..3b240cbed24 --- /dev/null +++ b/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0131 D2–D4 — the security catalog read (`createSecurityCatalogReader`, +// `@objectstack/core`) over a booted showcase, in three postures: `single`, +// `single` with the Default Organization, and walled. +// +// What it pins: per catalog type, the names the read lists are EXACTLY the +// names the declarations declare — the showcase stack's `positions`, +// `permissions` and `capabilities`, plus the platform's own bootstrap +// permission sets that `plugin-security` ships — and exactly the names the +// metadata door lists (`GET /api/v1/meta/:type`). Every listed name resolves, +// by name, to the entry the list gave for it. +// +// Why both halves. The expected sets are derived from the PRODUCERS (the stack +// object and `securityDefaultPermissionSets`), never from a registry, so a +// reader that stops seeing one source goes red here even when the door loses +// the same source alongside it. The door half says the read serves the +// catalog a metadata author sees, no more and no less. +// +// What it does NOT pin: which in-process reader holds which name (the engine +// registry holds the permission sets, the metadata service the stack-declared +// positions, both the capabilities — see the module doc). That is today's +// distribution, not the read's contract; a position reaching the engine +// registry one day must not turn this file red. +// +// The walled arm boots `multiTenant: 'posture-only'`: a real, non-degraded +// `isolated` posture with no organization wall. The catalog is +// environment-level (ADR-0131 D3), so the wall is not this read's subject; the +// same sets were measured on a boot with the real organizations package +// declared by its host root. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import showcaseStack from '@objectstack/example-showcase'; +import { securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { createSecurityCatalogReader, type SecurityCatalogType } from '@objectstack/core'; + +type Named = { name?: unknown }; +const namesOf = (items: unknown): string[] => + (Array.isArray(items) ? (items as Named[]) : []) + .map((i) => i?.name) + .filter((n): n is string => typeof n === 'string') + .sort(); + +const stack = showcaseStack as unknown as { + positions?: Named[]; + permissions?: Named[]; + capabilities?: Named[]; +}; + +/** The declared catalog, read off the producers — never off a registry. */ +const DECLARED: Record = { + position: namesOf(stack.positions), + permission: [...new Set([...namesOf(stack.permissions), ...namesOf(securityDefaultPermissionSets)])].sort(), + capability: namesOf(stack.capabilities), +}; + +const TYPES: SecurityCatalogType[] = ['position', 'permission', 'capability']; + +const POSTURES = [ + { label: 'single', opts: {}, posture: 'single' }, + { label: 'single with the Default Organization', opts: { orgContext: true }, posture: 'single' }, + { label: 'walled', opts: { multiTenant: 'posture-only' as const }, posture: 'isolated' }, +] as const; + +it('PRECONDITION: the declarations name a catalog in every type, the platform administrator set among them', () => { + for (const type of TYPES) expect(DECLARED[type].length, type).toBeGreaterThan(0); + expect(DECLARED.permission).toContain('admin_full_access'); +}); + +describe.each(POSTURES)('showcase, $label: the security catalog read', ({ opts, posture }) => { + let booted: VerifyStack; + let admin: string; + let reader: ReturnType; + + beforeAll(async () => { + booted = await bootStack(showcaseStack as Parameters[0], opts); + admin = await booted.signIn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ql: any = await booted.kernel.getServiceAsync('objectql'); + reader = createSecurityCatalogReader({ registry: ql.registry, metadata: booted.kernel.getService('metadata') }); + }, 180_000); + + afterAll(async () => { + await booted?.stop(); + }); + + it('PRECONDITION: the boot runs the posture this arm names', () => { + expect(booted.tenancy().requestedPosture).toBe(posture); + }); + + it.each(TYPES)('%s: lists exactly the declared names', async (type) => { + const listed = (await reader.list(type)).map((e) => e.name).sort(); + expect(listed).toEqual(DECLARED[type]); + }); + + it.each(TYPES)('%s: lists exactly the names the metadata door lists', async (type) => { + const res = await booted.apiAs(admin, 'GET', `/meta/${type}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { items?: unknown } | unknown[]; + const door = namesOf(Array.isArray(body) ? body : body.items); + expect((await reader.list(type)).map((e) => e.name).sort()).toEqual(door); + }); + + it.each(TYPES)('%s: every listed name resolves by name to the entry the list gave', async (type) => { + for (const entry of await reader.list(type)) { + expect(await reader.resolve(type, entry.name)).toEqual(entry); + } + }); +});