Skip to content

Commit c9be1f1

Browse files
fix(plugin-security): the permission-set lock reads the row's provenance, so org-owned sets, clones and runtime-package sets edit again (#21857)
Fixes #21789 Clause-②: no ## What this changes The packaged-permission-set lock in `plugin-security` answers one question for both write doors: is this set shipped by a code (artifact) package? It answered it as "does any engine-registry item of this name carry a package id?". The registry holds stored rows as well as artifacts, and the metadata list read (`GET /api/v1/meta/permission`, which every Studio page load issues) stamps a stored row's `package_id` column onto its body as `_packageId`. So a set saved into a writable runtime package looked code-shipped after the first list read. The read the console renders from had the matching defect. The security plugin keeps a marked copy of every overlay-backed definition in the metadata manager for the evaluator (the "projection echo"), and the protocol's layered read serves that copy as the item's `code` layer. The echo carried no provenance, so an org's own set, a clone and a runtime-package set all reported a `code` layer with no `provenance`. objectui's permission-matrix editor reads exactly that as "a code package ships this" and rendered them locked, while every write door accepted the save. Two edits, both in `packages/plugins/plugin-security/src`: 1. `packaged-permission-set-lock.ts`, `declaredPackageIdOf`: a tenant-authored item (ADR-0010 `_provenance: 'org'`, the stamp the hydrator writes on every stored row) is never a shipped artifact. It is read through `isTenantAuthored` from `@objectstack/metadata-core`, the exclusion `isCodeArtifactBody` and `SchemaRegistry.getArtifactItem` already apply. No second provenance evaluator. A stored row of a name a code package ships is hydrated wearing the artifact's envelope (`_provenance: 'package'`), and the artifact itself is in the same list, so a code-shipped set stays locked. 2. `permission-set-projection.ts`: the projection echo carries `_provenance: 'org'` exactly when `classifyPackagedPermissionSet` (the classifier both write doors ask, fed the same layered probe) answers `org` for the name. A `packaged` or `unknown` verdict leaves the echo unstamped, as before. The reported state and the enforced state are one judgment. Not touched: `metadata-protocol` (H4 was not needed), `packages/spec`, any error code, any export, any parameter of an exported function (the new parameter is on the module-private `syncEvaluatorRegistry`). The lock-resolution semantics from #21801 are unchanged. ## Measurements, before and after Driven through the real showcase over HTTP, base `088428fb` (before) and this branch (after): | shape | before: door (`PUT /meta` after the list read, `PATCH /data`) | before: layered read | after: door | after: layered read | |---|---|---|---|---| | set in a writable runtime package | 403 `NOT_OVERRIDABLE` / 403 `NOT_OVERRIDABLE` | `code` = echo, no `provenance`, `editable: true` | 200 / 200 | `provenance: 'org'`, `editable: true` | | org-owned set (data door) | 200 / 200 | `code` = echo, no `provenance`, `editable: true` | 200 / 200 | `provenance: 'org'`, `editable: true` | | clone ("Clone to customize") | 200 / 200 | `code` = echo, no `provenance`, `editable: true` | 200 / 200 | `provenance: 'org'`, `editable: true` | | control: `showcase_contributor` (shipped by `com.example.showcase`) | 403 `NOT_OVERRIDABLE` / 403 `NOT_OVERRIDABLE` | `code._packageId` = the package, `provenance: 'package'`, `editable: false` | unchanged | unchanged | The runtime-package set's registry row after the list read was `{ _packageId: 'com.dogfood.lock21789', _provenance: 'org' }`: the provenance that tells it apart was on the body all along. ## Mechanism hypotheses, measured - **H1, holds.** The lock read any non-sentinel `_packageId` as code-shipped. The three shapes carry, in the registry: runtime-package set `{ _packageId: PKG, _provenance: 'org' }` (the package id appears only after a list read; neither the write-through nor the boot hydration stamps it), org-owned set and clone `{ _provenance: 'org' }`, no package id. The clone's record has `created_by` and `organization_id` null, but so do the org-owned set's and the runtime-package set's records: it is not specific to the clone. - **H2, holds.** The platform's one answer is `isCodeArtifactBody` / `isTenantAuthored` in `@objectstack/metadata-core` (already a dependency of `plugin-security`). The lock reuses `isTenantAuthored`; it keeps its two documented extensions (the echo-marker skip and the spec `packageId` fallback). - **H3, holds, with a refinement.** The org-owned set and the clone were never refused by the server (both doors 200 before the fix); their "lock" was report-only. The runtime-package set was refused by both doors while the server's own `editable` said `true`. So the reported state and the enforced state were split in both directions, and the fix pins both. - **H4, not needed.** No `metadata-protocol` edit: the layered read already reads `provenance` off the `code` layer, and the echo now states it. - **H5, the lock's judgment (the smaller one).** Stamping the clone's `created_by` / organization would not change anything the lock or the console reads: the server lock already answered `org` for the clone, and the console's lock came from the echo's missing provenance. - **H6, holds.** The code-shipped set is still refused at both doors with `403 NOT_OVERRIDABLE` (the data door's refusal is the lock's own sentence naming the clone path), and its layered read still reports `provenance: 'package'`, its package id and `editable: false`, before and after a cold boot. ## Pins - `packaged-permission-set-lock.test.ts`, block `[#21789]`: the classifier over the bodies the hydrator registers (runtime-package row, org-owned row, clone; a shipped artifact, alone and beside a legacy overlay wearing its envelope, in both orders), the layered probe with no registry, the data door (hatch-open double, so only the lock can refuse), and the metadata-door gate, with the refusal asserted on `code` and `status`. - `permission-set-projection.test.ts`: the echo of a set no code package ships carries `_provenance: 'org'`; the control shows the echo of a legacy overlay of a shipped set does not. - `packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts` (new): the showcase, the three shapes made through their real doors (`POST /packages` then `PUT /meta/permission/NAME?package=PKG`; `POST /data/sys_permission_set`; the shipped `clone_permission_set` action's own payload), the list read, a precondition that the list read stamped the package id, then both doors and the layered read for each shape, the code-shipped control at both doors and on the read, and a cold boot on the same file that reads the three shapes again (the echo minted by the boot's reconciliation) and re-checks the control. ## Ablations (each committed first, mutated through `scripts/ablation-replace.mjs`, rebuilt, dist proven, restored to `HEAD`) Both ablations were run at `e9dff47f` (the fix and its pins committed, pre-merge), each through `node scripts/ablation-replace.mjs` (anchor hits went from 1 to 0, blob changed), then `pnpm turbo run build --filter=@objectstack/plugin-security`, then `node scripts/ablation-dist-preflight.mjs @objectstack/plugin-security MARKER --absent` (exit 0: marker absent from every built file), because the dogfood suite resolves `plugin-security` from `dist/`. Each restore was proven by blob equality with `HEAD` and an empty `git diff HEAD`, then a rebuild and the preflight without `--absent` (exit 0, marker back in `dist/index.js`, tree clean). | ablation | what was put back | unit result | dogfood result | |---|---|---|---| | 1 | the lock reads "has a package id" again: `if (isTenantAuthored(item)) return null;` replaced by a no-op | 5 failed / 87 passed: the four classifier/door pins for the runtime-package shape, and the echo pin | 2 failed / 12 passed: runtime-package set, metadata door and data door | | 2 | the echo states no provenance again: the `_provenance: 'org'` spread replaced by an empty one | 1 failed / 91 passed: the echo pin | 4 failed / 10 passed: the layered-read pin for each of the three shapes, and the cold-boot read | The controls (a code-shipped set refused, and its read reporting `provenance: 'package'`) stayed green in both directions, as they must. A first attempt at ablation 1 used a replacement that left the `isTenantAuthored` import unused, so the DTS step of the build failed (the JS bundle still carried the ablation and the same pins went red); it was redone with the import kept in use, and the numbers above are from the clean run. ## Tests and gates All on `9e3e32ed` (this branch after merging `origin/main` `8832655a`, which carries #21812 and touches `plugin-security`), after rebuilding the dogfood dependency closure: - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: 167 files passed, 3600 tests passed, 45 skipped. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 (including `check:test-typecheck`: 0 errors). - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/permission-set-lock-row-provenance.dogfood.test.ts`: 14 passed. `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 71 gate commands; all 71 run, every exit 0, `--ran` verdict: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads` first answered exit 3 (PREREQUISITE NOT MET: eight packages outside the dogfood closure had no `dist/`); those eight were built and the gate re-run, exit 0. - Lint, narrowed and proven: `pnpm exec eslint --no-inline-config --format json` over the five touched TypeScript files (the changeset is not linted): 5 files in the JSON output, 0 errors, 0 warnings. The population is the files this diff touches; `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules, stated in its own header), so this diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **Not in this PR: `metadata-protocol`.** Measured, the layered read did not need to change for the read and the lock to agree: it reads `provenance` off its `code` layer, which is the plugin's projection echo, and all three shapes read `provenance: 'org'` with `protocol.ts` byte-identical to `main`. No shape is left unfixed without a protocol edit. This PR's file list is disjoint from #21844's (`item-lock.ts`, `protocol.ts`, two protocol/objectql tests, the engine-double ledger, its changeset). - **Observation, not filed (carrier: the `domain:engine` seat, #21844 holds the region).** For a set no artifact ships, the layered read's `code` layer is still a non-null body (the echo, read through `readItemFromMetadataService` in `getMetaItemLayered`), while `GetMetaItemLayeredResponseSchema.code` says `null` when no artifact ships the item. The registry fallback right below it already drops a tenant-authored item (`runtimeOnly`, `isTenantAuthored`); the MetadataService read does not. After this PR the echo is tenant-stamped, so a provenance-only filter there would answer `code: null` for these sets; no client reads a wrong answer today, which is why it is noted here rather than built. - **Finding, reported for the seat to file (same family: a package id read as "shipped by code").** The Discard Overlay action's eligibility (`permission-set-overlay-discard.ts`, `discardPermissionSetOverlay`) reads `_packageId ?? packageId` on the registry item, as the lock did. Measured on `088428fb` and again on this branch: after a list read, `POST /api/v1/security/permission-sets/ID/discard-overlay` on a set saved into a writable runtime package answered 200 and deleted the set's only `sys_metadata` row. The action declares, and `content/docs/permissions/permission-sets.mdx` repeats, that it refuses any set that is not currently package-declared. `permission-set-drift.ts`'s declared filter carries the same reading. Not fixed here: outside the claimed file surface. - **Finding, reported for the seat to file.** A data-door edit (`PATCH /api/v1/data/sys_permission_set/ID`) of a set saved into a writable runtime package writes a second, package-less active `sys_metadata` row carrying the edit and leaves the package-bound row unchanged (the write-through's update leg calls `saveMetaItem` without the row's package). Measured on this branch: two active rows after one PATCH; the projected record reads `managed_by: 'admin'`, `package_id: null`. It is reachable on `main` before any list read, and through a package-less `PUT /meta`; this PR lets the data door accept the edit after a list read too. - **H5.** The clone's record has `created_by` and `organization_id` null, and so do the other two shapes' records: the projection writes them in system context. It is not what locked the clone, and is not changed here. - **Docs.** No `content/docs` sentence is made false by this change; `content/docs/concepts/metadata-lifecycle.mdx` (runtime-created sets, package-bound rows included, keep working) becomes true. `content/docs/permissions/permission-sets.mdx` still says an edit of a packaged set through Setup becomes an environment overlay, which the lock has refused since the clone-to-customize ruling; that is older drift, not touched here. - **Report state.** `Clause-②: no` is copied from the claim: the fix restores the lock's declared population (code-shipped sets) and widens no accepted input; a code-shipped set is refused exactly as before. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 969ffba commit c9be1f1

