Skip to content

Commit 5e0b489

Browse files
fix(plugin-security): Discard Overlay refuses every permission set no code package ships, so a runtime-package set's only stored row is no longer deleted (#21873)
Fixes #21860 Clause-②: no ## What this changes The Discard Overlay action (`POST /api/v1/security/permission-sets/:id/discard-overlay`) is declared to refuse any permission set that is not package-declared, so that it can never destroy a set the environment authored (its docblock, and the permission-sets docs page). It decided "package-declared" by asking whether any engine-registry item of the set's name carried a package id (`_packageId ?? packageId`). The registry holds stored rows as well as artifacts, and the metadata list read (`GET /api/v1/meta/permission`, issued by every Studio page load) stamps a stored row's `package_id` onto its body as `_packageId`. So a set saved into a writable runtime package passed the check after the first list read: the action answered `200` and deleted the set's only `sys_metadata` row. The drift diagnostics (`computePermissionSetDriftDiagnostics`) read the package id the same way, and their `overlay_shadow` detail names Discard Overlay as the remedy. Two edits, both in `packages/plugins/plugin-security/src`: 1. `permission-set-overlay-discard.ts`: eligibility is `classifyPackagedPermissionSet`'s verdict for the set's name. That is the classifier the lock's two write doors and the overlay detection reading already ask, over the same engine registry. Only `packaged` proceeds. `org` and `unknown` are both refused with the action's existing refusal (`PermissionDeniedError`, `403 PERMISSION_DENIED`), because the safe direction for a destructive action is the reading's: a set this environment cannot prove a code package ships is never deleted. The refusal's sentence now says the set is not shipped by any installed code package (for `unknown`: that this could not be determined, with the classifier's reason). 2. `permission-set-drift.ts`: the declared population is gated on the same verdict, per name. No second evaluator. In both files the per-item test that remains below the verdict (`_packageId ?? packageId`) no longer decides anything: it only picks, among the registry items of a name the classifier has already called code-shipped, which body is used (the degraded-kernel resync body in discard, the compared bodies in drift), by the same selector as before. So for every code-shipped set the accepted population, the bodies and the audited or reported package are what they were. This builds on PR #21857 (landed as c9be1f1, merged into this branch): there the classifier learned to skip a tenant-authored registry item (`_provenance: 'org'`), so a runtime-package set's stored row reads `org`. It already excluded the `'sys_metadata'` runtime-shadow sentinel. Not touched: `packaged-permission-set-lock.ts`, `permission-set-projection.ts`, `packages/spec`, any error code, route, export or exported signature. Docs: the permission-sets page's Discard Overlay sentence now names the sets the action refuses (a set created in the environment, a clone, a set saved into a writable runtime package). "Package-declared" on its own reads as covering a runtime package's set. ## Measurements, at the door (showcase, booted through `@objectstack/verify`, two boots on one database file) | shape | old reading (ablation A below, and this branch before #21857 landed) | this PR (62143b0) | |---|---|---| | set saved into a writable runtime package, after the list read | `200`, `overlaysDiscarded: 1`: its only `sys_metadata` row deleted | `403 PERMISSION_DENIED`, every row intact | | org-owned set (data door) | `403 PERMISSION_DENIED` (no package id on its item) | `403 PERMISSION_DENIED`, every row intact | | clone ("Clone to customize") | `403 PERMISSION_DENIED` (no package id on its item) | `403 PERMISSION_DENIED`, every row intact | | control: `showcase_contributor` (shipped by `com.example.showcase`) with a legacy overlay | `200`, overlay discarded, record healed to the artifact | unchanged | The runtime-package set's registry row after the list read is `{ _packageId: 'com.dogfood.discard21860', _provenance: 'org' }` (asserted as a precondition before any refusal is read). The control's precondition: after the cold boot, its record enforces the legacy overlay's grants and the boot's drift pass wrote `drift_status: 'overlay_shadow'`. ## Mechanism hypotheses, measured - **H1 holds.** Reproduced at both levels before the fix took effect: the unit pin read "promise resolved instead of rejecting", and the door answered `200` with `overlaysDiscarded: 1` (the set's only stored definition deleted). - **H2 holds, with one refinement.** On `main` at dispatch (`5b2d189e`) the classifier alone did NOT exclude the runtime-package set: it read any non-sentinel package id. That is exactly what PR #21857 changed in the lock (`isTenantAuthored`), and why this PR waited for it and is built on it. Measured on this branch before that merge: the three runtime-package legs (two unit, one door) stayed red with the classifier swap alone, and everything else was green. Only the `packaged` verdict may discard. `unknown` is refused, mirroring the overlay detection reading's direction rather than the write door's. - **H3 holds.** The drift filter had the same reading. It is gated on the same verdict, and a pin checks that the report and the action agree, shape by shape: reported exactly when eligible. - **H4 holds.** Every existing pin in both test files is unedited and green. The door control still discards a shipped set's overlay and heals its record. ## Pins - `permission-set-overlay-discard.test.ts`, new block: each environment-authored registry body is refused with `code` `PERMISSION_DENIED` and `status` `403`, and both tables are counted and compared whole before and after. The bodies are the runtime-package row (`_packageId` plus `_provenance: 'org'`), the org-owned set, the clone, and the lock's documented `'sys_metadata'` runtime shadow. A code-shipped set sits beside them in the registry. Further cases: a registry read that throws (`unknown`) is refused with the rows intact, and a control discards a code-shipped set's overlay beside a hydrated overlay item that wears the artifact's envelope. - `permission-set-drift.test.ts`, new block: a drifted environment-authored set with a stored definition is never diagnosed (for each of the four shapes). The agreement pin runs the drift report and Discard Overlay over one registry: the code-shipped set is reported and eligible (`409 INVALID_STATE`, nothing to discard), each other shape is unreported and refused (`403 PERMISSION_DENIED`), and no stored definition is deleted. - `packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts` (new): 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). Then a legacy overlay row for the shipped control, a cold boot, and the list read. Then a precondition for the stamp, then each shape's refusal with its record ids and stored rows compared before and after. The drift report run over the booted registry judges the shipped set and none of the three shapes. The control's discard returns `200`, its overlay row is gone, its record is kept, and its grants equal the shipped artifact's again. ## Ablations The fix and its pins were committed first. Each ablation went through `node scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed), then `pnpm --filter @objectstack/plugin-security build` and `node scripts/ablation-dist-preflight.mjs @objectstack/plugin-security MARKER` (exit 0, marker in `dist/index.js` and `dist/index.mjs`). The dogfood suite resolves `plugin-security` from `dist/`. Each restore was proven: blob equal to `HEAD`, `git diff HEAD` empty, a rebuild, `--absent` preflight exit 0, and an empty `git status --porcelain`. They ran before #21857 landed, on a local, never-pushed composition: this branch at a4d82d3 plus #21857's two source files, blob-identical to 9e3e32e. The plugin-security source in that composition is byte-identical to this PR's head. Only #21857's two test files differ, and they were not in the composition. | ablation | what was put back | unit result | dogfood result | |---|---|---|---| | A | discard's eligibility reads "an item of this name carries a package id" again (the verdict rebuilt as: some `readDeclared` item has the row's name and a truthy `_packageId ?? packageId`) | 3 failed / 29 passed: runtime-package and sentinel refusals, and the agreement pin | 1 failed / 6 passed: runtime-package set, `200` with `overlaysDiscarded: 1` | | B | drift's population reads the package id again (the classifier gate made always-true) | 3 failed / 29 passed: runtime-package and sentinel population pins, and the agreement pin | 1 failed / 6 passed: the drift-population leg | The org-owned, clone and control legs stayed green under both, as they must: the old reading already refused the first two and accepted the third. A first attempt at ablation A used a typed replacement that narrowed the verdict's type, so the DTS step failed. The JS bundle still carried the marker and the same three unit pins and one door pin went red. It was redone with a cast that keeps the declared type, and the numbers above are from the clean run. ## Tests and gates (head 62143b0 unless noted) - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: 167 files, 3611 passed, 45 skipped (on 50f93c1, the merge of `origin/main` at c9be1f1; the only later commit is a comment in the dogfood file). - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0, including `check:test-typecheck` (0 errors). `pnpm --filter @objectstack/dogfood typecheck`: exit 0 (its `tsconfig.json` includes `test/**`). - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/permission-set-discard-overlay-eligibility.dogfood.test.ts`: 7 passed on 62143b0. Together with #21857's `permission-set-lock-row-provenance.dogfood.test.ts`: 21 passed on 50f93c1. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 97 commands on 62143b0; all 97 ran, every exit 0. `--ran`: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN. The first battery, on 50f93c1, found one real red: `check:cross-package-test-inputs` read a docs path spelled in the dogfood file's header comment as a test input. The comment now names the page in words (62143b0). It also hit two PREREQUISITE NOT MET (exit 3: `check:skill-examples`, `check:dual-build-cjs-loads`), cleared by building the eight packages they named before the second battery. The derivation printed a stale-tree note: `origin/main` moved two commits (#21858, #21862) after the merge, and one of them edits `scripts/engine-double-contract.pinned.json`. Those commits touch neither this diff's packages nor its files, and `git merge-tree` against them is clean. - Lint, narrowed and proven: `pnpm exec eslint --no-inline-config --format json` over the five touched TypeScript files gives 5 files in the JSON output, 0 errors, 0 warnings, on 62143b0. The population is the files this diff touches. The `.md` and `.mdx` files are outside the lint globs in `eslint.config.mjs`, which never enables type-aware linting (no `parserOptions.project`, no typed rules, stated in its own comment), so this diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **Route.** The suggested route was to replace both `_packageId ?? packageId` readings with the verdict. Measured, the eligibility half is replaced. The per-item selector is kept below the verdict, because the two alternatives both change something for code-shipped sets. Keying it on the verdict's package id changes which bodies are compared when two code packages ship one name (ADR-0048 §3.4 lets them coexist under distinct registry keys). Dropping it would let a projection echo with no package id into the drift comparison. - **The `'sys_metadata'` sentinel shape** is pinned at the unit level only. It is the runtime shadow the lock module's docs describe; no door producing it was measured here. The old reading accepted it, and the classifier already refused it on `main`. - **Drift's reach.** The boot's drift pass runs before any list read, and the boot's hydration does not stamp the package id (measured by #21857). So on the showcase the drift misjudgment shows only in a later run of the diagnostics after a list read. The door pin runs `computePermissionSetDriftDiagnostics` over the booted stack's registry after the list read. - **Observation, not filed (carrier: none).** In the degraded-kernel branch (no metadata protocol), discard's resync body is the first registry item of the name that carries any package id. If a hydrated overlay of a code-shipped name preceded its artifact in registry order, that body would be the overlay's. Nothing measured shows that order. It is unchanged here, by H4. - **Changeset.** `.changeset/21860-discard-overlay-eligibility.md`, `patch` for `@objectstack/plugin-security`, with `Clause-②: no` copied from the claim. The action's declared population (sets a code package ships) is restored, and no accepted input widens. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2e78046 commit 5e0b489

