Skip to content

Commit 6d96721

Browse files
fix(plugin-security): an environment kernel's security catalog serves metadata-door permission sets and positions again (#23334)
Fixes #23308 Clause-②: no On an environment kernel, a permission set or position written through the metadata door is in the security catalog again: it resolves, it grants, and an `isDefault: true` set in a runtime package records its `everyone` suggestion. This is the regression C3 stage 8 (#23222) introduced, fixed as A2: a SecurityPlugin hook that keeps only the echo. ## Why it broke The security catalog (ADR-0131 D3) reads the engine registry first, then the kernel's metadata service. - On a host kernel, the metadata door hydrates every stored row into the engine registry, so nothing else is needed. - On an environment kernel, it does not: `applyRegistryWriteThrough` returns before the hydrator when the kernel has an `environmentId`. The catalog can find a stored set only in the metadata service. Only one thing put stored sets there: the permission-set projector's in-memory echo (`syncEvaluatorRegistry`). Stage 8 unwired the projector, and the echo went with it. Since then, on an environment kernel, every metadata-door set: - resolves to nothing; - grants nothing (fail-closed); - records no suggestion. `permission-set-echo-package-owner.test.ts` stayed green because it wired the projector itself. Positions never had an echo, so on an environment kernel a metadata-door position was unresolved before stage 8 too. **Premise, re-checked on this base.** Nothing else on `main` feeds an environment kernel's metadata service for a metadata-door `permission` or `position`: - The other `registerInMemory` callers are boot-time stack and code items in `app-plugin.ts`, the datasource plugin and the metadata manager itself. - Measured: the new boot test below, run on `main` `2808069121` without the fix, fails all six environment-kernel legs. The host control passes. ## What this changes - **New `security-catalog-echo.ts`.** It holds the echo alone: `stripDecorations`, `isProjectionEcho` and `readServedPackageBinding` (generalized to the type) moved out of the projector. - On a landed save, publish or delete of a `permission` or `position`, it reads the environment-wide overlay through `getMetaItemLayered`. - It registers the stripped body in the metadata service, stamped `_envProjection`. When the item is tenant-authored it also gets `_provenance: 'org'` (#21789) and the owning `_packageId` (#23233). - For a permission set, "tenant-authored" is the packaged-permission-set lock's verdict, as before. - A position is echoed only when no declaration of its name is served (next point), so an echoed position is always the stored overlay alone. - It echoes only a name that the engine registry does not serve and that the kernel's metadata service does not already hold as a non-echo item, such as a declaration an app plugin registered there. That is the environment-kernel shape, and a declaration is never shadowed by an overlay's echo. - On a host kernel the stored row is in the registry, so the catalog never reads an echo. - A first build echoed there too. Dogfood caught it: `deleteMetaItem`'s registry heal re-registered the echo as a plain registry item, so a deleted set and an uninstalled package's set went on being served (`permission-metadata-door-reaches-catalog`, `package-uninstall-revokes-named-grants`). - A declared artifact is likewise never echoed. - When the overlay is deleted, it drops our echo once the stored row is gone, plus any copy of it that leaked into the registry. Nothing is dropped while the registry serves the name. The metadata service has no in-memory-only removal: its `unregister` also deletes through writable `datasource:` loaders and publishes a `deleted` event. A package uninstall deletes each row through `deleteMetaItem`, so the same projector covers that case. - It writes no row: no `sys_permission_set` write comes back. It decides nothing about authorization. - **`security-plugin.ts`** registers it as the protocol's awaited mutation projector for both types. On `kernel:ready` (`runBootstrap`) it runs one pass over every stored, active, environment-wide overlay of both types, before the suggestion reconcile reads the catalog. - **`permission-set-projection.ts`** stays in the tree, unwired. It imports the moved helpers from the new module, so 7a's deletion of it is a pure removal. - No edit to `packages/metadata-protocol/src/protocol.ts`. - No export of the package entry changes. ## Tests - **`packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts` (new).** This is the boot-level pin, and it wires nothing itself. It boots cloud's per-project composition: an environment-scoped `ObjectQLPlugin` plus `createMetadataProtocolPlugin`, the real metadata service, `AuthPlugin` and `SecurityPlugin`, over a SQLite file. It asserts on the environment kernel: - the runtime-package set resolves with source `metadata` under its owning package; - its read is refused before the grant and granted after; - one pending `everyone` suggestion is recorded; - the no-package set resolves unowned and grants; - a position resolves, and its holder reads through the set it names; - after a restart over the same file, with the overlays confirmed as stored rows through a connection of the test's own, all three resolve and grant again; - after a `deleteMetaItem` of the no-package set and a `deletePackage` of the runtime package, neither set is served or grants, while the position still resolves; - a row stored first, then a redeploy whose stack declares the same position name: after the restart the declaration is served, not the row, and deleting the row leaves the declaration served. The host kernel runs the same scenario as a control. - **Ablation, measured on the final code:** - With the hook not registered, all seven environment legs fail and both host legs pass. Without the fix, `main` fails the six authoring and restart legs the same way. - With the projector kept and only the `kernel:ready` pass removed, only the restart leg and the leg after it fail. - **`security-catalog-echo.test.ts`** (renamed from `permission-set-echo-package-owner.test.ts`): - All six #23233 cases now run on the new module. The edit-surface case now pins that no `sys_permission_set` row is written; it used to pin the row's `managed_by`. The failed-binding case drives `echoSecurityCatalogMutation`. - The two #21789 echo-stamp cases (`_provenance: 'org'` only on an `org` verdict) are re-homed beside them. - The legacy-overlay control of #21789 becomes "a name the registry already serves is not echoed", covering both a host kernel's stored row and a declared artifact. - New cases cover: - drop and skip, including removal of an echo that leaked into the registry; - positions, with a declared-position control, and a declaration registered through the metadata service that an overlay of the same name neither shadows nor, when deleted, removes. That pin goes red with the metadata-service skip removed; - the boot pass, which reads only active environment-wide rows and is loud when the table is unreadable. ## Acceptance notes - 7a (#22975) stays parked until this lands. It then deletes the projector and its suite. - A1 (the same echo inside the metadata door, `protocol.ts`) was not built, per the dispatch. ## Verification On head `a966a51bc1` (`main` `688a39d770` merged in): - Full build: 74/74 tasks. `plugin-security` typecheck: exit 0. - `plugin-security` unit suite: 189/189 files, 3,878 passed (45 skipped). - The full CLI integration tier (`--project integration`): 122/122 files, 1,068 tests passed, including the boot test, 9/9. - Dogfood: 256 files passed (1 skipped), 2,163 tests passed. This includes `permission-metadata-door-reaches-catalog` and `package-uninstall-revokes-named-grants`. - `dispatch-gates --ran` re-reconciled on this tree: 76/76, with 0 NOT-MEASURED and 0 UNRUN. On `2f3ea34fc5`, the patch round's head before the last merge of `main`: - The derived sweep itself (`dispatch-gates --commands`): 76 derived and 76 run, all exit 0. That includes the three symbol-anchor sweeps, both censuses and `check:dual-build-cjs-loads`. eslint on the changed files: exit 0. - An earlier sweep went red on `check:engine-double-contract`, because the ledger still named the renamed suite. It was regenerated with `--write`. - It also went red on `check:objectql-double-limit`, because the boot-pass `find` double ignored `limit`. The double now applies it. - The last merge of `main` touched `protocol.ts` and `suggested-audience-bindings.ts`, so the tiers above were run again on the pushed head. Also: - objectui pinned sibling: no export of `@objectstack/plugin-security`'s entry changes, so nothing the console imports moves. - **Contract review:** PASS 6154942355 at `4051c1349a`, with ③-1 (a declaration held only by the metadata service) and ③-2 (no removal while the stored row exists) fixed in `22813f7459`. Then the delta review PASS 6156782288 at `a966a51bc1`. - **#23345** (seat 1, #23298): on an environment kernel that hydrates its own store, the registry serves the name, so this echo stays out of the way. Whichever lands second merges `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 24e064f commit 6d96721