6 files changed

Lines changed: 466 additions & 2 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
A permission set an organization owns, a clone of a packaged set, and a set saved into a writable runtime package are no longer locked as if a code package shipped them
6+
7+
Clause-②: no
8+
9+
The packaged-permission-set lock decides "is this set shipped by a code package?" from the engine registry. The registry also holds the stored definition rows, and a metadata list read (`GET /api/v1/meta/permission`, which every Studio page load issues) stamps a stored row's package binding onto it. A set saved into a writable runtime package (`PUT /api/v1/meta/permission/:name?package=<id>`) therefore looked code-shipped after the first list read, and every later edit of it answered `403 NOT_OVERRIDABLE` at both the metadata door and the data door. The lock now skips a stored row by its provenance (`_provenance: 'org'`, which every stored row carries), the same test the platform's code-artifact check applies, so those edits are accepted again.
10+
11+
The read had the matching defect. The security plugin keeps a marked in-memory copy of each stored definition for the permission evaluator, and the layered read (`GET /api/v1/meta/permission/:name/layers`) serves that copy as the item's `code` layer. The copy carried no provenance, so an org's own set, a clone and a runtime-package set all reported a `code` layer with no `provenance`, which the console's permission-matrix editor renders as "locked by a code package" while the server accepted the save. The copy now carries `_provenance: 'org'` exactly when the lock judges the set not code-shipped, so the layered read reports `provenance: 'org'` for those sets.
12+
13+
Unchanged: a set a code package ships is still refused at both doors with `403 NOT_OVERRIDABLE` and the same message naming the clone path, and its layered read still reports `provenance: 'package'`, its package id and `editable: false`. No error code, route or field moves.