7 files changed

Lines changed: 582 additions & 27 deletions

File tree

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+
Discard Overlay no longer deletes the stored definition of a permission set saved into a writable runtime package
6+
7+
Clause-②: no
8+
9+
The Discard Overlay action (`POST /api/v1/security/permission-sets/:id/discard-overlay`) is declared to refuse any set that is not package-declared, so that it can never destroy a set the environment authored. It decided that by asking whether any engine-registry item of the set's name carried a package id. 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 passed the check after the first list read: the action answered `200` and deleted the set's only `sys_metadata` row. It now asks the same classifier the packaged-permission-set lock's write doors ask, and refuses every set that classifier does not judge shipped by a code package (including when it cannot decide), with the action's existing `403 PERMISSION_DENIED`.
10+
11+
The drift diagnostics behind the record's `drift_status` / `drift_detail` (whose `overlay_shadow` detail names Discard Overlay as the remedy) read the package id the same way. They now judge the same population: only sets a code package ships.
12+
13+
Unchanged: a set a code package ships still has its overlay discarded and its record resynced to the shipped artifact, its drift is still reported as before, and no error code, route or field moves. The refusal's message now says the set is not shipped by any installed code package.

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -365,9 +365,10 @@ either can be live on one row, and they need different remedies:
365365
the package ships next. **Remedy:** the record's **Discard Overlay** Setup
366366
action (`POST /api/v1/security/permission-sets/:id/discard-overlay`,
367367
tenant-admin only) removes the stale overlay and resyncs the record to the
368-
current artifact immediately. It refuses on any set that is not currently
369-
package-declared, so it can never destroy a genuinely environment-authored
370-
set.
368+
current artifact immediately. It refuses on any set that no installed code
369+
package ships (a set created in this environment, a clone, or a set saved
370+
into a writable runtime package), so it can never destroy a genuinely
371+
environment-authored set.
371372
- **Provenance skip.** The record's `managed_by` column predates package
372373
provenance tracking (a legacy insert without `managed_by: 'package'` —
373374
typically a database first initialized on an older release line), so boot

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import {
2323
runPermissionSetDriftDiagnostics,
2424
} from './permission-set-drift.js';
2525
import { permissionSetRowFields } from './permission-set-projection.js';
26+
import {
27+
discardPermissionSetOverlay,
28+
type PermissionSetOverlayDiscardDeps,
29+
} from './permission-set-overlay-discard.js';
2630