8 files changed

Lines changed: 1239 additions & 345 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): an environment kernel's security catalog serves metadata-door permission sets and positions again
6+
7+
Clause-②: no
8+
9+
On an environment kernel (the per-project kernel a hosted tenant runs), a permission set or a position saved, published or deleted through the metadata door is in the security catalog again:
10+
11+
- It resolves from the kernel's metadata service, under the package its stored row is bound to.
12+
- A user granted it is authorized by it.
13+
- An `isDefault: true` set in a runtime package records its pending `everyone` suggestion again (ADR-0090 D5).
14+
15+
After a restart, the stored overlays are served again too.
16+
17+
This regressed in C3 stage 8 (`feat(plugin-security)!: retire the four security-catalog row objects`). That change unwired the permission-set projector. On an environment kernel, the projector's in-memory echo was the only thing that put a stored set into the metadata service, because the metadata door hydrates no project row into the engine registry. Since stage 8, such a set resolved to nothing and granted nothing (fail-closed), and no suggestion was recorded.
18+
19+
Positions never had an echo. On an environment kernel, a metadata-door position was unresolved before stage 8 as well, and it now resolves.
20+
21+
`SecurityPlugin` now registers its own mutation projector for `permission` and `position` on the protocol, plus one pass over the stored environment-wide overlays at `kernel:ready`. Both only register the overlay body in the metadata service. They write no row, and they change nothing a host kernel resolves, since a host kernel reads these from the engine registry first.

