Skip to content

Commit 9cfc1f7

Browse files
Elon Muskclaude
andauthored
feat(plugin-security): extend the packaged-permission-set lock to the restore leg of the write-through (#13409)
* feat(plugin-security): extend the packaged-permission-set lock to the restore leg of the write-through The restore leg now consults assertPermissionSetNotPackageDeclared before re-authoring a restored record's definition into metadata. A packaged (or unknown-provenance, fail-closed) name has its mint refused BEFORE the metadata write and the refusal reported loudly on the durability channel; the engine un-trash stands (the leg is a deliberate post-pass and never throws). The #11725 MEASURED RESIDUAL tripwire is inverted in the same change, plus a non-packaged control and a fail-closed case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi * docs(permissions): re-anchor the system-context census row for permission-set-projection.ts (pure shift, +6) The #12020 restore-leg comment block shifted the middleware's isSystem read from :1009 to :1015; census site count and page anchor count are 1:1 and the read text is byte-identical to origin/main, so this is the census gate's FIX class (a pure shift), rewritten by its own --fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e10cf34 commit 9cfc1f7

4 files changed

Lines changed: 224 additions & 41 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
Extend the packaged-permission-set lock ("lock the base, clone to customize", 2026-08-24 ruling) to the `restore` leg of the permission-set write-through — the one write point that did not consult it. The leg now checks provenance before re-authoring a restored record's definition into metadata: a package-declared name (or one whose provenance cannot be resolved — fail-closed) has its re-author refused and the refusal reported loudly on the durability channel, while the engine's un-trash stands (this leg runs after it and deliberately never throws). With the mint refused, boot reconciliation re-projects the declared body, so the environment converges to the package truth instead of a silent fork. Org-owned sets restore exactly as before.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ that silently does not happen.
9696
| 7 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `object-schema-fls.ts:228` |
9797
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3808` |
9898
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
99-
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1009` |
99+
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
100100
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1254` |
101101
| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
102102
| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` |

packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts

Lines changed: 147 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,30 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* THE RESTORE LEG — the one write point of `createPermissionSetWriteThrough`
5-
* that the 2026-08-24 "lock the base, clone to customize" ruling does NOT
6-
* guard, pinned as a MEASUREMENT plus a reachability fence.
4+
* THE RESTORE LEG — the fourth write point of `createPermissionSetWriteThrough`
5+
* under the 2026-08-24 "lock the base, clone to customize" ruling: guarded
6+
* since #12020, with the refusal on the DURABILITY channel, plus the
7+
* reachability fence.
78
*
8-
* The lock (`packaged-permission-set-lock.ts`, wired into the `insert` and
9-
* `update` legs of `permission-set-projection.ts`) refuses a save that targets
10-
* a package-declared permission set. Its author named the gap rather than
11-
* leaving it to be found: the `restore` leg re-authors a restored record's
12-
* body into metadata with no provenance check at all, and — unlike its
13-
* neighbours — it CATCHES rather than throws, because it runs after the engine
14-
* has already un-trashed the row.
9+
* The lock (`packaged-permission-set-lock.ts`) refuses a save that targets a
10+
* package-declared permission set. The `insert` and `update` legs of
11+
* `permission-set-projection.ts` THROW it; this leg cannot — it runs after
12+
* the engine has already un-trashed the row, so a throw would misreport a
13+
* completed engine operation and half-apply a multi-row restore. It consults
14+
* the SAME lock before its one guarded write (the `saveMetaItem` mint),
15+
* refuses the mint, and reports on the durability channel (#4632) through the
16+
* #9754 error→warn chain. The un-trash stands: with no overlay minted, boot
17+
* reconciliation re-projects the DECLARED body onto the record, so the
18+
* environment converges to the package truth rather than to a fork. The full
19+
* argument — why not refuse BEFORE the un-trash (no trashed-row read exists;
20+
* a pre-pass would go green in doubles and resolve nothing on a real engine),
21+
* why not compensate (this middleware's own delete leg guarantees the record
22+
* survives, so a re-trash through the data door cannot happen, and a failed
23+
* compensation makes the refusal a lie) — lives at the leg itself.
1524
*
1625
* ## What this file pins, and what it deliberately does not
1726
*
18-
* It pins four facts, in the order they answer the question:
27+
* It pins six facts, in the order they answer the question:
1928
*
2029
* 1. ⭐ CONTROL — the harness reaches the LOCK, and the lock is what answers.
2130
* An `update` on an artifact-backed name is refused with `NOT_OVERRIDABLE`
@@ -26,16 +35,31 @@
2635
* writes". Without this leg, case 2 below could pass for the wrong reason
2736
* — a probe answered by a gate that is not the one under test.
2837
*
29-
* 2. THE MEASUREMENT — the same fixture, the same set, through `restore`:
30-
* the body is re-authored, no refusal is raised, and the definition of a
31-
* package-declared set lands in the environment overlay store.
38+
* 2. THE LOCK AT THE RESTORE LEG — the same fixture, the same set, through
39+
* `restore`: the engine un-trash runs (the leg stays a post-pass), the
40+
* mint is refused BEFORE the metadata write, no overlay lands, and the
41+
* refusal is REPORTED on the durability channel with the lock's own error
42+
* — never thrown.
3243
*
33-
* ⚠️ This case pins the RESIDUAL, not a desired behaviour. The follow-up
34-
* that extends the lock to this leg MUST invert this case in the same PR;
35-
* that inversion is the whole point of pinning it. Do not "fix" the
36-
* assertion to match a lock you added elsewhere.
44+
* This case is the INVERSION #11725's MEASURED RESIDUAL demanded of the
45+
* follow-up (#12020): it used to pin the re-authoring of a packaged body
46+
* through this leg with no lock consulted. The deliberate red for the
47+
* inversion is recorded on #12020's PR: with the leg's lock consultation
48+
* removed, this case fails on `saves.length` — so it does not pass with
49+
* the lock absent.
3750
*
38-
* 3. THE FENCE — why the residual is not a live defect: the write-through's
51+
* 2b. NON-PACKAGED CONTROL — an org-owned set through the same leg is
52+
* re-authored exactly as before the lock arrived. The ruling's scope is
53+
* "packaged sets cannot be re-authored via restore" and nothing else;
54+
* this case is the evidence the extension did not grow past it.
55+
*
56+
* 2c. FAIL-CLOSED — provenance that cannot be answered refuses the mint
57+
* too, reported with the lock's `unknown` error. Accepting on a failed
58+
* read would reopen the hatch-open gap on every transient — the lock's
59+
* three-verdict contract (see its header) applies at every door it
60+
* guards, this one included.
61+
*
62+
* 3. THE FENCE — why the trashed-packaged-row scenario stays remote: the write-through's
3963
* `delete` leg cannot put a package-declared set into a restorable state.
4064
* An artifact-backed definition tombstones its overlay (an ADR-0005 RESET)
4165
* and the record re-projects to the shipped body; the driver delete never
@@ -63,17 +87,16 @@
6387
* `enable.trash` was retired (#2377 / ADR-0049); a real recycle bin is
6488
* parked at #3146.
6589
*
66-
* Those two are what make this leg unreachable TODAY. The day either goes red,
67-
* case 2 stops being a curiosity and becomes the defect it describes.
90+
* Those two are what make this leg unreachable TODAY. The day either goes
91+
* red — #3146 landing a real recycle bin is the expected way — the lock
92+
* pinned by case 2 is what stands between a trashed packaged row and a
93+
* silent fork, and this file is the proof it was already in place.
6894
*/
6995

7096
import { describe, it, expect } from 'vitest';
7197
import { assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
7298
import { PermissionSetSchema } from '@objectstack/spec/security';
73-
import {
74-
createPermissionSetWriteThrough,
75-
permissionSetBodyFromRow,
76-
} from './permission-set-projection.js';
99+
import { createPermissionSetWriteThrough } from './permission-set-projection.js';
77100
import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js';
78101

79102
/** The package-declared body every case in this file turns on. */
@@ -273,13 +296,16 @@ describe('[#11725] the restore leg of the permission-set write-through', () => {
273296
expect(ql.overlays.length, 'no overlay of a packaged set was minted').toBe(0);
274297
});
275298

276-
it('MEASURED RESIDUAL: the same set through RESTORE is re-authored with no lock consulted and no refusal', async () => {
277-
// ⚠️ Pins the residual, not a desired behaviour. See this file's header:
278-
// the follow-up that extends the lock to this leg inverts this case.
299+
it('LOCK AT THE RESTORE LEG: the same set through RESTORE keeps the engine un-trash but the re-author is REFUSED — reported on the durability channel, never thrown', async () => {
300+
// ⭐ The INVERSION of #11725's MEASURED RESIDUAL, demanded by that case's
301+
// own comment and delivered by #12020. It used to assert: no refusal, one
302+
// save, the packaged body in the overlay store. Now the lock is consulted
303+
// before the leg's one guarded write, and every half inverts.
279304
//
280305
// Identical fixture to the CONTROL above — same ql, same registry, same
281306
// protocol posture, same row. The ONLY difference is the operation, so the
282-
// difference in outcome is attributable to the leg and nothing else.
307+
// difference in outcome is attributable to the leg and nothing else. The
308+
// hatch is OPEN, so anything that refuses can only be the write-door lock.
283309
const ql = makeQl([packagedRow('package')], [declaredBody()]);
284310
const protocol = makeProtocol(ql, ['crm_rep'], 'open');
285311
const errors: any[] = [];
@@ -296,18 +322,102 @@ describe('[#11725] the restore leg of the permission-set write-through', () => {
296322
context: userCtx,
297323
});
298324

325+
// The engine operation is NOT misreported: the leg stays a post-pass and
326+
// the caller of a completed restore hears success, not a throw.
299327
expect(nextCalled, 'the engine un-trash runs first — the leg is a post-pass').toBe(true);
300-
expect(errors, 'nothing was refused, so nothing was reported').toEqual([]);
301-
expect(protocol.saves.length, 'the package-declared body WAS re-authored').toBe(1);
302-
expect(protocol.saves[0].name).toBe('crm_rep');
303-
// The definition of a package-declared set now lives in the environment
304-
// overlay store — authored through a door that never asked the lock.
305-
expect(ql.overlays.map((o: any) => o.name)).toEqual(['crm_rep']);
306-
expect(ql.overlays[0].item).toEqual(permissionSetBodyFromRow(packagedRow('package')));
328+
329+
// The refusal is LOUD, and it is the LOCK's — same gate identification as
330+
// the CONTROL: the error names the package and teaches the clone path,
331+
// which ADR-0005's tier gate (the only other 403 on this row) does not.
332+
expect(errors.length, 'the refusal reached the durability channel').toBe(1);
333+
expect(errors[0].e).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
334+
expect(errors[0].e.name).toBe('PackagedPermissionSetLockedError');
335+
expect(errors[0].e.message).toContain("is declared by package 'com.example.crm'");
336+
expect(errors[0].e.message).toContain('clone');
337+
// The leg's own report is the REFUSAL wording, not the failed-write one
338+
// (#5240 — one condition, one wording): it says the skip was deliberate.
339+
expect(errors[0].m).toContain('deliberately NOT re-authored');
340+
expect(errors[0].m).toContain('clone');
341+
342+
// And the mint never happened: refused BEFORE the metadata write, so no
343+
// overlay of a package-declared set exists to win over upgrades.
344+
expect(protocol.saves.length, 'refused BEFORE the metadata write').toBe(0);
345+
expect(ql.overlays, 'no overlay of a packaged set was minted').toEqual([]);
346+
});
347+
348+
it('NON-PACKAGED CONTROL: an org-owned set through RESTORE is re-authored exactly as before — the lock changes nothing for it', async () => {
349+
// The ruled scope is "packaged sets cannot be re-authored via restore"
350+
// and NOTHING else — this case is the evidence the extension did not grow
351+
// past it. Same registry as above (crm_rep IS declared), so the classifier
352+
// is answering `org` for THIS name, not `org` for everything.
353+
const orgRow = {
354+
id: 'ps_org',
355+
name: 'my_own_set',
356+
label: 'My Own Set',
357+
managed_by: 'user',
358+
object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }),
359+
field_permissions: JSON.stringify({}),
360+
system_permissions: JSON.stringify([]),
361+
row_level_security: JSON.stringify([]),
362+
tab_permissions: JSON.stringify({}),
363+
};
364+
const ql = makeQl([orgRow], [declaredBody()]);
365+
const protocol = makeProtocol(ql, ['crm_rep'], 'open');
366+
const errors: any[] = [];
367+
const mw = createPermissionSetWriteThrough({
368+
ql,
369+
getProtocol: () => protocol,
370+
logger: { error: (m: string, e?: Error) => errors.push({ m, e }), info: () => {}, warn: () => {} },
371+
});
372+
373+
const nextCalled = await run(mw, {
374+
object: 'sys_permission_set',
375+
operation: 'restore',
376+
options: { where: { id: 'ps_org' } },
377+
context: userCtx,
378+
});
379+
380+
expect(nextCalled, 'the engine un-trash runs').toBe(true);
381+
expect(errors, 'nothing was refused, nothing was reported').toEqual([]);
382+
expect(protocol.saves.length, 'the org-owned body IS re-authored, as always').toBe(1);
383+
expect(protocol.saves[0].name).toBe('my_own_set');
384+
expect(ql.overlays.map((o: any) => o.name)).toEqual(['my_own_set']);
385+
});
386+
387+
it('FAIL-CLOSED: provenance that cannot be answered refuses the re-author too — reported with the lock\'s unknown error, never guessed', async () => {
388+
// Both artifact sources are broken: the registry read throws and the
389+
// layered probe fails. The lock's three-verdict contract (its header says
390+
// why accepting on `unknown` is the one guess a write door must not make)
391+
// applies at this door exactly as at insert/update — except the refusal
392+
// is reported, not thrown, like every refusal on this leg.
393+
const ql = makeQl([packagedRow('package')], [declaredBody()]);
394+
(ql as any).registry = { listItems: () => { throw new Error('registry offline'); } };
395+
const protocol = makeProtocol(ql, ['crm_rep'], 'open');
396+
(protocol as any).getMetaItemLayered = async () => { throw new Error('metadata layer unreadable'); };
397+
const errors: any[] = [];
398+
const mw = createPermissionSetWriteThrough({
399+
ql,
400+
getProtocol: () => protocol,
401+
logger: { error: (m: string, e?: Error) => errors.push({ m, e }), info: () => {}, warn: () => {} },
402+
});
403+
404+
const nextCalled = await run(mw, {
405+
object: 'sys_permission_set',
406+
operation: 'restore',
407+
options: { where: { id: 'ps_pkg' } },
408+
context: userCtx,
409+
});
410+
411+
expect(nextCalled, 'the engine un-trash runs').toBe(true);
412+
expect(errors.length, 'the fail-closed refusal reached the durability channel').toBe(1);
413+
expect(errors[0].e).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
414+
expect(errors[0].e.name).toBe('PackagedPermissionSetProvenanceUnknownError');
415+
expect(protocol.saves.length, 'no mint on a guess').toBe(0);
416+
expect(ql.overlays).toEqual([]);
307417
});
308418

309419
it('FENCE: DELETE cannot produce a restorable row — the packaged definition RESETS and the driver delete never runs', async () => {
310-
// Why the residual above is not a live defect through this door: there is
420+
// Why a trashed packaged row cannot arise through this door: there is
311421
// nothing to restore. The write-through owns the delete, tombstones the
312422
// overlay, and returns WITHOUT calling `next()` — so the engine never
313423
// removes (or trashes) the record.

0 commit comments

Comments
 (0)