Skip to content

Commit 5619aac

Browse files
os-trumpclaude
andauthored
feat(plugin-security): register the packaged-permission-set lock at the metadata door (#12292)
* feat(plugin-security): register the packaged-permission-set lock at the metadata door The pre-persistence authoring-gate seam carried an 'object' registration only, so a metadata-door save targeting a package-declared permission set reached persistence whenever the ADR-0005 tier gate was open for the type (the documented OS_METADATA_WRITABLE operator hatch) — and the resulting overlay won at read. Per the maintainer ruling of 2026-08-25 (option B: keep NARROW, move the lock), the seam now carries a 'permission' registration that consults the same classifyPackagedPermissionSet classifier and throws the same PackagedPermissionSetLockedError the sys_permission_set data door already uses — one spelling of "package-declared", two doors, one refusal. Hatch writes to non-packaged names keep the documented behavior, and runtime-shadow (ADR-0070 / ADR-0094 D5-R) sets stay editable. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa * chore: record the new pinned engine double in the engine-double-contract ledger The gate's own remedy for new pinned coverage — the fake in packaged-permission-set-lock-gate.test.ts routes both write verbs through the shared dispatch predicates and the RETAINED ledger now holds the file. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6747718 commit 5619aac

9 files changed

Lines changed: 432 additions & 1 deletion
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
The packaged-permission-set lock now guards the metadata door as well as the data door. The pre-persistence authoring-gate seam gains a `permission` registration (`registerPackagedPermissionSetLockGate`, new export) that consults the same `classifyPackagedPermissionSet` classifier and throws the same `PackagedPermissionSetLockedError` the `sys_permission_set` write door already uses — one spelling of "package-declared", two doors, one refusal.
6+
7+
What stops working, and for whom: an operator using the `OS_METADATA_WRITABLE=permission` escape hatch to save a permission set that an installed package declares now gets `403 NOT_OVERRIDABLE` (the refusal names the sanctioned clone path) instead of silently minting a `sys_metadata` overlay whose grants win at read over the package's declaration. This applies hatch open or closed, draft and publish saves alike, and also means stored-overlay maintenance passes (e.g. stored-item migration) report a per-row refusal for such grandfathered forks rather than rewriting them.
8+
9+
What keeps working unchanged: hatch writes to any name no installed package declares — the hatch's documented per-org/env override capability — land exactly as before, and package-door authoring of workspace-owned sets (definitions living in `sys_metadata`; ADR-0070, ADR-0094 D5-R) stays editable. Package publishes travel the `package-author` channel, which this seam exempts by contract.

packages/plugins/plugin-security/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@objectstack/plugin-security",
33
"version": "17.2.0",
44
"license": "Apache-2.0",
5-
"description": "Security Plugin for ObjectStack RBAC, RLS, and Field-Level Security Runtime",
5+
"description": "Security Plugin for ObjectStack \u2014 RBAC, RLS, and Field-Level Security Runtime",
66
"main": "dist/index.js",
77
"types": "dist/index.d.ts",
88
"exports": {
@@ -27,6 +27,7 @@
2727
},
2828
"devDependencies": {
2929
"@objectstack/driver-sql": "workspace:*",
30+
"@objectstack/metadata-protocol": "workspace:*",
3031
"@objectstack/objectql": "workspace:*",
3132
"@objectstack/plugin-sharing": "workspace:*",
3233
"@objectstack/service-i18n": "workspace:*",

packages/plugins/plugin-security/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ export type {
132132
PackagedSetVerdict,
133133
LayeredProbe,
134134
} from './packaged-permission-set-lock.js';
135+
// [#11843 — maintainer ruling 2026-08-25, option B] The lock's METADATA-door
136+
// registration: the pre-persistence authoring-gate seam consults the SAME
137+
// classifier and throws the SAME error classes as the data door above.
138+
export { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js';
139+
export type { PermissionSetLockGateContext } from './packaged-permission-set-lock-gate.js';
135140
export {
136141
OVERLAY_PAGE_LIMIT,
137142
detectPackagedPermissionSetOverlays,
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #11843 — the packaged-permission-set lock answers at the METADATA door.
5+
*
6+
* The lock (`packaged-permission-set-lock.ts`) used to have exactly one
7+
* enforcement point: the `sys_permission_set` DATA door. The pre-persistence
8+
* authoring-gate seam carried an `'object'` registration only, so a
9+
* metadata-door save targeting a package-declared permission set reached
10+
* persistence whenever the ADR-0005 tier gate was open for the type
11+
* (`OS_METADATA_WRITABLE=permission`) — and the resulting overlay won at
12+
* read. The maintainer ruling of 2026-08-25 (「11843 同意」 — option B) closed
13+
* that door by REGISTERING the same lock on the seam rather than authoring a
14+
* second refusal; this file pins the registered behaviour end to end.
15+
*
16+
* ## The harness, and what makes each case answer for the layer it names
17+
*
18+
* A REAL `ObjectStackProtocolImplementation` (aliased to the producer's
19+
* source — see `vitest.config.ts`) over a minimal fake engine, on the
20+
* host-config topology (`environmentId` undefined — the flagship showcase's
21+
* own assembly, the one whose `saveMetaItem` runs the authoring gate ahead of
22+
* every persistence path). The refusal cases assert the lock's ERROR CLASS
23+
* IDENTITY (`instanceof PackagedPermissionSetLockedError`), not only the
24+
* `code`/`status` envelope: `NOT_OVERRIDABLE`/403 is shared with the ADR-0005
25+
* tier gate by design, so the class is the only fingerprint that proves WHICH
26+
* layer answered. And every refusal case asserts the ROW COUNT — the defect
27+
* this card measured was a write that landed, so "threw" alone is half a pin.
28+
*
29+
* ## What is deliberately NOT re-pinned here (Prime Directive #8)
30+
*
31+
* - The data door staying locked is `packaged-permission-set-lock.test.ts`.
32+
* - The hatch's documented behaviour for non-packaged names on the UNGATED
33+
* protocol is `sys-metadata-repository.package-writability.test.ts` in
34+
* `@objectstack/metadata-protocol` (39 pins, authoritative per the ruling);
35+
* this file adds only the with-gate half of that preservation.
36+
* - The classifier's own verdict table is `packaged-permission-set-lock.test.ts`.
37+
*/
38+
39+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
40+
// The producer's OWN write-verb dispatch decisions, so this fake engine cannot
41+
// accept a call ObjectQL refuses (`check:engine-double-contract`; imported
42+
// from `@objectstack/metadata-core` exactly as the sibling pins do).
43+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
44+
import {
45+
ObjectStackProtocolImplementation,
46+
resetEnvWritableMetadataTypes,
47+
} from '@objectstack/metadata-protocol';
48+
import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js';
49+
import {
50+
PackagedPermissionSetLockedError,
51+
PackagedPermissionSetProvenanceUnknownError,
52+
} from './packaged-permission-set-lock.js';
53+
54+
interface Row { [k: string]: unknown }
55+
56+
/** The installed code package whose declaration locks its set. */
57+
const PKG = 'com.example.crm';
58+
/** The set the package declares — artifact-shipped, so the lock owns it. */
59+
const PACKAGED_SET = 'crm_support_agent';
60+
/** An ordinary env-authored name no package declares. */
61+
const ORG_SET = 'org_reporting';
62+
/**
63+
* A set whose definition lives only in `sys_metadata` (ADR-0070 package-door
64+
* authoring): hydrated into the registry as a runtime shadow, stamped with
65+
* the `'sys_metadata'` sentinel `_packageId`. NOT package-declared to the
66+
* lock — ADR-0094 D5-R keeps this tier editable.
67+
*/
68+
const SHADOW_SET = 'workspace_drafted';
69+
70+
/** Spec-valid permission-set body — must pass the Zod gate, which runs BEFORE the seam under test. */
71+
const body = (name: string) => ({ name, label: 'Pinned', objects: {} });
72+
73+
/**
74+
* Minimal engine fake: enough storage for `saveMetaItem`'s repository path
75+
* (the accept cases read their row back) and a SchemaRegistry surface for the
76+
* classifier (`listItems`) and the protocol (`getArtifactItem` — what makes
77+
* the packaged name artifact-backed, mirroring an installed package).
78+
*/
79+
function makeFakeEngine(opts?: { listItemsThrows?: boolean }) {
80+
const rows = new Map<string, Row>();
81+
const historyRows: Row[] = [];
82+
const keyOf = (w: Record<string, unknown>, table = 'sys_metadata') =>
83+
`${table}|${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`;
84+
const findRow = (where: Record<string, unknown>) => {
85+
if (where.id !== undefined) {
86+
for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r };
87+
return null;
88+
}
89+
const k = keyOf(where);
90+
const r = rows.get(k);
91+
return r ? { key: k, row: r } : null;
92+
};
93+
const declaredItems = [
94+
{ name: PACKAGED_SET, label: 'Support Agent', _packageId: PKG, objects: {} },
95+
{ name: SHADOW_SET, label: 'Drafted', _packageId: 'sys_metadata', objects: {} },
96+
];
97+
return {
98+
rows,
99+
manifests: new Map<string, unknown>([[PKG, { id: PKG }]]),
100+
registry: {
101+
getPackage: () => undefined,
102+
registerItem: () => {},
103+
registerObject: () => {},
104+
getItem: () => undefined,
105+
listItems: (type: string) => {
106+
if (opts?.listItemsThrows) throw new Error('registry unreadable');
107+
return type === 'permission' ? declaredItems : [];
108+
},
109+
getArtifactItem: (type: string, name: string) =>
110+
type === 'permission' && name === PACKAGED_SET
111+
? { name, _packageId: PKG }
112+
: undefined,
113+
},
114+
async find(table: string, _opts: { where: Record<string, unknown> }) {
115+
if (table === 'sys_metadata_history') return historyRows;
116+
return Array.from(rows.values());
117+
},
118+
async findOne(table: string, opts2: { where: Record<string, unknown> }) {
119+
if (table === 'sys_metadata_history') return null;
120+
return findRow(opts2.where)?.row ?? null;
121+
},
122+
async insert(table: string, data: Record<string, unknown>) {
123+
if (table === 'sys_metadata_history') {
124+
const h: Row = { ...data };
125+
if (!h.id) h.id = `h_${historyRows.length + 1}`;
126+
historyRows.push(h);
127+
return { id: h.id as string };
128+
}
129+
const k = keyOf(data, table);
130+
const row: Row = { id: `r_${rows.size + 1}`, __table: table, ...data };
131+
rows.set(k, row);
132+
return { id: row.id as string };
133+
},
134+
async update(_t: string, data: Record<string, unknown>, opts2: { where: Record<string, unknown> }) {
135+
assertEngineUpdateDispatch(data, opts2);
136+
const found = findRow(opts2.where);
137+
if (!found) throw new Error('not found');
138+
rows.set(found.key, { ...found.row, ...data });
139+
return { id: found.row.id as string };
140+
},
141+
async delete(_t: string, opts2: { where: Record<string, unknown> }) {
142+
assertEngineDeleteDispatch(opts2);
143+
const found = findRow(opts2.where);
144+
if (!found) return { deleted: 0 };
145+
rows.delete(found.key);
146+
return { deleted: 1 };
147+
},
148+
async transaction<T>(cb: (ctx: unknown, info: { owned: boolean }) => Promise<T>): Promise<T> {
149+
return cb(undefined, { owned: true });
150+
},
151+
};
152+
}
153+
154+
function boot(opts?: { listItemsThrows?: boolean }) {
155+
const engine = makeFakeEngine(opts);
156+
const protocol = new ObjectStackProtocolImplementation(
157+
engine as never,
158+
() => new Map(),
159+
undefined, // host-config topology — no environmentId
160+
) as unknown as {
161+
saveMetaItem(req: Record<string, unknown>): Promise<unknown>;
162+
registerAuthoringGate?(type: string, gate: unknown): void;
163+
};
164+
const wired = registerPackagedPermissionSetLockGate(protocol, engine);
165+
return { engine, protocol, wired };
166+
}
167+
168+
const metaRowsOf = (engine: { rows: Map<string, Row> }) =>
169+
Array.from(engine.rows.values()).filter((r) => r.__table === 'sys_metadata');
170+
171+
const save = (
172+
protocol: { saveMetaItem(req: Record<string, unknown>): Promise<unknown> },
173+
req: Record<string, unknown>,
174+
) => protocol.saveMetaItem(req).then(() => null, (e: unknown) => e);
175+
176+
const openHatch = () => {
177+
process.env.OS_METADATA_WRITABLE = 'permission';
178+
resetEnvWritableMetadataTypes();
179+
(ObjectStackProtocolImplementation as unknown as { resetEnvWritableCache(): void }).resetEnvWritableCache();
180+
};
181+
182+
describe('#11843 — the lock answers at the metadata door', () => {
183+
beforeEach(() => {
184+
delete process.env.OS_METADATA_WRITABLE;
185+
resetEnvWritableMetadataTypes();
186+
(ObjectStackProtocolImplementation as unknown as { resetEnvWritableCache(): void }).resetEnvWritableCache();
187+
});
188+
afterEach(() => {
189+
delete process.env.OS_METADATA_WRITABLE;
190+
resetEnvWritableMetadataTypes();
191+
(ObjectStackProtocolImplementation as unknown as { resetEnvWritableCache(): void }).resetEnvWritableCache();
192+
});
193+
194+
// ── the inversion of the measured defect ─────────────────────────────────
195+
196+
it('hatch OPEN: a package-less save targeting a package-declared set is refused by the LOCK, and no row lands', async () => {
197+
const { engine, protocol, wired } = boot();
198+
expect(wired).toBe(true);
199+
openHatch();
200+
201+
const err = await save(protocol, { type: 'permission', name: PACKAGED_SET, item: body(PACKAGED_SET) });
202+
203+
// Class identity is the layer fingerprint — the ADR-0005 tier gate shares
204+
// this code and status, but only the lock constructs this class.
205+
expect(err).toBeInstanceOf(PackagedPermissionSetLockedError);
206+
expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
207+
expect((err as Error).message).toContain(PKG);
208+
// The defect was a write that LANDED — the throw alone is half the pin.
209+
expect(metaRowsOf(engine)).toEqual([]);
210+
}, 30_000);
211+
212+
it('hatch CLOSED: the identical save is refused by the same lock — the refusal does not depend on the hatch', async () => {
213+
const { engine, protocol } = boot();
214+
215+
const err = await save(protocol, { type: 'permission', name: PACKAGED_SET, item: body(PACKAGED_SET) });
216+
217+
expect(err).toBeInstanceOf(PackagedPermissionSetLockedError);
218+
expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
219+
expect(metaRowsOf(engine)).toEqual([]);
220+
}, 30_000);
221+
222+
it('hatch OPEN: a DRAFT save of the packaged name is refused too — the seam gates both minting paths', async () => {
223+
const { engine, protocol } = boot();
224+
openHatch();
225+
226+
const err = await save(protocol, {
227+
type: 'permission', name: PACKAGED_SET, item: body(PACKAGED_SET), mode: 'draft',
228+
});
229+
230+
expect(err).toBeInstanceOf(PackagedPermissionSetLockedError);
231+
expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
232+
expect(metaRowsOf(engine)).toEqual([]);
233+
}, 30_000);
234+
235+
// ── the preservation half — NARROW is retained, per the ruling ───────────
236+
237+
it('hatch OPEN: a package-less save to a NON-packaged name still lands, bound to no package', async () => {
238+
const { engine, protocol } = boot();
239+
openHatch();
240+
241+
const err = await save(protocol, { type: 'permission', name: ORG_SET, item: body(ORG_SET) });
242+
243+
expect(err).toBeNull();
244+
const rows = metaRowsOf(engine);
245+
expect(rows).toHaveLength(1);
246+
expect(rows[0]).toMatchObject({ package_id: null, organization_id: null });
247+
}, 30_000);
248+
249+
it('hatch OPEN: a runtime-shadow set (definition living only in sys_metadata) is NOT locked — ADR-0094 D5-R', async () => {
250+
const { engine, protocol } = boot();
251+
openHatch();
252+
253+
const err = await save(protocol, { type: 'permission', name: SHADOW_SET, item: body(SHADOW_SET) });
254+
255+
expect(err).toBeNull();
256+
expect(metaRowsOf(engine)).toHaveLength(1);
257+
}, 30_000);
258+
259+
// ── fail-closed, one spelling with the data door ─────────────────────────
260+
261+
it('no provenance source can answer → the save is refused rather than guessed (fail-closed)', async () => {
262+
// The gate function alone, on a protocol stub with no layered read: the
263+
// registry read throws and no second source exists, which is the exact
264+
// `unknown` verdict the lock's header refuses to accept on a write door.
265+
let gate: ((ctx: { type: string; name: string; body: unknown }) => Promise<void>) | undefined;
266+
const stub = {
267+
registerAuthoringGate: (_type: string, g: typeof gate) => { gate = g; },
268+
};
269+
const ql = { registry: { listItems: () => { throw new Error('registry unreadable'); } } };
270+
expect(registerPackagedPermissionSetLockGate(stub, ql)).toBe(true);
271+
expect(gate).toBeTypeOf('function');
272+
273+
const err = await gate!({ type: 'permission', name: PACKAGED_SET, body: body(PACKAGED_SET) })
274+
.then(() => null, (e: unknown) => e);
275+
276+
expect(err).toBeInstanceOf(PackagedPermissionSetProvenanceUnknownError);
277+
expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
278+
});
279+
280+
// ── feature detection, mirroring registerObjectPostureGate ───────────────
281+
282+
it('a protocol without the seam keeps its behaviour and the caller can read the false', () => {
283+
expect(registerPackagedPermissionSetLockGate({}, {})).toBe(false);
284+
expect(registerPackagedPermissionSetLockGate(undefined, {})).toBe(false);
285+
});
286+
});

0 commit comments

Comments
 (0)