‎packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts‎

Lines changed: 315 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/plugins/plugin-security/src/permission-set-echo-package-owner.test.ts‎

Lines changed: 0 additions & 295 deletions
This file was deleted.

‎packages/plugins/plugin-security/src/permission-set-projection.ts‎

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import {
101101
type LayeredProbe,
102102
} from './packaged-permission-set-lock.js';
103103
import { PermissionSetNameConflictError } from './errors.js';
104+
import { isProjectionEcho, readServedPackageBinding, stripDecorations } from './security-catalog-echo.js';
104105

105106
export const SYSTEM_CTX = { isSystem: true };
106107

@@ -463,16 +464,9 @@ function touchesDefinition(payload: Record<string, any>): boolean {
463464
* is, and only one of them guards a write door.
464465
*/
465466

466-
/** Strip layered-read / registry decorations so a re-authored body is clean. */
467-
function stripDecorations(body: any): any {
468-
if (!body || typeof body !== 'object') return body;
469-
const { _packageId, _provenance, _diagnostics, _lock, _lockReason, _lockSource, [ENV_PROJECTION_MARKER]: _mark, ...clean } = body;
470-
return clean;
471-
}
472-
473-
/** True when a layered-read layer is just our own registry echo, not a real artifact. */
474-
const isProjectionEcho = (v: any): boolean =>
475-
!!(v && typeof v === 'object' && (v as any)[ENV_PROJECTION_MARKER]);
467+
// `stripDecorations` and `isProjectionEcho` live in `security-catalog-echo.ts`
468+
// (imported above), the module that owns the echo since C3 stage 8 unwired
469+
// this projector.
476470

477471
/**
478472
* Read the DECLARED (artifact) body for a permission set from the engine's
@@ -788,26 +782,9 @@ async function syncEvaluatorRegistry(
788782
} catch { /* best-effort */ }
789783
}
790784

791-
/**
792-
* [#23233] The package the stored row a by-name read SERVES for `name` is
793-
* bound to: the `_packageId` the protocol's single-item read states on the
794-
* item from that row's `package_id` column. `undefined` when the row is bound
795-
* to no package, or the protocol has no single-item read (minimal embeddings,
796-
* unit-test stubs), which keys no row by package either.
797-
*
798-
* ⛔ The one read of that binding in this module — the data door's update leg
799-
* ({@link createPermissionSetWriteThrough}, #21861) and the registry echo
800-
* ({@link syncEvaluatorRegistry}) both ask it. Never from a patch, the
801-
* projected record or a registry item: those are copies, and the row is the
802-
* fact. A read that fails is not caught here; each caller decides what a
803-
* binding it could not read means for its own write.
804-
*/
805-
async function readServedPackageBinding(protocol: any, name: string): Promise<string | undefined> {
806-
if (typeof protocol?.getMetaItem !== 'function') return undefined;
807-
const served = await protocol.getMetaItem({ type: 'permission', name });
808-
const packageId = served?.item?._packageId;
809-
return typeof packageId === 'string' && packageId !== '' ? packageId : undefined;
810-
}
785+
// [#23233] `readServedPackageBinding`, the one read of a stored row's package
786+
// binding — asked by the data door's update leg and the registry echo here —
787+
// lives in `security-catalog-echo.ts` (imported above).
811788

812789
/** Drop the in-memory `permission` entry for a retired definition. */
813790
function dropEvaluatorRegistryEntry(metadata: any, name: string): void {

0 commit comments

Comments
 (0)