‎packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ import { describe, it, expect } from 'vitest';
7777
import { PermissionSetSchema } from '@objectstack/spec/security';
7878
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
7979
import { SysPermissionSet } from './objects/sys-permission-set.object.js';
80+
import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js';
81+
import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js';
8082
import {
8183
createPermissionSetWriteThrough,
8284
permissionSetRowFields,
@@ -885,3 +887,101 @@ describe('control B — the provenance read is not a name-keyed table read at al
885887
expect(ql.metaRows.length).toBe(0);
886888
});
887889
});
890+
891+
// ─────────────────────────────────────────────────────────────────────────────
892+
// [#21789] The lock reads the ROW's provenance, not the presence of a package id
893+
// ─────────────────────────────────────────────────────────────────────────────
894+
895+
/**
896+
* The registry holds stored rows as well as artifacts. The metadata layer
897+
* hydrates every env-wide `sys_metadata` row into it (a list read, and the
898+
* boot) as `mergeArtifactProtection(stateTenantAuthorship(row), artifact)`: the
899+
* row's `package_id` column stamped on as `_packageId`, `_provenance: 'org'`
900+
* written over it, and, only where a code artifact ships the name, the
901+
* artifact's envelope written over that. These builders produce exactly those
902+
* three bodies, so the verdicts below are about the shapes the boot really
903+
* registers, measured on a booted showcase (the dogfood pin
904+
* `permission-set-lock-row-provenance.dogfood.test.ts` asserts the hydrated
905+
* shape before it asserts the edit).
906+
*/
907+
const RUNTIME_PACKAGE = 'app.crm_workspace';
908+
/** Shape 1: a set saved into a writable runtime package, as hydrated. */
909+
const hydratedRuntimePackageRow = (over: Record<string, any> = {}) => ({
910+
...orgSet({ name: 'workspace_rep' }),
911+
_packageId: RUNTIME_PACKAGE,
912+
_provenance: 'org',
913+
...over,
914+
});
915+
/** Shapes 2 and 3: the org's own set, and a clone, as hydrated (no package column). */
916+
const hydratedOrgRow = (over: Record<string, any> = {}) => ({ ...orgSet(), _provenance: 'org', ...over });
917+
/** A code-shipped artifact as the loader registers it (`applyProtection` with a package id). */
918+
const shippedArtifact = (over: Record<string, any> = {}) => packagedSet({ _provenance: 'package', ...over });
919+
920+
describe('[#21789] the lock judges "shipped by a code package" from the row\'s provenance', () => {
921+
it('a set in a writable runtime package, hydrated with its package id, is NOT packaged', () => {
922+
expect(classifyPackagedPermissionSet('workspace_rep', makeQl([hydratedRuntimePackageRow()])))
923+
.toEqual({ status: 'org' });
924+
});
925+
926+
it('an org-owned set and a clone, hydrated as stored rows, are NOT packaged', () => {
927+
const ql = makeQl([hydratedOrgRow(), hydratedOrgRow({ name: 'ehr_quality_inspector_local' })]);
928+
expect(classifyPackagedPermissionSet('org_support_agent', ql)).toEqual({ status: 'org' });
929+
expect(classifyPackagedPermissionSet('ehr_quality_inspector_local', ql)).toEqual({ status: 'org' });
930+
});
931+
932+
it('control: a code-shipped set stays packaged, alone and beside a stored row of its name', () => {
933+
expect(classifyPackagedPermissionSet('ehr_quality_inspector', makeQl([shippedArtifact()])))
934+
.toEqual({ status: 'packaged', packageId: 'com.example.ehr' });
935+
// A legacy overlay of a shipped name is hydrated wearing the artifact's
936+
// envelope, and the artifact is in the same list: still packaged, in either order.
937+
const legacyOverlay = shippedArtifact({ systemPermissions: ['overlaid'] });
938+
for (const items of [[legacyOverlay, shippedArtifact()], [shippedArtifact(), legacyOverlay]]) {
939+
expect(classifyPackagedPermissionSet('ehr_quality_inspector', makeQl(items)))
940+
.toEqual({ status: 'packaged', packageId: 'com.example.ehr' });
941+
}
942+
});
943+
944+
it('the layered read\'s code layer is judged the same way when there is no registry', () => {
945+
const ql = makeQl(null);
946+
const read = (code: unknown) => ({ status: 'read' as const, envelope: { code, overlay: null, effective: code } });
947+
expect(classifyPackagedPermissionSet('workspace_rep', ql, read(hydratedRuntimePackageRow())))
948+
.toEqual({ status: 'org' });
949+
expect(classifyPackagedPermissionSet('ehr_quality_inspector', ql, read(shippedArtifact())))
950+
.toEqual({ status: 'packaged', packageId: 'com.example.ehr' });
951+
});
952+
953+
it('the data door accepts an edit of a runtime-package set, and the edit lands', async () => {
954+
const ql = makeQl([hydratedRuntimePackageRow()]);
955+
const protocol = makeHatchOpenProtocol(ql, {});
956+
registerPermissionSetProjection(protocol, { ql });
957+
await protocol.saveMetaItem({ type: 'permission', name: 'workspace_rep', item: orgSet({ name: 'workspace_rep' }) });
958+
const row = ql.permRows.find((r: any) => r.name === 'workspace_rep');
959+
expect(row, 'precondition: the projected row exists').toBeTruthy();
960+
const savesBefore = protocol.saves.length;
961+
962+
const rejection = await run(makeMiddleware(ql, protocol), {
963+
object: 'sys_permission_set', operation: 'update', context: userCtx,
964+
data: { id: row.id, system_permissions: '["support.use","workspace.only"]' },
965+
}).then(() => null, (e: any) => e);
966+
967+
expect(rejection, 'the lock must not refuse a set no code package ships').toBeNull();
968+
expect(protocol.saves.length, 'the definition write LANDED').toBe(savesBefore + 1);
969+
expect(JSON.parse(ql.permRows.find((r: any) => r.name === 'workspace_rep').system_permissions))
970+
.toEqual(['support.use', 'workspace.only']);
971+
});
972+
973+
it('the metadata door accepts a runtime-package set and still refuses a code-shipped one with 403 NOT_OVERRIDABLE', async () => {
974+
const ql = makeQl([hydratedRuntimePackageRow(), shippedArtifact()]);
975+
let gate: ((ctx: { type: string; name: string; body: unknown }) => Promise<void>) | undefined;
976+
const protocol = {
977+
registerAuthoringGate: (_type: string, g: typeof gate) => { gate = g; },
978+
getMetaItemLayered: async ({ name }: { name: string }) => ({ type: 'permission', name, code: null, overlay: null, effective: null }),
979+
};
980+
expect(registerPackagedPermissionSetLockGate(protocol, ql)).toBe(true);
981+
982+
await expect(gate!({ type: 'permission', name: 'workspace_rep', body: orgSet({ name: 'workspace_rep' }) }))
983+
.resolves.toBeUndefined();
984+
await expect(gate!({ type: 'permission', name: 'ehr_quality_inspector', body: packagedSet() }))
985+
.rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
986+
});
987+
});

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@
8787
* direction that cannot be undone (an overlay, once minted, wins forever).
8888
*/
8989

