diff --git a/.changeset/23298-env-kernel-registry-write-through.md b/.changeset/23298-env-kernel-registry-write-through.md new file mode 100644 index 00000000000..faebd7d9bad --- /dev/null +++ b/.changeset/23298-env-kernel-registry-write-through.md @@ -0,0 +1,12 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): an environment kernel that hydrates its own `sys_metadata` writes a published body through to its engine registry, so a narrowed permission set stops granting its old body without a restart + +Clause-②: no + +- **What was wrong.** The protocol's registry write-through skipped every type except `object` whenever the protocol had an `environmentId`. A kernel that hydrates its own store at boot (the standalone stack's `env_local`, or a hosted kernel with `hydrateMetadataFromDb`) put every stored body into its registry at boot and then never updated it. A save was accepted and served by `GET /api/v1/meta/:type/:name`, but the engine registry kept the boot-time body until a restart, and held nothing for an item created after boot. The security catalog reads that registry first, so a permission set narrowed through `PUT /api/v1/meta/permission/:name` kept resolving its earlier, wider body, and a permission set published after boot was not in the catalog at all: a user granted it was refused, and an `isDefault` set recorded no `everyone` suggestion. +- **What changed.** The write-through now asks whose registry it is, not whether the kernel has an environment. It registers the published body wherever the registry takes this kernel's stored rows: on an unscoped kernel, as before, and on an environment kernel once the boot has hydrated that kernel's own store into it (`loadMetaFromDb`). The delete's heal re-registers a MetadataService baseline on the same kernels, so a save followed by a reset does not leave the deleted body in the registry. +- **The owner.** The registry entry of a stored row now carries the row's package binding as `_packageId` on the boot and on the write-through, as a list read already stamped it, so a runtime package's `isDefault` set published through the metadata door is owned in the security catalog at once and records its `everyone` suggestion, on a host kernel and on a hydrating environment kernel alike; a row bound to no package stays unowned, and an artifact's own envelope still wins. +- **Unchanged.** An environment kernel whose store is never hydrated into its registry still writes nothing into it, so a registry shared with unscoped callers never receives a project-scoped row. The `object` branch and the organization-scope refusal are unchanged. What the registry holds after a save is what it holds after a restart. diff --git a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts index ba41458369e..a1a4c6543ce 100644 --- a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts @@ -95,9 +95,11 @@ const SITES: ReadonlyArray), _provenance: 'org' }; } +/** + * [#23233] A stored row's body with its package binding stated as `_packageId`, + * the way `getMetaItems` stamps a row from its `package_id` column. The binding + * is read, never derived: a row bound to no package ({@link boundPackageOf}), + * or a body that already names a `_packageId`, is returned as it is. A copy, + * so the caller's body is never written. + */ +function withRowBinding(data: unknown, packageId: unknown): unknown { + const binding = boundPackageOf(packageId); + if (binding === undefined || !data || typeof data !== 'object' || Array.isArray(data)) return data; + if ((data as { _packageId?: unknown })._packageId !== undefined) return data; + return { ...(data as Record), _packageId: binding }; +} + /** * [#21639, #21638] Is this artifact a view ITEM — a view a package ships under * its own name, `viewKind` set (the spec's ViewItem) — rather than a container @@ -6341,9 +6355,20 @@ export class ObjectStackProtocolImplementation implements * `environmentId !== undefined` wrapper #6710 had just retired next door — * so the identical proxy-signal defect outlived its own diagnosis on the * sibling gate. Both doors read {@link authoringChannel} now. + * + * [#23298] …and no longer, alone, whether a write registers its body into + * the engine registry. That is {@link registryTakesStoredRows}, which also + * reads {@link storeHydratedIntoRegistry}. */ private environmentId?: string; + /** + * [#23298] Set by {@link loadMetaFromDb}: this protocol has hydrated its + * own store into its engine registry, so every body the store holds is in + * that registry already. Read by {@link registryTakesStoredRows} alone. + */ + private storeHydratedIntoRegistry = false; + /** * The declared authoring channel (#6710). Defaults to `'environment'`, * which is what makes the #4463 gate active on every kernel that does not @@ -7669,8 +7694,9 @@ export class ObjectStackProtocolImplementation implements * * Scope parity is INHERITED, not re-decided: `applyRegistryWriteThrough` * carries the org/environment gates (#6602: an org-scoped overlay never - * enters the shared registry; the non-object branch stays - * control-plane-only) and `restoreArtifactRegistryView` refuses org + * enters the shared registry; the non-object branch registers only where + * the registry takes this kernel's stored rows, #23298) and + * `restoreArtifactRegistryView` refuses org * scopes on its own — so a peer applies exactly what the writer's own * kernel shape would have applied locally. * @@ -10456,7 +10482,8 @@ export class ObjectStackProtocolImplementation implements * container it reads into its own answer, so `GET /meta/view?object=` * lists the container's views. Nothing else stores or registers those * views on every kernel: the registry hydration that does - * ({@link hydrateExpandedViewItems}) runs only on an unscoped kernel and + * ({@link hydrateExpandedViewItems}) ran on a save only on an unscoped + * kernel ([#23298]: now wherever {@link registryTakesStoredRows}), and * only for an environment-wide row. So the by-name read answered an * expanded name only there, and answered nothing on an * environment-scoped kernel or for an organization-scoped container — and, @@ -19224,7 +19251,8 @@ export class ObjectStackProtocolImplementation implements * no package. The list merges it into its package's slot. An aggregated * container row still has its expansions judged one by one. The * environment-scoped kernel's running answer was already this one, because - * it registers nothing on a save. + * it registered nothing on a save then ([#23298]: a kernel that hydrates + * its own store now registers a save as its boot does, by this rule). * * ⛔ `view` only, judged on the canonical type. Every other type registers * as before: a row bound to one package, of a name two packages ship, holds @@ -19236,6 +19264,24 @@ export class ObjectStackProtocolImplementation implements * package-less view row, and a view row of a name only its own package * ships or no package ships, register as before too. * + * ## [#23233] The entry carries the row's package binding + * + * The registered body is stamped with the row's package binding as + * `_packageId` ({@link withRowBinding}), as `getMetaItems` stamps it from + * the row's `package_id` before it hydrates. This hydrator is the one the + * boot and the write-through share, so every registration of a stored row + * now names the package the row is bound to, not only the one a list read + * makes. Before this, the write-through and the boot registered a runtime + * package's body with no `_packageId`, and every registry reader that takes + * the owner from it (the security catalog, and through it the `everyone` + * suggestion of an `isDefault` set) saw the item as unowned: on a host + * kernel until the next list read, and on an environment kernel that + * hydrates its own store until never. ⛔ No owner is derived: the binding is + * only read, a body that already names a `_packageId` keeps it, a row with + * no binding stays unowned, and an artifact's envelope still wins over the + * stamp ({@link mergeArtifactProtection}). The expansions of a view + * container are registered from the unstamped row, as before. + * * ## [#9111] `type` is an ASSERTED input, not a silently-trusted one * * The spelling handed in is the spelling the registry entry is minted @@ -19383,7 +19429,11 @@ export class ObjectStackProtocolImplementation implements // and its receipt rendered both as the package. Registered as the // tenant row it is, it is reported as one and never armed. const envelope = canonicalType === 'flow' ? undefined : artifact; - registry.registerItem(type, mergeArtifactProtection(type, stateTenantAuthorship(data), envelope), 'name' as any); + registry.registerItem( + type, + mergeArtifactProtection(type, stateTenantAuthorship(withRowBinding(data, options.packageId)), envelope), + 'name' as any, + ); await this.hydrateExpandedViewItems(type, data, options); return true; } @@ -19742,10 +19792,13 @@ export class ObjectStackProtocolImplementation implements * * This is registry-MUTATING, and both its callers gate it off exactly * where {@link hydrateOverlayIntoRegistry}'s own comments say why: - * write-through (`applyRegistryWriteThrough`) never calls it when - * `this.environmentId !== undefined` (a real per-environment kernel — - * `assembleMetadataProtocol`'s own comment: "per-project (cloud) kernels - * source metadata from the control plane"), and `hydrateOverlayIntoRegistry` + * write-through (`applyRegistryWriteThrough`) never calls it where the + * registry does not take this kernel's stored rows + * ({@link registryTakesStoredRows}: an environment kernel whose own store + * was never hydrated into it — `assembleMetadataProtocol`'s own comment: + * "per-project (cloud) kernels source metadata from the control plane"; + * [#23298] it read `this.environmentId !== undefined` until then), and + * `hydrateOverlayIntoRegistry` * itself refuses ANY org-scoped row before ever reaching this function * ("[#6602] a per-org overlay is served on demand, never grafted into the * registry every org in this process shares") — REGARDLESS of @@ -19764,8 +19817,9 @@ export class ObjectStackProtocolImplementation implements * `getMetaItems` already read for THIS request's own scope, and never * writes them anywhere shared. This function keeps serving the case it * always did (env-wide rows on an unscoped/control-plane kernel — the ONLY - * combination #7736's own pin ever exercised), now with the corrected - * derivation chain. + * combination #7736's own pin ever exercised — and, at boot and since + * #23298 on a save, on an environment kernel that hydrates its own store), + * now with the corrected derivation chain. * * ## [#21511] An expansion inherits its container's authorship * @@ -19779,7 +19833,8 @@ export class ObjectStackProtocolImplementation implements * for a code artifact: on an unscoped kernel the by-name read reported * the expanded view `resettable` and the layers read reported it as its * own `code` layer (for a package-less container too, through the - * runtime-only `getItem` arm), where `env_local`, which registers nothing, + * runtime-only `getItem` arm), where `env_local`, which then registered + * nothing on a save, * reported neither. With the marker both kernels give one answer. * * ## [#21980] Never under a name another package ships @@ -19851,8 +19906,9 @@ export class ObjectStackProtocolImplementation implements * * Every gate the walk carries applies here too. An organization-scoped * write registers nothing and heals nothing (#6602 / #6780: the bare - * entries belong to the environment-wide row). Only a control-plane kernel - * re-registers a baseline. A walk that stops on a degraded baseline read + * entries belong to the environment-wide row). Only a kernel whose registry + * takes its stored rows ({@link registryTakesStoredRows}) re-registers a + * baseline. A walk that stops on a degraded baseline read * retires nothing, and that name stays registered, unrecorded, until a * restart: there is no retry ([#23149]'s residual, now reached from a * republish as well as a reset). @@ -19968,6 +20024,43 @@ export class ObjectStackProtocolImplementation implements .some((artifact) => (artifact as { _packageId?: unknown })._packageId !== own); } + /** + * [#23298] Does this protocol's engine registry take the rows this kernel + * stores? The ONE answer to the kernel-scope question both registry writes + * ask: the write-through ({@link applyRegistryWriteThrough}) and the + * delete's heal re-registering a baseline ({@link restoreArtifactRegistryView}). + * One predicate, so a save and the reset after it cannot disagree about + * whose registry it is. + * + * - **An unscoped (control-plane) kernel: yes**, as always. Its rows are + * the ones its registry's unscoped callers read. + * - **An environment kernel that has hydrated its own store into the + * registry ({@link loadMetaFromDb}): yes.** That registry takes every + * body the store holds at each boot, so registering the body a write + * just made live registers nothing the next boot would not; it only + * stops the registry serving the boot-time body, or no body, until a + * rebuild. + * `ObjectQLPlugin.start()` calls `loadMetaFromDb` on an environment + * kernel only when the composition declares `hydrateMetadataFromDb`, + * whose stated precondition is that the kernel's registry is + * per-instance isolated and its `sys_metadata` lives on its own driver. + * The standalone stack declares it on every serving boot (`env_local`); + * a hosted tenant kernel that persists its own `sys_metadata` declares + * it too. + * - **Any other environment kernel: no.** Its store was never hydrated + * into its registry, which may be one it shares with unscoped callers, + * and a project-scoped row must never be registered there. + * + * ⛔ `environmentId` alone does not answer this. It says the kernel serves + * one environment, not whose registry the protocol writes into, and reading + * it as the answer left every hydrating environment kernel serving the + * boot-time body of anything saved after boot — a narrowed permission + * set's wider grant included. + */ + private registryTakesStoredRows(): boolean { + return this.environmentId === undefined || this.storeHydratedIntoRegistry; + } + /** * [#4521] Write-through the SchemaRegistry after a mutation goes LIVE, so * a just-saved item is dispatchable — not merely listable. @@ -19996,12 +20089,21 @@ export class ObjectStackProtocolImplementation implements * • `runPublishSideEffects` — the draft→active promotion; * • `rollbackMetaItem` — the restored body is the live one. * - * The non-object branch carries the same `environmentId === undefined` - * gate the read-side hydration carries: a project-scoped row must not be - * registered into a registry that unscoped (control-plane) callers share. - * The write must not be more permissive about that than the read is. - * - * [#6602] That sentence was true of the ENVIRONMENT dimension and false + * The non-object branch registers only where the registry takes this + * kernel's stored rows ({@link registryTakesStoredRows}): a project-scoped + * row must not be registered into a registry that unscoped (control-plane) + * callers share. The write must not be more permissive about that than the + * read is. + * + * [#23298] …nor less permissive than the BOOT read. The gate used to be + * `environmentId === undefined`, so an environment kernel that hydrates its + * own store ({@link loadMetaFromDb}) registered every stored body at boot + * and none that a write made live after it: the registry, and the security + * catalog that reads it first, kept the boot-time body until a rebuild, and + * a narrowed permission set kept granting its wider one. The gate now asks + * whose registry this is, not whether the kernel has an environment. + * + * [#6602] That reason was true of the ENVIRONMENT dimension and false * of the ORGANIZATION one: the gate above says nothing about * `organization_id`, so on an unscoped kernel a per-org overlay write * hydrated straight into the process-wide registry under the plain key — @@ -20069,11 +20171,12 @@ export class ObjectStackProtocolImplementation implements // fails CLOSED on a missing entry, and refusing to register here // would make a runtime-created object unreachable for data CRUD // rather than merely un-listed. This branch has never carried the - // `environmentId` gate either, for the same reason. + // kernel-scope gate below either, for the same reason. this.applyObjectRegistryMutation(request); return; } - if (this.environmentId !== undefined) return; + // [#23298] Whose registry this is — never `environmentId` alone. + if (!this.registryTakesStoredRows()) return; try { await this.hydrateOverlayIntoRegistry(request.type, request.item, { packageId: request.packageId ?? undefined, @@ -20149,10 +20252,14 @@ export class ObjectStackProtocolImplementation implements * same helper also folds in the singular/plural retry, so a baseline * stored under the twin spelling is found rather than retired. * - * RE-REGISTRATION stays control-plane-only (`environmentId === undefined`) - * — the historical refresh semantics of the original call sites, unchanged. - * Only the READ is now unconditional, because a project kernel needs the - * same evidence before retiring an entry. + * RE-REGISTRATION runs only where the registry takes this kernel's stored + * rows ({@link registryTakesStoredRows}) — the kernels the write-through + * registers on. Only the READ is unconditional, because a project kernel + * needs the same evidence before retiring an entry. [#23298] It used to be + * control-plane-only (`environmentId === undefined`), the gate the + * write-through had: once a save on an environment kernel that hydrates its + * own store registers its body, a reset that found a baseline and did not + * re-register it would leave the deleted body in the registry. * * ## Why `organizationId` is a REQUIRED parameter (#6780) * @@ -20192,9 +20299,10 @@ export class ObjectStackProtocolImplementation implements * heals it, while a wrongly retired entry 404s data CRUD for every tenant. * The two costs are not symmetric, so the two gates are not either. * - * The KERNEL-scope gate stays where it was: `environmentId === undefined` - * still guards re-registration only, because that is a fact about the - * kernel this protocol instance serves, not about the row in hand. + * The KERNEL-scope gate stays where it was: it guards re-registration + * only, because whose registry this is ({@link registryTakesStoredRows}) + * is a fact about the kernel this protocol instance serves, not about the + * row in hand. * * ## [#23149] A view container's expansions are healed too * @@ -20295,7 +20403,8 @@ export class ObjectStackProtocolImplementation implements const baselineServesMember = memberKey === undefined || itemDiscriminator(singular, baseline.data) === memberKey; if (baseline.data !== undefined && baseline.data !== null && baselineServesMember) { - if (this.environmentId === undefined) { + // [#23298] The write-through's own kernel-scope gate. + if (this.registryTakesStoredRows()) { // [#22057] See tier 2 in the header. ⛔ View only. const bound = canonicalMetaType(type) === 'view' ? boundPackageOf((baseline.data as { _packageId?: unknown })._packageId) @@ -28688,6 +28797,11 @@ export class ObjectStackProtocolImplementation implements * `environment_id` constraint — ADR-0005 (revised 2026-05) gave each * environment its own database. * + * [#23298] The call is also how an environment kernel's protocol learns + * that its registry is its own: from it on, a write registers the body it + * made live as this load registers each row, so the registry never keeps a + * boot-time body ({@link registryTakesStoredRows}). + * * [ADR-0131 D6, triage ruling Q1 → C] An environment row that overlays * SEALED managed content ({@link isUnservedSealedOverlay}) is named by * {@link reportSealedOverlayRows} with its remedies. An `object` such row @@ -28756,6 +28870,12 @@ export class ObjectStackProtocolImplementation implements let invalid = 0; /** #5897 — see the TSDoc: set only on the non-benign outer-catch branch. */ let storeUnavailable = false; + // [#23298] From here on the registry takes this kernel's stored rows, + // so a write registers its live body as this load registers each row + // ({@link registryTakesStoredRows}). Set on the call, not on the read's + // outcome: a store this load could not read is still the store whose + // rows this registry takes. + this.storeHydratedIntoRegistry = true; try { // [ADR-0131 D6] Hydrate only the environment's rows // (organization_id IS NULL); no read serves any other. diff --git a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts index 1a46821732b..b82baec221e 100644 --- a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts +++ b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts @@ -149,8 +149,13 @@ describe('loadMetaFromDb — ADR-0048 package-scoped protection graft at boot (# // it — `_provenance: 'org'`, the same one the `object` branch has // always stated — because every row this hydrator sees came out of a // `sys_metadata` write. That is a statement of authorship, not a graft - // from an artifact: `_lock` and `_packageId` stay absent, which is what - // this case is about. + // from an artifact: `_lock` stays absent, which is what this case is + // about. + // + // [#23233] `_packageId` is no longer absent: the hydrator states the + // row's OWN package binding (its `package_id` column), as a list read + // always did. That is read from the row, not grafted from an artifact — + // which is why `_provenance` below stays `'org'` and not `'package'`. const registry = new SchemaRegistry({ multiTenant: false }); registry.logLevel = 'silent'; const rows = [ @@ -169,7 +174,7 @@ describe('loadMetaFromDb — ADR-0048 package-scoped protection graft at boot (# const direct: any = registry.getItem('page', 'home'); expect(direct.label).toBe('B Home (customized)'); expect(direct._lock).toBeUndefined(); - expect(direct._packageId).toBeUndefined(); + expect(direct._packageId).toBe(PKG_B); // [#16702] NOT `undefined` — the hydrator states tenant authorship. expect(direct._provenance).toBe('org'); }); diff --git a/packages/objectql/src/protocol-derived-provenance-doors.test.ts b/packages/objectql/src/protocol-derived-provenance-doors.test.ts index 5a1bb073e07..30a6d406721 100644 --- a/packages/objectql/src/protocol-derived-provenance-doors.test.ts +++ b/packages/objectql/src/protocol-derived-provenance-doors.test.ts @@ -411,7 +411,12 @@ describe('#16702 criterion 5 — mergeArtifactProtection precedence is unchanged const hydrated = engine.registry.getItem>('app', 'pet_hospital'); expect(hydrated?._provenance).toBe('org'); expect(hydrated?._lock).toBe('none'); - expect(hydrated?._packageId).toBeUndefined(); + // [#23233] The row's OWN package binding (its `package_id` column), as a + // list read always stamped it — read from the row, not invented and not + // grafted: no artifact exists, so `_provenance` stays `org`, `_lock` + // stays the row's own and no `_packageVersion` appears. + expect(hydrated?._packageId).toBe(PKG); + expect(hydrated?._packageVersion).toBeUndefined(); }); }); diff --git a/packages/objectql/src/protocol-environment-kernel-registry-write-through.test.ts b/packages/objectql/src/protocol-environment-kernel-registry-write-through.test.ts new file mode 100644 index 00000000000..b57709bfbaf --- /dev/null +++ b/packages/objectql/src/protocol-environment-kernel-registry-write-through.test.ts @@ -0,0 +1,344 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23298 — on an environment kernel that hydrates its own `sys_metadata` into + * its own registry, a body published through the metadata door reaches that + * registry at once, so the security catalog (which reads the registry first) + * resolves a narrowed permission set's new body with no rebuild. + * + * ## The defect + * + * `applyRegistryWriteThrough` skipped every non-`object` type whenever the + * protocol had an `environmentId`. Its stated reason — a project-scoped row must + * not be registered into a registry that unscoped (control-plane) callers share + * — holds only for a SHARED registry. An environment kernel that hydrates its + * own store at boot (`ObjectQLPlugin`'s `hydrateMetadataFromDb`: the standalone + * stack, a hosted tenant kernel) put every stored body into its registry at + * boot and then never updated it: the save was accepted and served by the + * protocol's reads, while the engine registry, and so the security catalog, + * kept the boot-time body until the kernel was rebuilt. A narrowed set kept + * granting its old, wider body. + * + * ## The pins + * + * - on an environment kernel, a narrowed set's next resolution reads the new + * body with no rebuild, and it is the body a rebuild reads; + * - the same for a view, read from the registry; + * - a reset of an item whose baseline the MetadataService holds puts the + * baseline back in the registry (the delete's heal re-registers on the + * kernels the write registers on, so a save followed by a reset leaves no + * deleted body behind); + * - a shared registry still never receives a project-scoped row: an + * environment protocol whose store was never hydrated into the registry it + * shares with a control-plane protocol writes nothing into it. + * + * ## The harness + * + * `environment-overlay-email-locale-restart.test.ts`'s: the real `ObjectQL` + * engine and its `SchemaRegistry`, the real `ObjectStackProtocolImplementation` + * constructed with an `environmentId`, and an in-memory driver. A BOOT hydrates + * the way `ObjectQLPlugin.start()` does on a kernel with + * `hydrateMetadataFromDb` (`loadMetaFromDb()`); a REBUILD is a new engine and a + * new protocol over the same stores. The catalog is the real + * `createSecurityCatalogReader` over the engine registry and a MetadataService + * double; the writes are the real `saveMetaItem` publish and `deleteMetaItem`. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createSecurityCatalogReader } from '@objectstack/core'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; +import { + SysMetadataAuditObject, + SysMetadataCommitObject, + SysMetadataHistoryObject, + SysMetadataObject, +} from '@objectstack/metadata-core'; +import { ObjectQL } from './engine.js'; + +const ENV = 'env_tenant'; +const PKG = 'com.acme.crm'; +const SET = 'crm_editor'; +const VIEW = 'crm_account_admin_list'; +/** A view the MetadataService holds as its baseline, and no package ships. */ +const BASELINE_VIEW = 'crm_account_baseline_list'; + +type Stores = Map>>; +type Row = Record; + +/** The package: one object the bodies below name. */ +function crmManifest(): Record { + return { + id: PKG, + name: 'acme_crm', + version: '1.0.0', + objects: [{ + name: 'crm_account', + label: 'Account', + fields: { name: { type: 'text', label: 'Name' } }, + }], + }; +} + +/** The set as first published: delete granted, not the app baseline. */ +const wideSet = (): Row => ({ + name: SET, + label: 'CRM editor', + objects: { crm_account: { allowRead: true, allowEdit: true, allowDelete: true } }, +}); + +/** The set as narrowed: delete revoked, marked the app baseline (the card's measured pair). */ +const narrowedSet = (): Row => ({ + name: SET, + label: 'CRM editor', + isDefault: true, + objects: { crm_account: { allowRead: true, allowEdit: true, allowDelete: false } }, +}); + +const viewBody = (name: string, label: string): Row => ({ + name, + label, + object: 'crm_account', + viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], +}); + +/** In-memory driver, copied from `environment-overlay-email-locale-restart.test.ts`; equality-only WHERE. */ +function makeMemoryDriver(stores: Stores) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + // Unique across boots: a rebuild is a new driver over the same stores. + const id = (data.id as string) ?? `r_${randomUUID()}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return driver; +} + +/** + * The kernel `metadata` service as the catalog and the delete's heal read it: + * in memory, keyed by type and name, holding only what a test declares. + */ +function makeMetadataService(declared: Array<{ type: string; item: Row }> = []) { + const store = new Map(); + for (const { type, item } of declared) store.set(`${type}\u0000${String(item.name)}`, item); + return { + get: (type: string, name: string) => store.get(`${type}\u0000${name}`), + list: (type: string) => [...store.entries()] + .filter(([key]) => key.startsWith(`${type}\u0000`)) + .map(([, item]) => item), + }; +} + +/** An engine over `stores`, with the platform's metadata tables and the package registered. */ +async function makeEngine(stores: Stores): Promise { + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores), true); + await engine.init(); + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + engine.registry.registerObject(o as any, 'test-package'); + } + engine.registerApp(crmManifest()); + return engine; +} + +/** + * One environment kernel over `stores`, booted as `ObjectQLPlugin.start()` + * boots a kernel with `hydrateMetadataFromDb`: the package registered, then + * the protocol's own store hydrated into the engine's own registry. Called + * again over the same stores = a rebuild. + */ +async function bootEnvironmentKernel(stores: Stores, metadata = makeMetadataService()) { + const engine = await makeEngine(stores); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map([['metadata', metadata]]), ENV); + const load = await protocol.loadMetaFromDb(); + expect(load, 'precondition: the boot read the store').toMatchObject({ errors: 0, storeUnavailable: false }); + const catalog = createSecurityCatalogReader({ registry: engine.registry as never, metadata }); + return { engine, protocol, catalog }; +} + +const definitionOf = async (catalog: ReturnType, name: string) => + (await catalog.resolve('permission', name))?.definition as Row | undefined; + +const savedWritable = process.env.OS_METADATA_WRITABLE; +beforeEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); +afterEach(() => { + if (savedWritable === undefined) delete process.env.OS_METADATA_WRITABLE; + else process.env.OS_METADATA_WRITABLE = savedWritable; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); + +describe('#23298 — an environment kernel that hydrates its own store takes the published body', () => { + it('a narrowed permission set\'s next resolution reads the new body, with no rebuild — the body a rebuild reads', async () => { + const stores: Stores = new Map(); + const first = await bootEnvironmentKernel(stores); + expect(await first.protocol.saveMetaItem({ type: 'permission', name: SET, item: wideSet() })) + .toMatchObject({ success: true }); + + // The kernel the administrator works on: its boot put the wide body + // into its registry, and the catalog resolves it from there. + const kernel = await bootEnvironmentKernel(stores); + const before = await kernel.catalog.resolve('permission', SET); + expect(before, 'anti-vacuity: the boot-time body is the registry\'s').toMatchObject({ source: 'registry' }); + expect(before?.definition).toMatchObject({ objects: { crm_account: { allowDelete: true } } }); + expect(before?.definition).not.toHaveProperty('isDefault', true); + + expect(await kernel.protocol.saveMetaItem({ type: 'permission', name: SET, item: narrowedSet(), mode: 'publish' })) + .toMatchObject({ success: true }); + + const after = await kernel.catalog.resolve('permission', SET); + expect(after).toMatchObject({ source: 'registry' }); + expect(after?.definition).toMatchObject({ isDefault: true, objects: { crm_account: { allowDelete: false } } }); + + // The body the registry now holds is the one a rebuild would read. + const rebuilt = await bootEnvironmentKernel(stores); + expect(await definitionOf(rebuilt.catalog, SET)).toEqual(after?.definition); + }); + + it('a published view reaches the registry with no rebuild — the body a rebuild reads', async () => { + const stores: Stores = new Map(); + const first = await bootEnvironmentKernel(stores); + expect(await first.protocol.saveMetaItem({ type: 'view', name: VIEW, item: viewBody(VIEW, 'Accounts') })) + .toMatchObject({ success: true }); + + const kernel = await bootEnvironmentKernel(stores); + expect(kernel.engine.registry.getItem('view', VIEW), 'anti-vacuity: the boot-time body') + .toMatchObject({ label: 'Accounts' }); + + expect(await kernel.protocol.saveMetaItem({ type: 'view', name: VIEW, item: viewBody(VIEW, 'Accounts (admin)') })) + .toMatchObject({ success: true }); + const after = kernel.engine.registry.getItem('view', VIEW) as Row | undefined; + expect(after).toMatchObject({ label: 'Accounts (admin)' }); + + const rebuilt = await bootEnvironmentKernel(stores); + expect(rebuilt.engine.registry.getItem('view', VIEW)).toEqual(after); + }); + + it('a reset of an item the MetadataService holds a baseline of puts the baseline back in the registry', async () => { + const stores: Stores = new Map(); + const baseline = viewBody(BASELINE_VIEW, 'Baseline accounts'); + const metadata = makeMetadataService([{ type: 'view', item: baseline }]); + const kernel = await bootEnvironmentKernel(stores, metadata); + expect(kernel.engine.registry.getItem('view', BASELINE_VIEW), 'precondition: no package ships it').toBeUndefined(); + + expect(await kernel.protocol.saveMetaItem({ type: 'view', name: BASELINE_VIEW, item: viewBody(BASELINE_VIEW, 'Saved accounts') })) + .toMatchObject({ success: true }); + expect(kernel.engine.registry.getItem('view', BASELINE_VIEW)).toMatchObject({ label: 'Saved accounts' }); + + await kernel.protocol.deleteMetaItem({ type: 'view', name: BASELINE_VIEW }); + expect(Array.from(stores.get('sys_metadata')?.values() ?? []).filter((r) => r.name === BASELINE_VIEW && r.state === 'active')) + .toEqual([]); + // Not the deleted body: the baseline, as a rebuild serves it. + expect(kernel.engine.registry.getItem('view', BASELINE_VIEW)).toMatchObject({ label: 'Baseline accounts' }); + }); +}); + +describe('#23298 — a shared registry never receives a project-scoped row', () => { + /** + * ONE engine, so ONE registry, shared by an unscoped control-plane protocol + * (which hydrated its store into it) and an environment protocol over the + * same engine whose store was never hydrated into it — the topology the + * write-through's gate exists for. + */ + async function sharedRegistry() { + const stores: Stores = new Map(); + const engine = await makeEngine(stores); + const controlPlane = new ObjectStackProtocolImplementation(engine, () => new Map()); + await controlPlane.loadMetaFromDb(); + const environment = new ObjectStackProtocolImplementation(engine, () => new Map(), ENV); + return { stores, engine, controlPlane, environment }; + } + + const activeRows = (stores: Stores, name: string) => + Array.from(stores.get('sys_metadata')?.values() ?? []).filter((r) => r.name === name && r.state === 'active'); + + it('a permission set and a view published by the environment protocol stay out of the shared registry', async () => { + const { stores, engine, environment } = await sharedRegistry(); + + expect(await environment.saveMetaItem({ type: 'permission', name: SET, item: wideSet(), mode: 'publish' })) + .toMatchObject({ success: true }); + expect(await environment.saveMetaItem({ type: 'view', name: VIEW, item: viewBody(VIEW, 'Accounts'), mode: 'publish' })) + .toMatchObject({ success: true }); + + // Anti-vacuity: both writes landed and are live. + expect(activeRows(stores, SET)).toHaveLength(1); + expect(activeRows(stores, VIEW)).toHaveLength(1); + expect(engine.registry.getItem('permission', SET)).toBeUndefined(); + expect(engine.registry.getItem('view', VIEW)).toBeUndefined(); + }); + + it('CONTROL: the control-plane protocol\'s own publish reaches the same registry', async () => { + const { engine, controlPlane } = await sharedRegistry(); + + expect(await controlPlane.saveMetaItem({ type: 'permission', name: SET, item: wideSet(), mode: 'publish' })) + .toMatchObject({ success: true }); + expect(engine.registry.getItem('permission', SET)).toMatchObject({ name: SET }); + }); +}); diff --git a/packages/plugins/plugin-security/src/permission-set-env-kernel-registry-write-through.test.ts b/packages/plugins/plugin-security/src/permission-set-env-kernel-registry-write-through.test.ts new file mode 100644 index 00000000000..4be1342bd52 --- /dev/null +++ b/packages/plugins/plugin-security/src/permission-set-env-kernel-registry-write-through.test.ts @@ -0,0 +1,290 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23298 and #23233 — a permission set published through the metadata door is + * in the security catalog at once, under the package it is bound to, on a host + * kernel and on an environment kernel that hydrates its own `sys_metadata`. The + * engine registry holds it, the catalog resolves it with its owner, and an + * `isDefault` set records its one `everyone` suggestion. No list read and no + * rebuild happen in between. + * + * ## Why this lives here + * + * The suggestion collector (`syncAudienceBindingSuggestions`) is this + * package's. The writes that feed it are `metadata-protocol`'s (aliased to + * source in `vitest.config.ts`), so the suite runs the protocol in this + * checkout. The engine is the real `ObjectQL` (aliased to source too) with its + * real `SchemaRegistry`, over an in-memory driver. + * + * ## The two halves of the write + * + * - **#23298, the body.** The registry write-through skipped every type but + * `object` on any kernel with an `environmentId`, so on an environment + * kernel a set published after boot was in no reader at all (the plugin's + * permission-set echo is no longer registered). It now registers wherever + * the registry takes the kernel's stored rows: an unscoped kernel, or an + * environment kernel that hydrated its own store (`loadMetaFromDb`, which + * `ObjectQLPlugin.start()` calls under `hydrateMetadataFromDb`). + * - **#23233, the owner.** The hydrator the boot and the write-through share + * registered the body without the row's package binding, so the catalog + * served a runtime package's set unowned (on a host kernel until a list + * read stamped it), and the suggestion collector, which reads the owner + * from the catalog entry alone, skipped it. The hydrator now stamps the + * binding as `_packageId`. The bodies below name no `packageId` of their + * own: the owner comes from the binding only. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { bindSecurityCatalogReader, createSecurityCatalogReader } from '@objectstack/core'; +import { + SysMetadataAuditObject, + SysMetadataCommitObject, + SysMetadataHistoryObject, + SysMetadataObject, +} from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; +import { ObjectQL } from '@objectstack/objectql'; +import { SysOrganization } from '@objectstack/platform-objects/identity'; +import { SysAudienceBindingSuggestion } from './objects/sys-audience-binding-suggestion.object.js'; +import { syncAudienceBindingSuggestions } from './suggested-audience-bindings.js'; +import { declareEveryoneBaseline } from './builtin-positions.js'; + +const ENV = 'env_tenant'; +/** A writable runtime package — never installed as code, so nothing ships its sets. */ +const RUNTIME_PKG = 'app.crm_workspace'; +const SET = 'workspace_member_default'; +const SECURITY_PACKAGE = 'com.objectstack.plugin-security'; +const DEFAULT_ORG = 'org_default'; + +type Stores = Map>>; +type Row = Record; + +/** In-memory driver, copied from `objectql`'s `environment-overlay-email-locale-restart.test.ts`; equality-only WHERE. */ +function makeMemoryDriver(stores: Stores) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + const id = (data.id as string) ?? `r_${randomUUID()}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return driver; +} + +/** The kernel `metadata` service as the catalog reads it, holding nothing: no echo is registered. */ +function makeMetadataService() { + return { get: (_type: string, _name: string) => undefined, list: (_type: string) => [] }; +} + +/** An installed code package that ships its own default set. */ +const CODE_PKG = 'com.example.crm'; +const CODE_SET = 'crm_readonly'; + +type Kernel = 'host' | 'environment'; + +/** + * One kernel: the engine with the tables this flow writes and a code package + * installed, the protocol (an `environmentId` for an environment kernel), + * booted as `ObjectQLPlugin.start()` boots it (`loadMetaFromDb`: a host + * kernel always, an environment kernel under `hydrateMetadataFromDb`), the + * catalog bound to the engine, and the built-in `everyone` position declared. + */ +async function boot(kind: Kernel) { + const stores: Stores = new Map([ + ['sys_organization', new Map([[DEFAULT_ORG, { id: DEFAULT_ORG, slug: 'default', name: 'Default' }]])], + ]); + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores), true); + await engine.init(); + for (const o of [ + SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject, + SysOrganization, SysAudienceBindingSuggestion, + ]) { + engine.registry.registerObject(o as any, 'test-package'); + } + engine.registerApp({ + id: CODE_PKG, + name: 'example_crm', + version: '1.0.0', + permissions: [{ name: CODE_SET, label: 'CRM read-only', isDefault: true, objects: {} }], + }); + const metadata = makeMetadataService(); + const protocol = new ObjectStackProtocolImplementation( + engine as never, + () => new Map([['metadata', metadata]]), + kind === 'environment' ? ENV : undefined, + ); + expect(await protocol.loadMetaFromDb()).toMatchObject({ errors: 0, storeUnavailable: false }); + const catalog = createSecurityCatalogReader({ registry: engine.registry as never, metadata }); + bindSecurityCatalogReader(engine, catalog); + declareEveryoneBaseline(engine.registry, SECURITY_PACKAGE, ['member_default']); + return { stores, engine, protocol, catalog }; +} + +const suggestionRows = (stores: Stores, set: string) => + Array.from(stores.get('sys_audience_binding_suggestion')?.values() ?? []) + .filter((row) => row.permission_set_name === set); + +/** A runtime package's new default set. Its body names no `packageId`: the owner is the row's binding. */ +const newDefaultSet = (): Row => ({ name: SET, label: 'Workspace member', isDefault: true, objects: {} }); + +/** The metadata-door publish of the set, bound to the runtime package. */ +async function publishIntoRuntimePackage(protocol: ObjectStackProtocolImplementation) { + const body = newDefaultSet(); + expect(body, 'precondition: the body names no package').not.toHaveProperty('packageId'); + expect(await protocol.saveMetaItem({ + type: 'permission', name: SET, item: body, packageId: RUNTIME_PKG, mode: 'publish', + })).toMatchObject({ success: true }); +} + +const savedWritable = process.env.OS_METADATA_WRITABLE; +beforeEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); +afterEach(() => { + if (savedWritable === undefined) delete process.env.OS_METADATA_WRITABLE; + else process.env.OS_METADATA_WRITABLE = savedWritable; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); + +describe('#23298 — a NEW permission set published on an environment kernel is in the security catalog at once', () => { + it('the registry holds it, the catalog resolves it under its package, and its isDefault records one pending everyone suggestion', async () => { + const { stores, engine, protocol, catalog } = await boot('environment'); + expect(await catalog.resolve('permission', SET), 'precondition: a new set').toBeUndefined(); + + await publishIntoRuntimePackage(protocol); + + // No list read, no rebuild: straight to the readers. + expect(engine.registry.getItem('permission', SET)).toMatchObject({ name: SET, isDefault: true }); + expect(await catalog.resolve('permission', SET)).toMatchObject({ + source: 'registry', packageId: RUNTIME_PKG, definition: { name: SET, isDefault: true }, + }); + + await syncAudienceBindingSuggestions(engine); + expect(suggestionRows(stores, SET)).toEqual([ + expect.objectContaining({ + package_id: RUNTIME_PKG, anchor: 'everyone', status: 'pending', deployment_decision: false, + }), + ]); + }); +}); + +describe('#23233 — on a host kernel, the set is owned from the publish onward, with no list read in between', () => { + it('records exactly one pending everyone suggestion, under the package the set is bound to', async () => { + const { stores, engine, protocol } = await boot('host'); + await publishIntoRuntimePackage(protocol); + + await syncAudienceBindingSuggestions(engine); + expect(suggestionRows(stores, SET)).toEqual([ + expect.objectContaining({ + package_id: RUNTIME_PKG, anchor: 'everyone', status: 'pending', deployment_decision: false, + }), + ]); + }); + + it('the catalog read and the protocol read name the same owner', async () => { + const { protocol, catalog } = await boot('host'); + await publishIntoRuntimePackage(protocol); + + // The catalog first: the protocol's by-name read is not a list read, but + // it is read second so nothing it does can stand in for the publish. + const resolved = await catalog.resolve('permission', SET); + const served: any = await protocol.getMetaItem({ type: 'permission', name: SET }); + + expect(resolved).toMatchObject({ source: 'registry', packageId: RUNTIME_PKG }); + expect(served?.item?._packageId).toBe(RUNTIME_PKG); + }); +}); + +describe('#23233 — controls: the owner is the binding, never invented', () => { + it.each(['host', 'environment'] as const)( + 'a code package\'s default set is unchanged (%s kernel): its artifact, its owner and its suggestion', + async (kind) => { + const { stores, engine, catalog } = await boot(kind); + + expect(await catalog.resolve('permission', CODE_SET)).toMatchObject({ source: 'registry', packageId: CODE_PKG }); + await syncAudienceBindingSuggestions(engine); + expect(suggestionRows(stores, CODE_SET)).toEqual([ + expect.objectContaining({ package_id: CODE_PKG, anchor: 'everyone', status: 'pending' }), + ]); + }, + ); + + it('a set saved into no package stays unowned and records no suggestion', async () => { + const { stores, engine, protocol, catalog } = await boot('host'); + expect(await protocol.saveMetaItem({ + type: 'permission', name: 'org_member_default', item: { ...newDefaultSet(), name: 'org_member_default' }, + })).toMatchObject({ success: true }); + + const resolved = await catalog.resolve('permission', 'org_member_default'); + expect(resolved).toMatchObject({ source: 'registry' }); + expect(resolved).not.toHaveProperty('packageId'); + await syncAudienceBindingSuggestions(engine); + expect(suggestionRows(stores, 'org_member_default')).toEqual([]); + }); +});