2731
/**
2832
* Minimal in-memory ql: sys_permission_set + sys_metadata + $in support.
@@ -324,3 +328,94 @@ describe('runPermissionSetDriftDiagnostics — pin 6: a refused diagnostic write
324328
expect(warn.some((l) => l.m.includes('REFUSED'))).toBe(true);
325329
});
326330
});
331+
332+
333+
/* ------------------------------------------------------------------------- *
334+
* [#21860] the declared population is the lock classifier's, and it is the
335+
* population the Discard Overlay action accepts
336+
*
337+
* A set saved into a writable runtime package carries its package id on its
338+
* registry body after the first metadata list read
339+
* (`{ _packageId: <the package>, _provenance: 'org' }`). This report read
340+
* that as "package-declared": a drifted runtime-package set with a stored
341+
* definition was diagnosed `overlay_shadow`, and the detail names Discard
342+
* Overlay as the remedy, which deleted the set's only stored definition.
343+
* ------------------------------------------------------------------------- */
344+
345+
const RUNTIME_PACKAGE = 'com.dogfood.runtime_pkg';
346+
347+
/** Registry bodies as the hydrator registers them for each environment-authored shape. */
348+
const envShapes = [
349+
{ label: 'runtime-package set', body: { name: 'rt_pkg_set', label: 'Runtime package set', objects: { obj_a: { allowRead: true } }, _packageId: RUNTIME_PACKAGE, _provenance: 'org' }, packageId: RUNTIME_PACKAGE },
350+
{ label: 'org-owned set', body: { name: 'org_owned_set', label: 'Org-owned set', objects: { obj_a: { allowRead: true } }, _provenance: 'org' }, packageId: null },
351+
{ label: 'clone', body: { name: 'contributor_clone', label: 'Contributor (clone)', objects: { obj_a: { allowRead: true } }, _provenance: 'org' }, packageId: null },
352+
// The lock module's documented runtime shadow: the `'sys_metadata'` sentinel package id.
353+
{ label: 'runtime shadow', body: { name: 'runtime_only_set', label: 'Runtime-only set', objects: { obj_a: { allowRead: true } }, _packageId: 'sys_metadata', _provenance: 'org' }, packageId: null },
354+
] as const;
355+
356+
/** A record that DIFFERS from the body (so a judged set would be surfaced), plus its stored definition. */
357+
function seedDrifted(ql: ReturnType<typeof makeQl>, body: any, packageId: string | null): string {
358+
const id = `ps_${body.name}`;
359+
ql.permRows.push({
360+
id, name: body.name, managed_by: 'admin', package_id: packageId,
361+
...permissionSetRowFields(body),
362+
object_permissions: JSON.stringify({ obj_z: { allowRead: true } }),
363+
});
364+
ql.metaRows.push({
365+
id: `meta_${body.name}`, type: 'permission', name: body.name, state: 'active', organization_id: null,
366+
package_id: packageId, metadata: JSON.stringify(body),
367+
});
368+
return id;
369+
}
370+
371+
describe('[#21860] computePermissionSetDriftDiagnostics — the declared population is the lock classifier\'s', () => {
372+
for (const shape of envShapes) {
373+
it(`a drifted ${shape.label} with a stored definition is never diagnosed (and never told to Discard Overlay)`, async () => {
374+
const ql = makeQl([declaredSet({ _provenance: 'package' }), shape.body]);
375+
ql.permRows.push(inSyncRow());
376+
seedDrifted(ql, shape.body, shape.packageId);
377+
378+
const diagnostics = await computePermissionSetDriftDiagnostics(ql);
379+
380+
// Only the code-shipped set is judged; the environment's own set is not.
381+
expect(diagnostics.map((d) => ({ name: d.name, status: d.status, packageId: d.packageId })))
382+
.toEqual([{ name: 'ehr_quality_inspector', status: 'in_sync', packageId: 'com.example.ehr' }]);
383+
});
384+
}
385+
386+
it('the report and the Discard Overlay action judge the same population: reported ⇔ eligible', async () => {
387+
const shipped = declaredSet({ _provenance: 'package' });
388+
const ql = makeQl([shipped, ...envShapes.map((s) => s.body)]);
389+
ql.permRows.push(inSyncRow()); // code-shipped, no overlay: eligible, and 409 for want of one
390+
const ids: Record<string, string> = { [shipped.name]: 'ps_1' };
391+
for (const shape of envShapes) ids[shape.body.name] = seedDrifted(ql, shape.body, shape.packageId);
392+
393+
const reported = new Set((await computePermissionSetDriftDiagnostics(ql)).map((d) => d.name));
394+
395+
const deps: PermissionSetOverlayDiscardDeps = {
396+
ql,
397+
resolveSets: async () => [{ name: 'platform_admin', objects: { '*': { modifyAllRecords: true } }, fields: {}, systemPermissions: [] } as any],
398+
getProtocol: () => undefined,
399+
logger: { info: () => {}, warn: () => {}, error: () => {} },
400+
};
401+
const verdicts: Record<string, { reported: boolean; discard: unknown }> = {};
402+
for (const [name, id] of Object.entries(ids)) {
403+
const discard = await discardPermissionSetOverlay(deps, { userId: 'u_admin' }, id).then(
404+
() => 'accepted',
405+
(e: any) => ({ code: e?.code, status: e?.status ?? e?.statusCode }),
406+
);
407+
verdicts[name] = { reported: reported.has(name), discard };
408+
}
409+
410+
expect(verdicts).toEqual({
411+
// Eligible (past the refusal), and nothing to discard.
412+
ehr_quality_inspector: { reported: true, discard: { code: 'INVALID_STATE', status: 409 } },
413+
rt_pkg_set: { reported: false, discard: { code: 'PERMISSION_DENIED', status: 403 } },
414+
org_owned_set: { reported: false, discard: { code: 'PERMISSION_DENIED', status: 403 } },
415+
contributor_clone: { reported: false, discard: { code: 'PERMISSION_DENIED', status: 403 } },
416+
runtime_only_set: { reported: false, discard: { code: 'PERMISSION_DENIED', status: 403 } },
417+
});
418+
// Nothing was deleted: every stored definition is still there.
419+
expect(ql.metaRows.map((r) => r.name).sort()).toEqual(['contributor_clone', 'org_owned_set', 'rt_pkg_set', 'runtime_only_set']);
420+
});
421+
});

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
logSeedDurabilityFailure,
6868
} from './per-organization-catalog.js';
6969
import { readDeclared } from './bootstrap-declared-permissions.js';
70+
import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js';
7071
import { buildExistingByName } from './seed-name-lookup.js';
7172