90+
import { isTenantAuthored } from '@objectstack/metadata-core';
91+
9092
/**
9193
* ⛔ This module imports NOTHING from the rest of the plugin, and that is
9294
* structural rather than stylistic: `permission-set-projection.ts` imports
@@ -146,7 +148,24 @@ export type LayeredProbe =
146148
| { status: 'read'; envelope: unknown }
147149
| { status: 'failed'; reason: string };
148150

149-
/** Is this registry/layer item a real shipped artifact for `name`? */
151+
/**
152+
* Is this registry/layer item a real shipped artifact for `name`?
153+
*
154+
* [#21789] A package id on the item does not answer that by itself. The
155+
* registry holds stored rows as well as artifacts: the metadata layer hydrates
156+
* env-wide `sys_metadata` rows into it, and the list read (`getMetaItems`)
157+
* stamps the row's `package_id` column onto the body as `_packageId` on the
158+
* way. A set saved into a writable runtime package therefore carries a package
159+
* id just as a code-shipped one does, and reading "has a package id" as
160+
* "shipped by code" locked it after the first list read. What tells the two apart is the
161+
* provenance the hydrator writes on every stored row (ADR-0010
162+
* `_provenance: 'org'`), read through `isTenantAuthored`: the exclusion
163+
* `isCodeArtifactBody` and `SchemaRegistry.getArtifactItem` already apply, so
164+
* this is the platform's one answer, not a second one. A stored row of a name a
165+
* code package ships is hydrated wearing the artifact's envelope
166+
* (`_provenance: 'package'`), and the artifact itself is in the same list, so a
167+
* code-shipped set stays locked.
168+
*/
150169
function declaredPackageIdOf(item: any, name: string): string | null {
151170
if (!item || typeof item !== 'object') return null;
152171
if (item.name !== name) return null;
@@ -155,6 +174,9 @@ function declaredPackageIdOf(item: any, name: string): string | null {
155174
// set look packaged on the next pass, which is a lock that latches on the
156175
// wrong evidence.
157176
if (item[ENV_PROJECTION_MARKER]) return null;
177+
// A stored (tenant-authored) row is never a shipped artifact, whatever
178+
// package it is bound to. See the doc comment.
179+
if (isTenantAuthored(item)) return null;
158180
const packageId = item._packageId ?? item.packageId;
159181
if (typeof packageId !== 'string' || packageId === '') return null;
160182
if (packageId === RUNTIME_SHADOW_PACKAGE_ID) return null;

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,40 @@ describe('projectPermissionMutation (the awaited projector)', () => {
419419
expect(healed?._envProjection).toBeUndefined();
420420
});
421421

422+
// [#21789] The echo is served by the protocol's layered read as the item's
423+
// `code` layer, and the envelope's `provenance` is read off it — so the echo
424+
// must say what the lock says, or the read reports a lock the doors do not
425+
// enforce (an org's own set rendered "locked by a code package").
426+
it('[#21789] the echo of a set no code package ships says so: `_provenance: \'org\'`', async () => {
427+
const ql = makeQl();
428+
// The registry holds the stored row the way the hydrator registers it —
429+
// a runtime package id stamped on, tenant provenance written over it.
430+
(ql as any).registry = {
431+
listItems: (t: string) => (t === 'permission'
432+
? [{ ...envBody(), _packageId: 'app.crm_workspace', _provenance: 'org' }]
433+
: []),
434+
};
435+
const protocol = makeProtocol(ql);
436+
ql.metaRows.push({ id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', organization_id: null, metadata: JSON.stringify(envBody()) });
437+
const metadata = makeMetadataFacade();
438+
await projectPermissionMutation(protocol, { ql, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' });
439+
const entry = metadata.registry.get('permission/organization_admin');
440+
expect({ marker: entry?._envProjection, provenance: entry?._provenance }).toEqual({ marker: true, provenance: 'org' });
441+
});
442+
443+
it('[#21789] control: the echo of a legacy overlay of a code-shipped set does NOT claim tenant provenance', async () => {
444+
const ql = makeQl();
445+
const shipped = { ...envBody({ systemPermissions: ['declared.only'] }), _packageId: 'com.example.crm', _provenance: 'package' };
446+
(ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [shipped] : []) };
447+
const protocol = makeProtocol(ql, { organization_admin: shipped });
448+
ql.metaRows.push({ id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', organization_id: null, metadata: JSON.stringify(envBody({ systemPermissions: ['overlaid'] })) });
449+
const metadata = makeMetadataFacade();
450+
await projectPermissionMutation(protocol, { ql, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' });
451+
const entry = metadata.registry.get('permission/organization_admin');
452+
expect(entry?.systemPermissions, 'precondition: the overlay was projected').toEqual(['overlaid']);
453+
expect({ marker: entry?._envProjection, provenance: entry?._provenance ?? null }).toEqual({ marker: true, provenance: null });
454+
});
455+
422456
it('skips draft saves and non-permission events', async () => {
423457
const ql = makeQl();
424458
const protocol = makeProtocol(ql);

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import type { PermissionSetNameCollisionDiagnostic } from './permission-set-name
9797
import {
9898
ENV_PROJECTION_MARKER,
9999
assertPermissionSetNotPackageDeclared,
100+
classifyPackagedPermissionSet,
100101
type LayeredProbe,
101102
} from './packaged-permission-set-lock.js';
102103
import { PermissionSetNameConflictError } from './errors.js';
@@ -723,6 +724,21 @@ export async function upsertEnvPermissionSet(
723724
* artifact after the overlay is gone. When the overlay disappears, a stale
724725
* echo is healed back to `restoreTo` (the declared body) or dropped.
725726
*
727+
* [#21789] ⭐ The echo also states its PROVENANCE, and it states the lock's
728+
* verdict. The metadata manager's get is registry-first, so the protocol's
729+
* layered read serves this echo as the item's `code` layer, and the envelope's
730+
* `provenance` is read off that layer. Unstamped, every set with an overlay row
731+
* (an org's own set, a clone, a set in a writable runtime package) reported a
732+
* code layer with no provenance, which is how a client reads "a code package
733+
* ships this" (objectui's permission-matrix editor rendered such a set locked
734+
* while every write door accepted the save). So the echo carries
735+
* `_provenance: 'org'` exactly when {@link classifyPackagedPermissionSet}, the
736+
* classifier both write doors ask, answers `org` for the name: the reported
737+
* state and the enforced state are one judgment. A `packaged` verdict (a
738+
* legacy overlay of a code-shipped set) or an `unknown` one leaves the echo
739+
* unstamped, as before, so the read keeps reporting the lock the doors keep
740+
* enforcing.
741+
*
726742
* Best-effort: when the facade lacks `registerInMemory`, overlay-only names
727743
* still resolve via the DatabaseLoader / record dbLoader.
728744
*/
@@ -731,12 +747,14 @@ async function syncEvaluatorRegistry(
731747
name: string,
732748
body: any,
733749
overlayBacked: boolean,
750+
tenantAuthored = false,
734751
): Promise<void> {
735752
try {
736753
if (!metadata || typeof metadata.registerInMemory !== 'function' || !name) return;
737754
if (overlayBacked && body?.name) {
738755
metadata.registerInMemory('permission', name, {
739756
...stripDecorations(body),
757+
...(tenantAuthored ? { _provenance: 'org' } : {}),
740758
[ENV_PROJECTION_MARKER]: true,
741759
});
742760
return;
@@ -829,6 +847,9 @@ export async function projectPermissionMutation(
829847
const { ql, metadata, logger } = deps;
830848
let body: any = null;
831849
let overlayBacked = false;
850+
// [#21789] The layered read, handed to the lock's classifier below as its
851+
// second source, exactly as the write doors hand it theirs.
852+
let layeredProbe: LayeredProbe | undefined;
832853
if (protocol && typeof protocol.getMetaItemLayered === 'function') {
833854
const layered = await protocol.getMetaItemLayered({
834855
type: 'permission',
@@ -846,6 +867,7 @@ export async function projectPermissionMutation(
846867
const isEnvelope = layered && typeof layered === 'object'
847868
&& ('effective' in layered || 'overlay' in layered || 'code' in layered);
848869
if (isEnvelope) {
870+
layeredProbe = { status: 'read', envelope: layered };
849871
const overlay = layered.overlay ?? null;
850872
overlayBacked = !!overlay;
851873
const declared = readDeclaredBody(ql, evt.name);
@@ -885,7 +907,11 @@ export async function projectPermissionMutation(
885907
// unchanged` — never "a write happened". A FAILED write still leaves all
886908
// three at zero and still skips the sync, exactly as before.
887909
if (out.seeded + out.updated + out.unchanged > 0) {
888-
await syncEvaluatorRegistry(metadata, evt.name, body, overlayBacked);
910+
// [#21789] The echo's provenance is the lock's verdict for the name — see
911+
// {@link syncEvaluatorRegistry}.
912+
const tenantAuthored = overlayBacked
913+
&& classifyPackagedPermissionSet(evt.name, ql, layeredProbe).status === 'org';
914+
await syncEvaluatorRegistry(metadata, evt.name, body, overlayBacked, tenantAuthored);
889915
}
890916
return out;
891917
}

0 commit comments

Comments
 (0)