Skip to content

Commit 8b94d70

Browse files
committed
feat(plugin-security): authoring-time gate for package-owned permission sets + projection dogfood proof (ADR-0094 D5)
Closes the last inert-metadata hole from the ADR-0094 pure-projection refactor (#2898), and binds the invariants into the liveness proof registry. - metadata-protocol: new registerAuthoringGate(type, fn) seam — a per-type gate run inside saveMetaItem before persistence; a returned rejection becomes a thrown Error (code/status). Fail-open on a gate error (this closes an inert-metadata hole, not a hard boundary — the data-plane two-doors gate + the projector's refusal already protect the record). - plugin-security: registers a `permission` gate that refuses an env-scope saveMetaItem targeting a managed_by:package sys_permission_set record — previously such an overlay persisted but neither projected nor enforced (ADR-0049 violation). The package door (save carries the owning packageId) and env-authored/new sets are unaffected. Error `package_owned` (403). - dogfood: showcase-permission-projection proves the ADR-0094 invariants on the real stack (write-through, awaited projection, declared-set edit → enforced overlay, delete-as-reset, and this authoring gate), registered in the ADR-0054 proof registry (unbound — a storage invariant has no authorable ledger property to ratchet, kept honest with a blockedReason). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q
1 parent 1dede32 commit 8b94d70

10 files changed

Lines changed: 441 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
Reject environment-door metadata saves that target a package-owned permission set (ADR-0094 D5, closes framework#2898) — the last inert-metadata hole from the pure-projection refactor.
7+
8+
- **`@objectstack/metadata-protocol`**: new `registerMutationProjector` sibling `registerAuthoringGate(type, fn)` — a per-type gate run inside `saveMetaItem` before persistence; a returned rejection becomes a thrown Error carrying `code`/`status`. The domain plugin that owns a type's projection decides (the generic layer stays shape-agnostic). Fail-open on a gate error (this closes an inert-metadata hole, not a hard boundary).
9+
- **`@objectstack/plugin-security`**: registers a `permission` gate that refuses an env-scope `saveMetaItem` whose target name is a `managed_by:'package'` `sys_permission_set` record — previously such an overlay persisted but neither projected nor enforced (ADR-0049 violation). The package door (a save carrying the owning `packageId`) and env-authored/new sets are unaffected. Error code `package_owned` (403) with "edit the package and re-publish, or clone to a new name" guidance.
10+
11+
Also lands a dogfood proof (`showcase-permission-projection`) binding the ADR-0094 pure-projection invariants — write-through, awaited projection, declared-set edit becomes an enforced overlay, delete-as-reset, and this authoring gate — into the liveness proof registry.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// @proof: permission-set-projection
4+
//
5+
// ADR-0094 — `sys_permission_set` is a PURE PROJECTION of the metadata layer,
6+
// proven on the real showcase stack. The record has no independent authority:
7+
//
8+
// 1. A data-door create/edit lands in the METADATA store (write-through) and
9+
// the record is re-derived by the AWAITED projector — consistent on the
10+
// very next read, no race.
11+
// 2. A data-door edit of a DECLARED set becomes an enforced env overlay
12+
// (the layered effective body changes), closing the "Setup edit never
13+
// enforces" gap.
14+
// 3. Deleting a runtime-only set retires its record; deleting an
15+
// artifact-backed set RESETS it to the declared body (the definition
16+
// ships with the app and cannot be removed from the environment).
17+
// 4. [framework#2898 / ADR-0094 D5] An environment-door metadata save that
18+
// targets a package-owned set is rejected at authoring time.
19+
20+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
21+
import showcaseStack from '@objectstack/example-showcase';
22+
import { bootStack, type VerifyStack } from '@objectstack/verify';
23+
24+
describe('sys_permission_set pure projection (ADR-0094)', () => {
25+
let stack: VerifyStack;
26+
let ql: any;
27+
let protocol: any;
28+
let adminToken: string;
29+
30+
beforeAll(async () => {
31+
stack = await bootStack(showcaseStack);
32+
adminToken = await stack.signIn();
33+
ql = await stack.kernel.getServiceAsync('objectql');
34+
protocol = await stack.kernel.getServiceAsync('protocol');
35+
}, 60_000);
36+
afterAll(async () => { await stack?.stop(); });
37+
38+
const findSet = async (name: string) =>
39+
(await ql.find('sys_permission_set', { where: { name } }, { context: { isSystem: true } }))?.[0];
40+
const overlayBody = async (name: string) => {
41+
const layered = await protocol.getMetaItemLayered({ type: 'permission', name });
42+
return layered?.overlay ?? null;
43+
};
44+
45+
// ── 1. Data-door create → metadata store + awaited projection ─────────────
46+
it('a data-door create lands in metadata and the record is projected before the response returns', async () => {
47+
const NAME = 'proj_probe_agent';
48+
const res = await stack.apiAs(adminToken, 'POST', '/data/sys_permission_set', {
49+
name: NAME,
50+
label: 'Projection Probe',
51+
object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }),
52+
system_permissions: JSON.stringify(['probe.use']),
53+
});
54+
expect(res.status).toBeLessThan(300);
55+
56+
// The record exists immediately (awaited projection — no poll/sleep) and is
57+
// env-owned, not forged package provenance.
58+
const row = await findSet(NAME);
59+
expect(row, 'record projected synchronously with the create').toBeTruthy();
60+
expect(row.managed_by).toBe('user');
61+
expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true } });
62+
63+
// …and the authoritative store is the metadata overlay, not the row.
64+
const overlay = await overlayBody(NAME);
65+
expect(overlay?.name, 'the definition lives in the metadata store').toBe(NAME);
66+
expect(overlay.systemPermissions).toEqual(['probe.use']);
67+
});
68+
69+
it('a data-door edit updates metadata and the record follows in lock-step', async () => {
70+
const NAME = 'proj_probe_agent';
71+
const row = await findSet(NAME);
72+
const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${row.id}`, {
73+
system_permissions: JSON.stringify(['probe.use', 'probe.admin']),
74+
});
75+
expect(res.status).toBeLessThan(300);
76+
77+
expect((await overlayBody(NAME)).systemPermissions).toEqual(['probe.use', 'probe.admin']);
78+
expect(JSON.parse((await findSet(NAME)).system_permissions)).toEqual(['probe.use', 'probe.admin']);
79+
});
80+
81+
it('deleting a runtime-only set retires both the definition and the record', async () => {
82+
const NAME = 'proj_probe_agent';
83+
const row = await findSet(NAME);
84+
const res = await stack.apiAs(adminToken, 'DELETE', `/data/sys_permission_set/${row.id}`);
85+
expect(res.status).toBeLessThan(300);
86+
expect(await findSet(NAME), 'runtime-only record retired on delete').toBeFalsy();
87+
expect(await overlayBody(NAME), 'metadata overlay gone too').toBeFalsy();
88+
});
89+
90+
// ── 2. Data-door edit of a DECLARED set becomes an enforced overlay ───────
91+
it('editing a declared set through the data door produces an enforced metadata overlay', async () => {
92+
// member_default is a platform-declared set (an artifact baseline exists).
93+
expect(await overlayBody('member_default'), 'no overlay before the edit').toBeFalsy();
94+
const md = await findSet('member_default');
95+
const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${md.id}`, {
96+
description: 'customized via Setup (ADR-0094)',
97+
});
98+
expect(res.status).toBeLessThan(300);
99+
100+
// The edit is now an env overlay — the store the resolver reads, not a
101+
// record-only change that silently never enforces.
102+
const overlay = await overlayBody('member_default');
103+
expect(overlay, 'Setup edit of a declared set becomes an env overlay').toBeTruthy();
104+
expect(overlay.description).toBe('customized via Setup (ADR-0094)');
105+
expect((await findSet('member_default')).description).toBe('customized via Setup (ADR-0094)');
106+
});
107+
108+
// ── 3. Delete of an artifact-backed set RESETS (does not remove) ──────────
109+
it('deleting a declared set through the data door resets it to the declared body, keeping the record', async () => {
110+
const before = await findSet('member_default');
111+
const res = await stack.apiAs(adminToken, 'DELETE', `/data/sys_permission_set/${before.id}`);
112+
expect(res.status).toBeLessThan(300);
113+
114+
const after = await findSet('member_default');
115+
expect(after, 'a packaged/declared set cannot be removed from the environment').toBeTruthy();
116+
// Overlay is gone (reset) and the customized description is gone with it.
117+
expect(await overlayBody('member_default')).toBeFalsy();
118+
expect(after.description ?? null).not.toBe('customized via Setup (ADR-0094)');
119+
});
120+
121+
// ── 4. Authoring gate — env door refuses a package-owned set (#2898) ──────
122+
it('an environment-door metadata save targeting a package-owned set is rejected (ADR-0094 D5)', async () => {
123+
const contributor = await findSet('showcase_contributor');
124+
expect(contributor?.managed_by, 'showcase_contributor is package-owned').toBe('package');
125+
126+
await expect(
127+
protocol.saveMetaItem({
128+
type: 'permission',
129+
name: 'showcase_contributor',
130+
item: { name: 'showcase_contributor', label: 'hijack-via-env-door', objects: {} },
131+
}),
132+
'the environment door must refuse a package-owned set',
133+
).rejects.toMatchObject({ code: 'package_owned' });
134+
135+
// The record stays at its package baseline.
136+
expect((await findSet('showcase_contributor')).label).toBe(contributor.label);
137+
});
138+
139+
it('a brand-new environment set authored through the metadata door appears as a Setup record', async () => {
140+
const NAME = 'proj_env_authored';
141+
await protocol.saveMetaItem({
142+
type: 'permission',
143+
name: NAME,
144+
item: { name: NAME, label: 'Env Authored', objects: { crm_lead: { allowRead: true } }, systemPermissions: ['env.use'] },
145+
});
146+
// Studio-authored env sets now surface in Setup (the record is created by
147+
// the projector, not left invisible as before ADR-0094).
148+
const row = await findSet(NAME);
149+
expect(row, 'Studio-authored env set appears in Setup').toBeTruthy();
150+
expect(row.managed_by).toBe('user');
151+
expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true } });
152+
});
153+
});