7273
/**
@@ -125,7 +126,27 @@ export async function computePermissionSetDriftDiagnostics(
125126
const out: PermissionSetDriftDiagnostic[] = [];
126127
if (!ql || typeof ql.find !== 'function') return out;
127128

128-
const declared = readDeclared(ql, 'permission').filter((ps) => ps?.name && (ps._packageId ?? ps.packageId));
129+
// [#21860] "Package-declared" is the lock's verdict for the NAME — a code
130+
// (artifact) package ships it — read through the one classifier the write
131+
// doors, the overlay detection reading and the Discard Overlay action ask.
132+
// A package id on the item alone is not that answer: a set saved into a
133+
// writable runtime package carries one after the first metadata list read,
134+
// and this report used to diagnose it as package-declared and name Discard
135+
// Overlay as the remedy, an action that deletes its only stored definition.
136+
// The per-item test that follows the verdict only picks which bodies of a
137+
// code-shipped name are compared, exactly as before.
138+
const codeShipped = new Map<string, boolean>();
139+
const isCodeShipped = (name: string): boolean => {
140+
let shipped = codeShipped.get(name);
141+
if (shipped === undefined) {
142+
shipped = classifyPackagedPermissionSet(name, ql).status === 'packaged';
143+
codeShipped.set(name, shipped);
144+
}
145+
return shipped;
146+
};
147+
const declared = readDeclared(ql, 'permission').filter(
148+
(ps) => ps?.name && (ps._packageId ?? ps.packageId) && isCodeShipped(String(ps.name)),
149+
);
129150
if (declared.length === 0) return out;
130151

131152
const organizationId = opts.organizationId;

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

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,3 +356,140 @@ describe('discardPermissionSetOverlay — pin 6: a refused resync is never audit
356356
expect(warn.some((l) => l.m.includes('RESYNC WRITE WAS REFUSED'))).toBe(true);
357357
});
358358
});
359+
360+
361+
/* ------------------------------------------------------------------------- *
362+
* [#21860] eligibility is the lock's classifier, not "an item carries a
363+
* package id"
364+
*
365+
* The registry holds stored rows as well as artifacts, and the metadata list
366+
* read stamps a stored row's `package_id` column onto its body as
367+
* `_packageId`. A set saved into a WRITABLE RUNTIME package is therefore
368+
* registered as `{ _packageId: <the package>, _provenance: 'org' }` after the
369+
* first list read, and this action read the package id as "package-declared":
370+
* it answered 200 and deleted the set's only `sys_metadata` row. The bodies
371+
* below are the ones the hydrator registers for each shape (an org's own set
372+
* and a clone carry the tenant marker and no package id).
373+
*
374+
* Every refusal asserts the envelope (`code` + `status`) AND that no row
375+
* moved: both tables are counted before and after, and the stored rows are
376+
* compared whole.
377+
* ------------------------------------------------------------------------- */
378+
379+
const RUNTIME_PACKAGE = 'com.dogfood.runtime_pkg';
380+
381+
interface EnvShape {
382+
label: string;
383+
/** The body the registry holds for the set, as the hydrator registers it. */
384+
registered: Record<string, any>;
385+
/** The stored definition's `package_id` column (the runtime package, or none). */
386+
packageId: string | null;
387+
}
388+
389+
const envObjects = { obj_a: { allowRead: true } };
390+
391+
const ENV_SHAPES: EnvShape[] = [
392+
{
393+
label: 'a set saved into a writable runtime package (after a list read stamped its package id)',
394+
registered: { name: 'rt_pkg_set', label: 'Runtime package set', objects: envObjects, _packageId: RUNTIME_PACKAGE, _provenance: 'org' },
395+
packageId: RUNTIME_PACKAGE,
396+
},
397+
{
398+
label: "an org's own set",
399+
registered: { name: 'org_owned_set', label: 'Org-owned set', objects: envObjects, _provenance: 'org' },
400+
packageId: null,
401+
},
402+
{
403+
label: 'a "Clone to customize" clone',
404+
registered: { name: 'contributor_clone', label: 'Contributor (clone)', objects: envObjects, _provenance: 'org' },
405+
packageId: null,
406+
},
407+
{
408+
// The runtime shadow the lock module documents for a definition that lives
409+
// only in `sys_metadata`: its package id is the `'sys_metadata'` sentinel,
410+
// which the classifier excludes and a bare "has a package id" read accepts.
411+
label: "a runtime shadow carrying the 'sys_metadata' sentinel package id",
412+
registered: { name: 'runtime_only_set', label: 'Runtime-only set', objects: envObjects, _packageId: 'sys_metadata', _provenance: 'org' },
413+
packageId: null,
414+
},
415+
];
416+
417+
/** The set's record and its one stored definition — the only copy of it there is. */
418+
function seedEnvShape(ql: ReturnType<typeof makeQl>, shape: EnvShape): string {
419+
const name = shape.registered.name;
420+
const id = `ps_${name}`;
421+
ql.permRows.push({
422+
id, name, managed_by: 'admin', package_id: shape.packageId,
423+
object_permissions: JSON.stringify(envObjects),
424+
});
425+
ql.metaRows.push({
426+
id: `meta_${name}`, type: 'permission', name, state: 'active', organization_id: null,
427+
package_id: shape.packageId,
428+
metadata: JSON.stringify({ name, label: shape.registered.label, objects: envObjects }),
429+
});
430+
return id;
431+
}
432+
433+
const snapshot = (ql: ReturnType<typeof makeQl>) => ({
434+
permissionSets: ql.permRows.length,
435+
metadataRows: ql.metaRows.length,
436+
rows: structuredClone({ perm: ql.permRows, meta: ql.metaRows }),
437+
});
438+
439+
/** The shipped artifact of the control, with the envelope a code package's item carries. */
440+
const shippedArtifact = () => declaredSet({ _provenance: 'package' });
441+
442+
describe('[#21860] discardPermissionSetOverlay — eligibility is the lock classifier\'s verdict', () => {
443+
for (const shape of ENV_SHAPES) {
444+
it(`refuses ${shape.label} with 403 PERMISSION_DENIED, and every row is intact`, async () => {
445+
// A code-shipped set sits beside it, so "some package ships something"
446+
// is true of the registry: the verdict must be about THIS name.
447+
const ql = makeQl([shippedArtifact(), shape.registered]);
448+
const id = seedEnvShape(ql, shape);
449+
const before = snapshot(ql);
450+
451+
await expect(discardPermissionSetOverlay(deps(ql), tenantAdminCtx, id))
452+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
453+
454+
const after = snapshot(ql);
455+
expect({ permissionSets: after.permissionSets, metadataRows: after.metadataRows })
456+
.toEqual({ permissionSets: before.permissionSets, metadataRows: before.metadataRows });
457+
expect(after.rows).toEqual(before.rows);
458+
});
459+
}
460+
461+
it('refuses when the classifier cannot answer (the registry read throws), with every row intact', async () => {
462+
const shape = ENV_SHAPES[0];
463+
const ql = makeQl([shape.registered]);
464+
ql.registry.listItems = () => { throw new Error('registry offline'); };
465+
const id = seedEnvShape(ql, shape);
466+
const before = snapshot(ql);
467+
468+
await expect(discardPermissionSetOverlay(deps(ql), tenantAdminCtx, id))
469+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
470+
471+
expect(snapshot(ql)).toEqual(before);
472+
});
473+
474+
it('control: a code-shipped set\'s overlay is still discarded and the row healed to the artifact — beside a legacy overlay wearing the artifact\'s envelope in the registry', async () => {
475+
const artifact = shippedArtifact(); // ships 3 objects
476+
// A stored overlay of a name a code package ships is hydrated wearing the
477+
// artifact's envelope (`_provenance: 'package'`), next to the artifact.
478+
const hydratedOverlay = { name: artifact.name, objects: { obj_a: {} }, _packageId: artifact._packageId, _provenance: 'package' };
479+
const ql = makeQl([artifact, hydratedOverlay]);
480+
ql.permRows.push({
481+
id: 'ps_1', name: artifact.name, managed_by: 'package', package_id: artifact._packageId,
482+
...permissionSetRowFields(artifact),
483+
object_permissions: JSON.stringify({ obj_a: { allowRead: true } }),
484+
});
485+
ql.metaRows.push(overlayRow({ obj_a: {} }));
486+
487+
const result = await discardPermissionSetOverlay(deps(ql), tenantAdminCtx, 'ps_1');
488+
489+
expect(result.overlaysDiscarded).toBe(1);
490+
expect(ql.metaRows).toHaveLength(0);
491+
expect(ql.permRows).toHaveLength(1);
492+
expect(result.healedObjectGrantCount).toBe(3);
493+
expect(Object.keys(JSON.parse(result.permissionSet.object_permissions)).sort()).toEqual(['obj_a', 'obj_b', 'obj_c']);
494+
});
495+
});

0 commit comments

Comments
 (0)