packages/metadata-protocol/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata } from './protocol.js';
44
export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js';
55
export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js';
6+
export type { MetadataAuthoringGate, AuthoringGateRejection } from './protocol.js';
67

78
export { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
89
export type {

packages/metadata-protocol/src/mutation-listeners.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,43 @@ describe('ObjectStackProtocolImplementation.registerMutationProjector (ADR-0094)
116116
expect(out).toEqual({ success: false, error: 'projection boom' });
117117
});
118118
});
119+
120+
// ADR-0094 D5 / framework#2898 — the per-type authoring gate seam. Run inside
121+
// saveMetaItem before persistence; a returned rejection becomes a thrown Error
122+
// carrying code/status. A gate that itself throws is fail-open (logged).
123+
describe('ObjectStackProtocolImplementation.registerAuthoringGate (ADR-0094 D5)', () => {
124+
const gateArgs = (over: Record<string, unknown> = {}) => ({
125+
type: 'permission', name: 'crm_rep', item: {}, organizationId: null, packageId: null, mode: 'publish' as const, ...over,
126+
});
127+
128+
it('no-ops when no gate is registered for the type', async () => {
129+
const p = makeProtocol();
130+
await expect((p as any).runAuthoringGate(gateArgs())).resolves.toBeUndefined();
131+
});
132+
133+
it('throws a structured Error when the gate rejects', async () => {
134+
const p = makeProtocol();
135+
p.registerAuthoringGate('permission', async () => ({ code: 'package_owned', status: 403, message: 'nope' }));
136+
await expect((p as any).runAuthoringGate(gateArgs())).rejects.toMatchObject({ message: 'nope', code: 'package_owned', status: 403 });
137+
});
138+
139+
it('passes when the gate returns null/void', async () => {
140+
const p = makeProtocol();
141+
p.registerAuthoringGate('permission', async () => null);
142+
await expect((p as any).runAuthoringGate(gateArgs())).resolves.toBeUndefined();
143+
});
144+
145+
it('normalizes plural type names and passes the singular type to the gate', async () => {
146+
const p = makeProtocol();
147+
let seenType = '';
148+
p.registerAuthoringGate('permissions', async (a: any) => { seenType = a.type; return null; });
149+
await (p as any).runAuthoringGate(gateArgs({ type: 'permission' }));
150+
expect(seenType).toBe('permission');
151+
});
152+
153+
it('is FAIL-OPEN when the gate throws (allows the save, logged)', async () => {
154+
const p = makeProtocol();
155+
p.registerAuthoringGate('permission', async () => { throw new Error('lookup down'); });
156+
await expect((p as any).runAuthoringGate(gateArgs())).resolves.toBeUndefined();
157+
});
158+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,37 @@ export interface MutationProjectionOutcome {
792792
error?: string;
793793
}
794794

795+
/**
796+
* Structured rejection a {@link MetadataAuthoringGate} returns to refuse a
797+
* save. Surfaced by `saveMetaItem` as a thrown Error carrying `code`/`status`,
798+
* so an authoring surface (Studio / REST) renders an actionable message.
799+
*/
800+
export interface AuthoringGateRejection {
801+
code: string;
802+
status: number;
803+
message: string;
804+
}
805+
806+
/**
807+
* Per-type authoring gate (ADR-0094 D5, framework#2898). Invoked by
808+
* `saveMetaItem` BEFORE persistence; returning an {@link AuthoringGateRejection}
809+
* refuses the write. The generic protocol layer must not know a domain's
810+
* data-plane shape (e.g. `sys_permission_set`'s `managed_by`), so the owning
811+
* plugin registers a gate that reads whatever it needs and decides. The
812+
* package door (a save carrying the owning `packageId`) and system/boot writes
813+
* are already distinguishable via the gate's args, so a gate can scope itself
814+
* to the environment door precisely.
815+
*/
816+
export type MetadataAuthoringGate = (args: {
817+
type: string;
818+
name: string;
819+
item: unknown;
820+
organizationId: string | null;
821+
packageId: string | null;
822+
mode: 'draft' | 'publish';
823+
actor?: string;
824+
}) => Promise<AuthoringGateRejection | null | void>;
825+
795826
export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
796827
private engine: MetadataHostEngine;
797828
private getServicesRegistry?: () => Map<string, any>;
@@ -840,6 +871,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
840871
*/
841872
private mutationProjectors = new Map<string, MetadataMutationProjector>();
842873

874+
/**
875+
* Per-type authoring gates (ADR-0094 D5, framework#2898), keyed by singular
876+
* metadata type. Run in `saveMetaItem` before persistence; a returned
877+
* rejection refuses the write. One per type; a second registration
878+
* replaces the first (idempotent re-init).
879+
*/
880+
private authoringGates = new Map<string, MetadataAuthoringGate>();
881+
843882
constructor(
844883
engine: IDataEngine,
845884
getServicesRegistry?: () => Map<string, any>,
@@ -887,6 +926,56 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
887926
this.mutationProjectors.set(singular, projector);
888927
}
889928

929+
/**
930+
* Register the authoring gate for a metadata type (ADR-0094 D5,
931+
* framework#2898). Called by the plugin that owns the type's data-plane
932+
* projection (e.g. plugin-security gates env-scope `permission` saves that
933+
* target a package-owned set). Singular or plural type names both resolve.
934+
*/
935+
registerAuthoringGate(type: string, gate: MetadataAuthoringGate): void {
936+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
937+
this.authoringGates.set(singular, gate);
938+
}
939+
940+
/**
941+
* Run the registered authoring gate for a save (ADR-0094 D5). Throws a
942+
* structured Error when the gate rejects; a no-op when no gate is
943+
* registered. A gate that itself throws is treated as FAIL-OPEN (logged):
944+
* this gate closes an inert-metadata hole, not a hard security boundary
945+
* (the data-plane two-doors gate + the projector's package-owned refusal
946+
* already protect the record), so a transient lookup failure must not
947+
* block a legitimate save.
948+
*/
949+
private async runAuthoringGate(args: {
950+
type: string;
951+
name: string;
952+
item: unknown;
953+
organizationId: string | null;
954+
packageId: string | null;
955+
mode: 'draft' | 'publish';
956+
actor?: string;
957+
}): Promise<void> {
958+
const singular = PLURAL_TO_SINGULAR[args.type] ?? args.type;
959+
const gate = this.authoringGates.get(singular);
960+
if (!gate) return;
961+
let rejection: AuthoringGateRejection | null | void;
962+
try {
963+
rejection = await gate({ ...args, type: singular });
964+
} catch (e) {
965+
console.warn(
966+
`[Protocol] authoring gate for ${singular}/${args.name} threw — allowing the save (fail-open): `
967+
+ `${e instanceof Error ? e.message : String(e)}`,
968+
);
969+
return;
970+
}
971+
if (rejection) {
972+
const err = new Error(rejection.message);
973+
(err as any).code = rejection.code;
974+
(err as any).status = rejection.status;
975+
throw err;
976+
}
977+
}
978+
890979
/**
891980
* Run the registered projector for a just-persisted mutation (ADR-0094).
892981
* Returns `undefined` when no projector is registered for the type;
@@ -3874,6 +3963,24 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
38743963
}
38753964
}
38763965

3966+
// [ADR-0094 D5 / framework#2898] Per-type authoring gate. Runs after the
3967+
// basic authorization/lock/destructive checks and before persistence, so
3968+
// a plugin that owns a type's data-plane projection can refuse a save the
3969+
// generic layer can't reason about — e.g. plugin-security rejects an
3970+
// environment-door `permission` overlay that targets a package-owned set
3971+
// (which would persist an overlay that neither projects nor enforces —
3972+
// the inert-metadata hole ADR-0049 forbids). The gate sees `packageId`
3973+
// and `mode`, so the package door re-authoring its own set is allowed.
3974+
await this.runAuthoringGate({
3975+
type: request.type,
3976+
name: request.name,
3977+
item: request.item,
3978+
organizationId: request.organizationId ?? null,
3979+
packageId: request.packageId ?? null,
3980+
mode,
3981+
...(request.actor ? { actor: request.actor } : {}),
3982+
});
3983+
38773984
// Defense-in-depth: reject the layered *read* envelope as a write body.
38783985
//
38793986
// `getMetaItemLayered` returns a 3-state diagnostic shape

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export {
3333
upsertEnvPermissionSet,
3434
projectPermissionMutation,
3535
registerPermissionSetProjection,
36+
registerPermissionAuthoringGate,
37+
assertEnvPermissionSaveAllowed,
3638
createPermissionSetWriteThrough,
3739
reconcilePermissionSetProjection,
3840
} from './permission-set-projection.js';

0 commit comments

Comments
 (0)