From 8b94d70bb703ff16c079c4c367a14c242c4d8822 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 07:59:17 +0000 Subject: [PATCH 1/5] feat(plugin-security): authoring-time gate for package-owned permission sets + projection dogfood proof (ADR-0094 D5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q --- .changeset/permission-set-authoring-gate.md | 11 ++ ...case-permission-projection.dogfood.test.ts | 153 ++++++++++++++++++ packages/metadata-protocol/src/index.ts | 1 + .../src/mutation-listeners.test.ts | 40 +++++ packages/metadata-protocol/src/protocol.ts | 107 ++++++++++++ packages/plugins/plugin-security/src/index.ts | 2 + .../src/permission-set-projection.test.ts | 46 ++++++ .../src/permission-set-projection.ts | 55 +++++++ .../plugin-security/src/security-plugin.ts | 4 + .../spec/scripts/liveness/proof-registry.mts | 22 +++ 10 files changed, 441 insertions(+) create mode 100644 .changeset/permission-set-authoring-gate.md create mode 100644 packages/dogfood/test/showcase-permission-projection.dogfood.test.ts diff --git a/.changeset/permission-set-authoring-gate.md b/.changeset/permission-set-authoring-gate.md new file mode 100644 index 0000000000..a2be94627e --- /dev/null +++ b/.changeset/permission-set-authoring-gate.md @@ -0,0 +1,11 @@ +--- +"@objectstack/metadata-protocol": minor +"@objectstack/plugin-security": minor +--- + +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. + +- **`@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). +- **`@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. + +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. diff --git a/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts b/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts new file mode 100644 index 0000000000..d0f07132b5 --- /dev/null +++ b/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// @proof: permission-set-projection +// +// ADR-0094 — `sys_permission_set` is a PURE PROJECTION of the metadata layer, +// proven on the real showcase stack. The record has no independent authority: +// +// 1. A data-door create/edit lands in the METADATA store (write-through) and +// the record is re-derived by the AWAITED projector — consistent on the +// very next read, no race. +// 2. A data-door edit of a DECLARED set becomes an enforced env overlay +// (the layered effective body changes), closing the "Setup edit never +// enforces" gap. +// 3. Deleting a runtime-only set retires its record; deleting an +// artifact-backed set RESETS it to the declared body (the definition +// ships with the app and cannot be removed from the environment). +// 4. [framework#2898 / ADR-0094 D5] An environment-door metadata save that +// targets a package-owned set is rejected at authoring time. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +describe('sys_permission_set pure projection (ADR-0094)', () => { + let stack: VerifyStack; + let ql: any; + let protocol: any; + let adminToken: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + protocol = await stack.kernel.getServiceAsync('protocol'); + }, 60_000); + afterAll(async () => { await stack?.stop(); }); + + const findSet = async (name: string) => + (await ql.find('sys_permission_set', { where: { name } }, { context: { isSystem: true } }))?.[0]; + const overlayBody = async (name: string) => { + const layered = await protocol.getMetaItemLayered({ type: 'permission', name }); + return layered?.overlay ?? null; + }; + + // ── 1. Data-door create → metadata store + awaited projection ───────────── + it('a data-door create lands in metadata and the record is projected before the response returns', async () => { + const NAME = 'proj_probe_agent'; + const res = await stack.apiAs(adminToken, 'POST', '/data/sys_permission_set', { + name: NAME, + label: 'Projection Probe', + object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }), + system_permissions: JSON.stringify(['probe.use']), + }); + expect(res.status).toBeLessThan(300); + + // The record exists immediately (awaited projection — no poll/sleep) and is + // env-owned, not forged package provenance. + const row = await findSet(NAME); + expect(row, 'record projected synchronously with the create').toBeTruthy(); + expect(row.managed_by).toBe('user'); + expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); + + // …and the authoritative store is the metadata overlay, not the row. + const overlay = await overlayBody(NAME); + expect(overlay?.name, 'the definition lives in the metadata store').toBe(NAME); + expect(overlay.systemPermissions).toEqual(['probe.use']); + }); + + it('a data-door edit updates metadata and the record follows in lock-step', async () => { + const NAME = 'proj_probe_agent'; + const row = await findSet(NAME); + const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${row.id}`, { + system_permissions: JSON.stringify(['probe.use', 'probe.admin']), + }); + expect(res.status).toBeLessThan(300); + + expect((await overlayBody(NAME)).systemPermissions).toEqual(['probe.use', 'probe.admin']); + expect(JSON.parse((await findSet(NAME)).system_permissions)).toEqual(['probe.use', 'probe.admin']); + }); + + it('deleting a runtime-only set retires both the definition and the record', async () => { + const NAME = 'proj_probe_agent'; + const row = await findSet(NAME); + const res = await stack.apiAs(adminToken, 'DELETE', `/data/sys_permission_set/${row.id}`); + expect(res.status).toBeLessThan(300); + expect(await findSet(NAME), 'runtime-only record retired on delete').toBeFalsy(); + expect(await overlayBody(NAME), 'metadata overlay gone too').toBeFalsy(); + }); + + // ── 2. Data-door edit of a DECLARED set becomes an enforced overlay ─────── + it('editing a declared set through the data door produces an enforced metadata overlay', async () => { + // member_default is a platform-declared set (an artifact baseline exists). + expect(await overlayBody('member_default'), 'no overlay before the edit').toBeFalsy(); + const md = await findSet('member_default'); + const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${md.id}`, { + description: 'customized via Setup (ADR-0094)', + }); + expect(res.status).toBeLessThan(300); + + // The edit is now an env overlay — the store the resolver reads, not a + // record-only change that silently never enforces. + const overlay = await overlayBody('member_default'); + expect(overlay, 'Setup edit of a declared set becomes an env overlay').toBeTruthy(); + expect(overlay.description).toBe('customized via Setup (ADR-0094)'); + expect((await findSet('member_default')).description).toBe('customized via Setup (ADR-0094)'); + }); + + // ── 3. Delete of an artifact-backed set RESETS (does not remove) ────────── + it('deleting a declared set through the data door resets it to the declared body, keeping the record', async () => { + const before = await findSet('member_default'); + const res = await stack.apiAs(adminToken, 'DELETE', `/data/sys_permission_set/${before.id}`); + expect(res.status).toBeLessThan(300); + + const after = await findSet('member_default'); + expect(after, 'a packaged/declared set cannot be removed from the environment').toBeTruthy(); + // Overlay is gone (reset) and the customized description is gone with it. + expect(await overlayBody('member_default')).toBeFalsy(); + expect(after.description ?? null).not.toBe('customized via Setup (ADR-0094)'); + }); + + // ── 4. Authoring gate — env door refuses a package-owned set (#2898) ────── + it('an environment-door metadata save targeting a package-owned set is rejected (ADR-0094 D5)', async () => { + const contributor = await findSet('showcase_contributor'); + expect(contributor?.managed_by, 'showcase_contributor is package-owned').toBe('package'); + + await expect( + protocol.saveMetaItem({ + type: 'permission', + name: 'showcase_contributor', + item: { name: 'showcase_contributor', label: 'hijack-via-env-door', objects: {} }, + }), + 'the environment door must refuse a package-owned set', + ).rejects.toMatchObject({ code: 'package_owned' }); + + // The record stays at its package baseline. + expect((await findSet('showcase_contributor')).label).toBe(contributor.label); + }); + + it('a brand-new environment set authored through the metadata door appears as a Setup record', async () => { + const NAME = 'proj_env_authored'; + await protocol.saveMetaItem({ + type: 'permission', + name: NAME, + item: { name: NAME, label: 'Env Authored', objects: { crm_lead: { allowRead: true } }, systemPermissions: ['env.use'] }, + }); + // Studio-authored env sets now surface in Setup (the record is created by + // the projector, not left invisible as before ADR-0094). + const row = await findSet(NAME); + expect(row, 'Studio-authored env set appears in Setup').toBeTruthy(); + expect(row.managed_by).toBe('user'); + expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); + }); +}); diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index f449252b53..4eb7cd30db 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -3,6 +3,7 @@ export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata } from './protocol.js'; export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js'; export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js'; +export type { MetadataAuthoringGate, AuthoringGateRejection } from './protocol.js'; export { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; export type { diff --git a/packages/metadata-protocol/src/mutation-listeners.test.ts b/packages/metadata-protocol/src/mutation-listeners.test.ts index fdcdec9cd6..8d58962f0d 100644 --- a/packages/metadata-protocol/src/mutation-listeners.test.ts +++ b/packages/metadata-protocol/src/mutation-listeners.test.ts @@ -116,3 +116,43 @@ describe('ObjectStackProtocolImplementation.registerMutationProjector (ADR-0094) expect(out).toEqual({ success: false, error: 'projection boom' }); }); }); + +// ADR-0094 D5 / framework#2898 — the per-type authoring gate seam. Run inside +// saveMetaItem before persistence; a returned rejection becomes a thrown Error +// carrying code/status. A gate that itself throws is fail-open (logged). +describe('ObjectStackProtocolImplementation.registerAuthoringGate (ADR-0094 D5)', () => { + const gateArgs = (over: Record = {}) => ({ + type: 'permission', name: 'crm_rep', item: {}, organizationId: null, packageId: null, mode: 'publish' as const, ...over, + }); + + it('no-ops when no gate is registered for the type', async () => { + const p = makeProtocol(); + await expect((p as any).runAuthoringGate(gateArgs())).resolves.toBeUndefined(); + }); + + it('throws a structured Error when the gate rejects', async () => { + const p = makeProtocol(); + p.registerAuthoringGate('permission', async () => ({ code: 'package_owned', status: 403, message: 'nope' })); + await expect((p as any).runAuthoringGate(gateArgs())).rejects.toMatchObject({ message: 'nope', code: 'package_owned', status: 403 }); + }); + + it('passes when the gate returns null/void', async () => { + const p = makeProtocol(); + p.registerAuthoringGate('permission', async () => null); + await expect((p as any).runAuthoringGate(gateArgs())).resolves.toBeUndefined(); + }); + + it('normalizes plural type names and passes the singular type to the gate', async () => { + const p = makeProtocol(); + let seenType = ''; + p.registerAuthoringGate('permissions', async (a: any) => { seenType = a.type; return null; }); + await (p as any).runAuthoringGate(gateArgs({ type: 'permission' })); + expect(seenType).toBe('permission'); + }); + + it('is FAIL-OPEN when the gate throws (allows the save, logged)', async () => { + const p = makeProtocol(); + p.registerAuthoringGate('permission', async () => { throw new Error('lookup down'); }); + await expect((p as any).runAuthoringGate(gateArgs())).resolves.toBeUndefined(); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 1043db6527..0bdc972024 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -792,6 +792,37 @@ export interface MutationProjectionOutcome { error?: string; } +/** + * Structured rejection a {@link MetadataAuthoringGate} returns to refuse a + * save. Surfaced by `saveMetaItem` as a thrown Error carrying `code`/`status`, + * so an authoring surface (Studio / REST) renders an actionable message. + */ +export interface AuthoringGateRejection { + code: string; + status: number; + message: string; +} + +/** + * Per-type authoring gate (ADR-0094 D5, framework#2898). Invoked by + * `saveMetaItem` BEFORE persistence; returning an {@link AuthoringGateRejection} + * refuses the write. The generic protocol layer must not know a domain's + * data-plane shape (e.g. `sys_permission_set`'s `managed_by`), so the owning + * plugin registers a gate that reads whatever it needs and decides. The + * package door (a save carrying the owning `packageId`) and system/boot writes + * are already distinguishable via the gate's args, so a gate can scope itself + * to the environment door precisely. + */ +export type MetadataAuthoringGate = (args: { + type: string; + name: string; + item: unknown; + organizationId: string | null; + packageId: string | null; + mode: 'draft' | 'publish'; + actor?: string; +}) => Promise; + export class ObjectStackProtocolImplementation implements ObjectStackProtocol { private engine: MetadataHostEngine; private getServicesRegistry?: () => Map; @@ -840,6 +871,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { */ private mutationProjectors = new Map(); + /** + * Per-type authoring gates (ADR-0094 D5, framework#2898), keyed by singular + * metadata type. Run in `saveMetaItem` before persistence; a returned + * rejection refuses the write. One per type; a second registration + * replaces the first (idempotent re-init). + */ + private authoringGates = new Map(); + constructor( engine: IDataEngine, getServicesRegistry?: () => Map, @@ -887,6 +926,56 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { this.mutationProjectors.set(singular, projector); } + /** + * Register the authoring gate for a metadata type (ADR-0094 D5, + * framework#2898). Called by the plugin that owns the type's data-plane + * projection (e.g. plugin-security gates env-scope `permission` saves that + * target a package-owned set). Singular or plural type names both resolve. + */ + registerAuthoringGate(type: string, gate: MetadataAuthoringGate): void { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + this.authoringGates.set(singular, gate); + } + + /** + * Run the registered authoring gate for a save (ADR-0094 D5). Throws a + * structured Error when the gate rejects; a no-op when no gate is + * registered. A gate that itself throws is treated as FAIL-OPEN (logged): + * this gate closes an inert-metadata hole, not a hard security boundary + * (the data-plane two-doors gate + the projector's package-owned refusal + * already protect the record), so a transient lookup failure must not + * block a legitimate save. + */ + private async runAuthoringGate(args: { + type: string; + name: string; + item: unknown; + organizationId: string | null; + packageId: string | null; + mode: 'draft' | 'publish'; + actor?: string; + }): Promise { + const singular = PLURAL_TO_SINGULAR[args.type] ?? args.type; + const gate = this.authoringGates.get(singular); + if (!gate) return; + let rejection: AuthoringGateRejection | null | void; + try { + rejection = await gate({ ...args, type: singular }); + } catch (e) { + console.warn( + `[Protocol] authoring gate for ${singular}/${args.name} threw — allowing the save (fail-open): ` + + `${e instanceof Error ? e.message : String(e)}`, + ); + return; + } + if (rejection) { + const err = new Error(rejection.message); + (err as any).code = rejection.code; + (err as any).status = rejection.status; + throw err; + } + } + /** * Run the registered projector for a just-persisted mutation (ADR-0094). * Returns `undefined` when no projector is registered for the type; @@ -3874,6 +3963,24 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } } + // [ADR-0094 D5 / framework#2898] Per-type authoring gate. Runs after the + // basic authorization/lock/destructive checks and before persistence, so + // a plugin that owns a type's data-plane projection can refuse a save the + // generic layer can't reason about — e.g. plugin-security rejects an + // environment-door `permission` overlay that targets a package-owned set + // (which would persist an overlay that neither projects nor enforces — + // the inert-metadata hole ADR-0049 forbids). The gate sees `packageId` + // and `mode`, so the package door re-authoring its own set is allowed. + await this.runAuthoringGate({ + type: request.type, + name: request.name, + item: request.item, + organizationId: request.organizationId ?? null, + packageId: request.packageId ?? null, + mode, + ...(request.actor ? { actor: request.actor } : {}), + }); + // Defense-in-depth: reject the layered *read* envelope as a write body. // // `getMetaItemLayered` returns a 3-state diagnostic shape diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 0e4346c392..ac50180451 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -33,6 +33,8 @@ export { upsertEnvPermissionSet, projectPermissionMutation, registerPermissionSetProjection, + registerPermissionAuthoringGate, + assertEnvPermissionSaveAllowed, createPermissionSetWriteThrough, reconcilePermissionSetProjection, } from './permission-set-projection.js'; diff --git a/packages/plugins/plugin-security/src/permission-set-projection.test.ts b/packages/plugins/plugin-security/src/permission-set-projection.test.ts index 1a57a63c8f..0ce547c177 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -17,6 +17,8 @@ import { upsertEnvPermissionSet, projectPermissionMutation, registerPermissionSetProjection, + registerPermissionAuthoringGate, + assertEnvPermissionSaveAllowed, createPermissionSetWriteThrough, reconcilePermissionSetProjection, } from './permission-set-projection.js'; @@ -301,6 +303,50 @@ describe('registerPermissionSetProjection', () => { }); }); +// ── Authoring gate (ADR-0094 D5 / framework#2898) ─────────────────────────── + +describe('assertEnvPermissionSaveAllowed', () => { + it('rejects an env-door save targeting a package-owned record', async () => { + const ql = makeQl(); + ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' }); + const r = await assertEnvPermissionSaveAllowed(ql, { name: 'crm_rep', packageId: null }); + expect(r?.code).toBe('package_owned'); + expect(r?.status).toBe(403); + }); + + it('allows the package door (save carries the owning packageId)', async () => { + const ql = makeQl(); + ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' }); + expect(await assertEnvPermissionSaveAllowed(ql, { name: 'crm_rep', packageId: 'com.example.crm' })).toBeNull(); + }); + + it('allows an env-authored set and a brand-new name', async () => { + const ql = makeQl(); + ql.permRows.push({ id: 'ps_env', name: 'my_custom', managed_by: 'user' }); + expect(await assertEnvPermissionSaveAllowed(ql, { name: 'my_custom', packageId: null })).toBeNull(); + expect(await assertEnvPermissionSaveAllowed(ql, { name: 'does_not_exist', packageId: null })).toBeNull(); + }); + + it('registers the gate on a capable protocol and refuses the save through it', async () => { + const ql = makeQl(); + ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' }); + let gate: any = null; + const protocol = { registerAuthoringGate: (_t: string, fn: any) => { gate = fn; } }; + expect(registerPermissionAuthoringGate(protocol, ql)).toBe(true); + expect(typeof gate).toBe('function'); + const rej = await gate({ type: 'permission', name: 'crm_rep', item: {}, organizationId: null, packageId: null, mode: 'publish' }); + expect(rej?.code).toBe('package_owned'); + // env-owned name passes + ql.permRows.push({ id: 'ps_env', name: 'ok_set', managed_by: 'user' }); + expect(await gate({ type: 'permission', name: 'ok_set', item: {}, organizationId: null, packageId: null, mode: 'publish' })).toBeNull(); + }); + + it('returns false on a protocol without registerAuthoringGate', () => { + expect(registerPermissionAuthoringGate({}, makeQl())).toBe(false); + expect(registerPermissionAuthoringGate(null, makeQl())).toBe(false); + }); +}); + // ── Data-door write-through (ADR-0094 D3) ─────────────────────────────────── function makeMiddleware(ql: any, protocol: any, metadata?: any) { diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index fe369616ea..6a56b7492b 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -421,6 +421,61 @@ export function registerPermissionSetProjection( return false; } +// ───────────────────────────────────────────────────────────────────────────── +// Authoring gate (ADR-0094 D5, framework#2898) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Reject an ENVIRONMENT-door `permission` save whose target name is a + * package-owned `sys_permission_set` record. A package set's definition lives + * in its package (edited via the package door → publish); an env-scope overlay + * of it would persist a layered overlay that the projector refuses to apply + * (ADR-0086 D4) and the resolver never enforces as an override — the exact + * declared-but-inert surface ADR-0049 forbids. Rejecting at authoring time + * turns that silent no-op into an actionable error. + * + * Scoped to the ENV door precisely: a save carrying the owning `packageId` + * (the package door re-authoring its own set, draft→publish) is allowed, and + * so is any save for a name with no package-owned record (new env sets, + * env-authored sets, package sets being materialized by the system path — + * which never routes through here). Fail-open on lookup error: this closes an + * inert-metadata hole, not a hard boundary (the data-plane two-doors gate and + * the projector's refusal already protect the record). + */ +export async function assertEnvPermissionSaveAllowed( + ql: any, + args: { name: string; packageId: string | null; item?: unknown }, +): Promise<{ code: string; status: number; message: string } | null> { + // Package door — the package re-authoring its own metadata; not the env door. + if (args.packageId) return null; + if (!ql || typeof ql.find !== 'function' || !args.name) return null; + const existing = (await tryFind(ql, 'sys_permission_set', { name: args.name }, 1))[0]; + if (existing?.managed_by === 'package') { + return { + code: 'package_owned', + status: 403, + message: + `[Security] '${args.name}' is a package-managed permission set (managed_by:'package') — ` + + `it cannot be customized through the environment door. Edit it in its package and re-publish, ` + + `or clone it to a new name to author an environment-owned set (ADR-0094 D5 / ADR-0086 two-doors).`, + }; + } + return null; +} + +/** + * Register the `permission` authoring gate on the protocol (ADR-0094 D5). + * Returns `true` when wired, `false` on a protocol that predates + * `registerAuthoringGate`. + */ +export function registerPermissionAuthoringGate(protocol: any, ql: any): boolean { + if (!protocol || typeof protocol.registerAuthoringGate !== 'function') return false; + protocol.registerAuthoringGate('permission', async (a: any) => + assertEnvPermissionSaveAllowed(ql, { name: a.name, packageId: a.packageId ?? null, item: a.item }), + ); + return true; +} + // ───────────────────────────────────────────────────────────────────────────── // Data-door write-through (ADR-0094 D3) // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 8d11ce17a3..13ddbd2310 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -18,6 +18,7 @@ import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './boot import { createPermissionSetWriteThrough, registerPermissionSetProjection, + registerPermissionAuthoringGate, reconcilePermissionSetProjection, } from './permission-set-projection.js'; import { @@ -1217,6 +1218,9 @@ export class SecurityPlugin implements Plugin { envProjectionWired = registerPermissionSetProjection(protocol, { ql, metadata: this.metadata, logger: ctx.logger, }); + // [ADR-0094 D5 / framework#2898] Reject env-door overlays of + // package-owned sets at authoring time (idempotent registration). + registerPermissionAuthoringGate(protocol, ql); } // [ADR-0094 D4] Converge record ↔ metadata: project env overlays // onto records (creating missing ones), backfill legacy data-door diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index c0daf06779..9581104567 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -154,6 +154,28 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ blockedReason: 'the form layout/section/widget surface is not yet governed and has no runtime proof (ADR-0054 Phase 2).', }, + { + id: 'permission-set-projection', + label: 'sys_permission_set pure projection', + summary: + 'the metadata layer is the sole authoritative store for a permission-set definition; the ' + + 'sys_permission_set record is a derived projection (data-door write-through + awaited ' + + 'projection). A record that could drift from — or independently authorize against — its ' + + 'metadata is the two-store split-brain ADR-0094 retires.', + proofId: 'permission-set-projection', + proofRef: 'packages/dogfood/test/showcase-permission-projection.dogfood.test.ts#permission-set-projection', + // Unbound: this is a STORAGE/architecture invariant, not an authorable + // `type.path` property the ledger ratchet can key on — there is no + // permission-set field whose `live` status this proof gates. The proof is + // still registered here (so the tag is not an orphan) and runs + // unconditionally in the dogfood suite; it simply has no ledger property to + // bind. Kept honest per ADR-0054 §3 rather than faking a binding. + bound: false, + ledgerBindings: [], + blockedReason: + 'projection is a storage invariant (ADR-0094), not an authorable spec property — no ledger ' + + 'entry to ratchet; the proof runs unconditionally in the dogfood suite instead.', + }, ]; /** Bound ledger paths → the class that binds them. Key: `/`. */ From 4b7e8b1bd069a86d26a3c62dc5743c8dfd965b40 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:10:35 +0000 Subject: [PATCH 2/5] feat(plugin-security): lock permission-set API name after creation + document projection UX (ADR-0094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup UI alignment for the ADR-0094 pure-projection behavior. The Setup surface is SDUI-driven by the object metadata, so both affordances are object-metadata changes objectui already honors (ObjectForm forwards `readonlyWhen` to fieldRules; the engine also strips such writes server-side) — no objectui code change needed: - sys_permission_set.name gains `readonlyWhen: record.id != null` — the name is the metadata identity the record projects from, so the create form accepts it but the edit form locks it, matching the data-door's 400 rename rejection. Verified the meta API surfaces the CEL predicate and the boot stack loads it cleanly. - permission-sets.mdx documents the one-authoritative-store model: Setup edits of declared sets now enforce, the API name is immutable (clone to rename), and deleting a packaged set resets it to the shipped definition while a self-created set is removed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q --- content/docs/permissions/permission-sets.mdx | 28 +++++++++++++++++++ .../src/objects/rbac-objects.test.ts | 13 +++++++++ .../src/objects/sys-permission-set.object.ts | 12 +++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index cf42fa8c32..c912943bd2 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -200,6 +200,34 @@ bindings, and its pending audience-binding suggestions in the same request (no ghost grants); the uninstall response reports the revocation under `cleanups`. Environment-authored sets and other packages' rows survive. +## One authoritative store — the record is a projection (ADR-0094) + +A permission-set **definition** has a single authoritative store: the metadata +layer (packaged declarations plus the `sys_metadata` overlay). The queryable +`sys_permission_set` record — what Setup lists and user assignment reads — is a +**pure projection** of that definition, never independently authoritative. +Every non-system write on the record (Setup CRUD, a bulk import, any API that +goes through the data engine) is redirected into a metadata write and the +record is re-derived by an awaited projector, so the two can never drift. This +has three author-visible consequences: + +- **Editing a packaged (declared) set through Setup** becomes an environment + overlay of that definition — it now genuinely takes effect, where a + record-only edit previously displayed but never enforced. +- **The API name is immutable after creation.** It is the definition's metadata + identity, so the create form accepts it but the edit form locks it, and a + rename through the data door is rejected. Clone the set to a new name instead. +- **Deleting through the data door depends on where the definition lives.** A + set you created in this environment is removed. A set that ships with an + installed package/app cannot be removed from the environment — "delete" + **resets** it to the shipped definition (the customization overlay is + dropped), and the row remains. Uninstall the owning package to remove a + packaged set entirely. + +An environment-scope customization of a **package-owned** set is refused at +authoring time (`package_owned`): edit it in its package and re-publish, or +clone it to a new environment-owned name. + --- ## See also diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index 07bce5102b..b3e9f033b4 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -144,6 +144,19 @@ describe('RBAC object canonical names + row actions', () => { const names = (SysPermissionSet.actions ?? []).map((a) => a.name).sort(); expect(names).toEqual(['activate_permission_set', 'clone_permission_set', 'deactivate_permission_set']); }); + + it('[ADR-0094] locks the API name after creation (readonly on edit, editable on create)', () => { + // The name is the metadata identity the record projects from — renaming + // through the data door is rejected (400); this is the matching UI lock. + const nameField: any = (SysPermissionSet.fields as any).name; + expect(nameField.readonlyWhen, 'name carries a readonlyWhen lock').toBeTruthy(); + // The predicate keys off the server-assigned id: absent on create, present + // on edit — so create stays editable and edit is locked. + const pred = JSON.stringify(nameField.readonlyWhen); + expect(pred).toContain('record.id'); + // The static readonly flag is NOT set (that would block create too). + expect(nameField.readonly ?? false).toBe(false); + }); }); diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index ab01abd1d1..a944198d90 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { P } from '@objectstack/spec'; /** * sys_permission_set — System Permission Set Object @@ -137,7 +138,16 @@ export const SysPermissionSet = ObjectSchema.create({ required: true, searchable: true, maxLength: 100, - description: 'Unique machine name for the permission set', + description: + 'Unique machine name for the permission set. This is the set’s metadata identity ' + + '(ADR-0094) and cannot be changed after creation — the data door rejects a rename; ' + + 'clone the set to a new name instead.', + // [ADR-0094] The name is the metadata key the record projects from, so it + // is immutable once the record exists. `record.id` is server-assigned: + // absent on the create form (editable), present on edit (locked). The + // data-door write-through independently rejects a rename (400), so this + // is the matching UI affordance rather than the only guard. + readonlyWhen: P`record.id != null && record.id != ''`, group: 'Identity', }), From 900bec3f07dc95550961fe3c35d92fab03668dd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:20:35 +0000 Subject: [PATCH 3/5] docs(adr-0094): addendum generalizing the one-authoritative-store rule to sibling two-store types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the permission-set-specific decision to a classification rule for every declared-metadata ↔ queryable-record two-store type (sys_position, sys_sharing_rule, sys_capability), keyed on which store enforcement reads at request time: - metadata-authoritative → record is a projection (this ADR's machinery, reusable via registerMutationProjector / registerAuthoringGate); - record-authoritative → the record is the authority and declared metadata is a boot SEED only (seed-not-clobber; do NOT project — that would invert the real authority). Records the per-type findings (sys_sharing_rule is record-authoritative — evaluation reads the row live; sys_position needs the seed-vs-authority audit), so the follow-up is scoped, not rediscovered. Tracked in framework#2909. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q --- ...0094-sys-permission-set-pure-projection.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/adr/0094-sys-permission-set-pure-projection.md b/docs/adr/0094-sys-permission-set-pure-projection.md index 8cbee401ba..3a946c96ff 100644 --- a/docs/adr/0094-sys-permission-set-pure-projection.md +++ b/docs/adr/0094-sys-permission-set-pure-projection.md @@ -215,3 +215,71 @@ tracked as framework#2898 rather than silently expanding this change. - Implementation: `packages/plugins/plugin-security/src/permission-set-projection.ts`, `packages/metadata-protocol/src/protocol.ts` (`registerMutationProjector`), `packages/plugins/plugin-security/src/security-plugin.ts` (wiring). + +--- + +## Addendum (2026-07-14): generalizing to the sibling declared-metadata ↔ queryable-record types + +`sys_permission_set` is not the only object with **two stores** — a declared +definition in the metadata layer AND a queryable `sys_*` record, historically +synced only at boot and on publish. An audit found three siblings seeded the +same way: `sys_position` (`bootstrapDeclaredPositions`), `sys_sharing_rule` +(`bootstrapDeclaredSharingRules`), and `sys_capability` +(`bootstrapSystemCapabilities`). This addendum promotes the decision from a +permission-set-specific fix to a **classification rule** for all of them, so a +future maintainer neither leaves a split-brain unaddressed nor naively applies +the wrong cure. + +### The general invariant + +A declared definition and its queryable record must not be **two independently +writable authorities** reconciled only at boot/publish. Exactly one is +authoritative; the other is derived and documented as such — enforced +structurally (a choke point every write traverses), not by a subscriber a new +path can bypass. + +### The classification criterion — *which store does enforcement read at request time?* + +The cure follows the authority, and the authority is decided by one question: +**at request time, does the runtime read the metadata definition or the data +record?** + +- **Metadata-authoritative** (enforcement resolves the definition from the + metadata layer) → the record is a **pure projection** (this ADR's machinery: + data-door write-through + awaited `registerMutationProjector` + + `registerAuthoringGate` + boot reconciliation). The record must never be an + independent authority, because it isn't the one enforcement trusts. +- **Record-authoritative** (enforcement reads the `sys_*` record live) → the + record is the authority and the declared metadata is a **boot SEED only**, + not a competing overlay. The cure is the mirror image: the seeder must not + clobber an environment-edited record, and no path may treat the declared + body as a live override that silently loses to (or fights) the record. **Do + not** apply the projection machinery here — projecting the record *from* + metadata would invert the real authority. + +The generic protocol seams added by this ADR (`registerMutationProjector`, +`registerAuthoringGate`) serve the *metadata-authoritative* case and are +reusable by any such type; the record-authoritative case needs no new seam, +only a seed-not-clobber discipline. + +### Per-type decisions + +| Type | Enforcement reads | Class | Decision | +| :-- | :-- | :-- | :-- | +| `sys_permission_set` | metadata (`PermissionEvaluator.resolvePermissionSets` → `metadata.list('permission')`, DB row only as fallback) | metadata-authoritative | **Record is a projection — done** (this ADR). | +| `sys_sharing_rule` | the record, live (`sharing-plugin.ts` "rule evaluation reads `sys_sharing_rule` live"; `sharing-rule-service` `engine.find`) | **record-authoritative** | Declared rules are a **boot seed**; the record is the authority. Do **not** project. Audit that `bootstrapDeclaredSharingRules` preserves env-edited rows (seed-not-clobber) and that the metadata overlay is not read as a live override. | +| `sys_position` | mixed — position→permission-set resolution is metadata-first for the *sets*, but the `sys_position` **record** (incl. its `permissions` field, bindings, `delegatable`, `admin` gating) is read live by the anchor gate and `DelegatedAdminGate` | **needs the seed-vs-authority audit** against the criterion above; likely record-authoritative for bindings with metadata seeding identity | Classify precisely, then either project (if the position *definition* is enforced from metadata) or make declared positions seed-only. | +| `sys_capability` | the record (curated registry read for capability existence) | record-authoritative (registry) | Seed-only; low authoring surface. Audit seed-not-clobber. | + +Only `sys_permission_set` was both metadata-authoritative **and** carried a +harmful, actively-drifting split-brain, which is why it was fixed first and in +full. The others are recorded here with their class so the follow-up work is +scoped, not rediscovered — tracked in framework#2909. + +### Why an addendum, not a new ADR + +The decision — *one authoritative store; the other derived; enforced +structurally* — is identical; only the per-type **direction** differs. A +separate ADR would duplicate the rationale and split the classification from +the decision that motivates it. This addendum keeps the rule and its +applications in one place. From 69ab87f1af58d8b9ddde76a409dcd6b714ae1d56 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 09:20:54 +0000 Subject: [PATCH 4/5] feat(plugin-security)!: package-owned permission sets customize via the standard env metadata overlay (ADR-0094 D5 revised) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direction confirmed by the maintainers 2026-07-14, reversing the #2898 authoring-time rejection: an env-scope overlay of a package-owned set is the platform's standard ADR-0005 customization, made FIRST-CLASS instead of refused — rejecting it would make `permission` the one type whose declared allowOrgOverride:true is a lie, and clone-to-customize forks away from vendor baseline updates (including security tightenings). - projector: a package-owned record's facets follow the EFFECTIVE (overlay-wins) body; managed_by:'package' + package_id provenance preserved. - write-through: a data-door edit of a packaged set translates into that overlay (no more flat 403); data-door "delete" removes the overlay and RESETS the record to the shipped declaration. Single-store kernels (no metadata overlay layer) keep the legacy two-doors refusal, now asserted by the write-through itself. - two-doors gate narrows to what stays structurally true: provenance forging refused (insert/update, single/array), and transfer/restore/purge on package rows refused (no overlay translation exists for them). - metadata-protocol: the registerAuthoringGate seam is removed again — with the rejection gone it had no consumer, and an unconsumed seam is exactly the inert surface ADR-0049 forbids. - docs: ADR-0094 TL;DR/D2/D5 revised (including the whole-document-overlay upgrade-pinning trade and its mitigations); authorization.mdx + permission-sets.mdx updated; dogfood proofs flipped to the new semantics (overlay customize/reset on showcase_contributor, live). Closes #2898 (resolved by support, not rejection). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q --- .changeset/permission-set-authoring-gate.md | 11 -- .../permission-set-overlay-customization.md | 14 ++ content/docs/permissions/authorization.mdx | 20 +-- content/docs/permissions/permission-sets.mdx | 32 ++-- ...0094-sys-permission-set-pure-projection.md | 64 ++++++-- ...case-permission-projection.dogfood.test.ts | 42 +++-- .../test/two-doors-permission.dogfood.test.ts | 41 +++-- packages/metadata-protocol/src/index.ts | 1 - .../src/mutation-listeners.test.ts | 40 ----- packages/metadata-protocol/src/protocol.ts | 107 ------------- packages/plugins/plugin-security/src/index.ts | 2 - .../src/permission-set-projection.test.ts | 140 ++++++++++++----- .../src/permission-set-projection.ts | 144 +++++++----------- .../src/security-plugin.test.ts | 30 ++-- .../plugin-security/src/security-plugin.ts | 63 +++++--- 15 files changed, 364 insertions(+), 387 deletions(-) delete mode 100644 .changeset/permission-set-authoring-gate.md create mode 100644 .changeset/permission-set-overlay-customization.md diff --git a/.changeset/permission-set-authoring-gate.md b/.changeset/permission-set-authoring-gate.md deleted file mode 100644 index a2be94627e..0000000000 --- a/.changeset/permission-set-authoring-gate.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor -"@objectstack/plugin-security": minor ---- - -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. - -- **`@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). -- **`@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. - -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. diff --git a/.changeset/permission-set-overlay-customization.md b/.changeset/permission-set-overlay-customization.md new file mode 100644 index 0000000000..17df537ef6 --- /dev/null +++ b/.changeset/permission-set-overlay-customization.md @@ -0,0 +1,14 @@ +--- +"@objectstack/plugin-security": minor +--- + +Package-owned permission sets are now customizable through the standard environment metadata overlay (ADR-0094 D5, revised — closes framework#2898 by making the overlay FIRST-CLASS instead of rejecting it). + +- An env-scope `saveMetaItem('permission', …)` on a package-owned set is a real customization: the awaited projector applies the effective (overlay-wins) body to the `sys_permission_set` record while preserving its `managed_by:'package'` + `package_id` provenance, and the evaluator enforces it. +- A data-door edit of a packaged set (Setup PATCH) is translated into exactly that overlay — no more flat 403; a data-door "delete" removes the overlay and RESETS the record to the shipped declaration (the row survives). +- The ADR-0086 two-doors data gate narrows to what stays structurally true: forging package provenance through the admin door remains refused, as do the lifecycle ops with no overlay translation (`transfer`/`restore`/`purge`) on package rows; kernels without a metadata overlay layer keep the legacy full refusal. +- Cross-package roles compose via positions (bind several packages' sets); overlays narrow. Rationale: rejecting the overlay would make `permission` the one type whose declared `allowOrgOverride: true` is a lie, and clone-to-customize forks away from vendor baseline updates. + +Note the standard overlay trade, now applicable to permission sets: while an overlay pins a set, later vendor baseline changes (including tightenings) don't take effect for that name until the overlay is reset or re-authored — surfaced by the Studio layered diff and covered by ADR-0091 recertification. + +Also lands a dogfood proof (`showcase-permission-projection`) covering the full ADR-0094 invariant set — write-through, awaited projection, declared-set edit becomes an enforced overlay, package-set customize/reset lifecycle — registered in the liveness proof registry. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 9443155e17..2cdeb01b61 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -142,15 +142,17 @@ one of two doors, each writing only what it owns: declared body rather than removing it; renaming through the data door is rejected (the name is the metadata identity — clone instead). Subject assignments remain plain config rows, unchanged. -- **Data-layer write gate** — the security middleware **refuses** any admin-door - write to a `managedBy:'package'` `sys_permission_set` row, and refuses a - payload that forges that provenance (insert or update, single or array). It - fails closed ahead of the CRUD check — even a `modifyAllRecords` super-user is - blocked — so the door separation is a real boundary, not a UI hint. System / - boot writes carry `isSystem` and bypass it, so the seeder and materializer are - never self-blocked. An environment adjusts a packaged set through the - ADR-0005 overlay / muting subtract layer (below), never by editing the base - record. +- **Data-layer gate (evolved by ADR-0094)** — the security middleware still + **refuses** any payload that forges package provenance (insert or update, + single or array) and the lifecycle ops with no overlay translation + (`transfer`/`restore`/`purge`) on package rows, failing closed ahead of the + CRUD check — even a `modifyAllRecords` super-user is blocked. Ordinary + admin-door **edits of a packaged set are no longer refused**: the ADR-0094 + write-through translates them into the standard ADR-0005 env-scope + **overlay** — the record projects the effective body while the package keeps + owning the row, and "delete" resets to the shipped declaration. System / + boot writes carry `isSystem` and bypass it, so the seeder and materializer + are never self-blocked. ## Lifecycle coverage (five stages) diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index c912943bd2..8303800560 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -192,13 +192,14 @@ tenant-level. A package ships its own sets (`managedBy: 'package'` + `packageId`), seeded idempotently at boot and re-seeded on upgrade; environment-authored sets -(`platform`/`user`) are never clobbered. The data layer refuses admin-door -writes to package rows (two-doors separation), which is what makes package -uninstall well-defined — and enforced: uninstalling a package -(`DELETE /api/v1/packages/:id`) revokes its own sets, their position/user -bindings, and its pending audience-binding suggestions in the same request -(no ghost grants); the uninstall response reports the revocation under -`cleanups`. Environment-authored sets and other packages' rows survive. +(`platform`/`user`) are never clobbered. The data layer refuses forging package +provenance through the admin door (two-doors separation, evolved by ADR-0094 — +ordinary edits of a packaged set become environment overlays, see below), which +is what makes package uninstall well-defined — and enforced: uninstalling a +package (`DELETE /api/v1/packages/:id`) revokes its own sets, their +position/user bindings, and its pending audience-binding suggestions in the +same request (no ghost grants); the uninstall response reports the revocation +under `cleanups`. Environment-authored sets and other packages' rows survive. ## One authoritative store — the record is a projection (ADR-0094) @@ -211,9 +212,14 @@ goes through the data engine) is redirected into a metadata write and the record is re-derived by an awaited projector, so the two can never drift. This has three author-visible consequences: -- **Editing a packaged (declared) set through Setup** becomes an environment - overlay of that definition — it now genuinely takes effect, where a - record-only edit previously displayed but never enforced. +- **Editing any declared set through Setup — packaged sets included** becomes + an environment overlay of that definition (the standard metadata + customization): it genuinely takes effect, where a record-only edit + previously displayed but never enforced. The row keeps its package + provenance; the Studio layered view diffs the shipped baseline against your + customization, and removing the overlay resets to the baseline. Note the + trade every overlay makes: while an overlay pins a set, the vendor's later + baseline changes don't take effect for it until you reset or re-author. - **The API name is immutable after creation.** It is the definition's metadata identity, so the create form accepts it but the edit form locks it, and a rename through the data door is rejected. Clone the set to a new name instead. @@ -224,9 +230,9 @@ has three author-visible consequences: dropped), and the row remains. Uninstall the owning package to remove a packaged set entirely. -An environment-scope customization of a **package-owned** set is refused at -authoring time (`package_owned`): edit it in its package and re-publish, or -clone it to a new environment-owned name. +Cross-package roles need no hand-authored cross-package set: bind each +package's own sets to one **position** (the union model adds), and use an +overlay where a packaged set must be narrowed. --- diff --git a/docs/adr/0094-sys-permission-set-pure-projection.md b/docs/adr/0094-sys-permission-set-pure-projection.md index 3a946c96ff..e9069c4751 100644 --- a/docs/adr/0094-sys-permission-set-pure-projection.md +++ b/docs/adr/0094-sys-permission-set-pure-projection.md @@ -32,9 +32,12 @@ not by a subscriber a new write path might forget to trigger: re-derived from metadata (metadata wins), and legacy records that exist **only** in the data plane are migrated into the metadata store once. -Package-owned records (`managed_by:'package'`) keep their ADR-0086 semantics: their -baseline is the shipped declaration, projected by boot seeding / publish -materialization; the environment door never touches them. +Package-owned records (`managed_by:'package'`) keep the shipped declaration as their +BASELINE (boot seeding / publish materialization), and — per the revised D5 — the +environment customizes them through the standard ADR-0005 overlay: the record projects +the effective (overlay-wins) body with its package provenance preserved, and removing +the overlay resets it to the declaration. Forging package provenance through the data +door stays impossible. --- @@ -94,10 +97,10 @@ thrown, the metadata write itself succeeded and boot reconciliation heals on nex `plugin-security` registers the `permission` projector. It re-reads the **fresh layered effective body** and: -- upserts the env record (creates it if missing, `managed_by:'user'` — Studio-created - sets now appear in Setup); -- **refuses package-owned records** (`managed_by:'package'`) — the package door owns - them (ADR-0086 D4); +- upserts the record (creates it if missing, `managed_by:'user'` — Studio-created + sets now appear in Setup); a PACKAGE-OWNED record's facets follow the effective + body too, with its `managed_by:'package'` + `package_id` provenance preserved + (see D5 — an env overlay is the standard customization of a packaged set); - syncs the **metadata manager's in-memory `permission` entry** (`registerInMemory`) so the evaluator's registry-first `list('permission')` resolution sees the same effective body it projects — closing the @@ -155,14 +158,45 @@ convergence pass: The pass is idempotent and re-runs harmlessly on every boot. -### D5 — Env-scope overlays of package-owned sets remain inert (and should be rejected at authoring) - -For a name whose record is package-owned, the environment door is refused at -projection (existing #2867 rule, kept). The metadata type registry currently allows -authoring such an overlay (`allowOrgOverride: true`), which produces a layered overlay -that neither projects nor enforces — an ADR-0049 violation surfaced but not fixed here. -Rejecting it at `saveMetaItem` requires a per-type authoring gate in the protocol; -tracked as framework#2898 rather than silently expanding this change. +### D5 — Env-scope overlays of package-owned sets are FIRST-CLASS customizations (revised 2026-07-14) + +**History.** As first landed, the projector refused env-scope bodies for +package-owned records (the #2867 rule), which left an authored overlay of a +packaged set inert — neither projecting nor enforcing. The initial follow-up +(#2898) proposed rejecting such overlays at authoring time. The maintainers +reversed that direction on 2026-07-14: rejection would have made permission +sets the one metadata type whose declared `allowOrgOverride: true` is a lie, +and "clone to customize" **forks** — a clone stops receiving the vendor's +subsequent baseline changes (including security tightenings) and loses the +layered code-vs-overlay diff. + +**Decision.** An environment-scope overlay of a package-owned permission set +is the platform's **standard ADR-0005 customization**, fully supported: + +- the projector projects the EFFECTIVE (overlay-wins) body onto the record + while **preserving** `managed_by:'package'` + `package_id` — the package + still owns the row; the overlay customizes it; +- a data-door edit of a package row is translated by the write-through into + exactly this overlay (no more flat 403); a data-door "delete" removes the + overlay — an ADR-0005 **reset** to the shipped declaration; +- the ADR-0086 two-doors gate narrows to what is still structurally true: + the admin door can never **forge** package provenance, and lifecycle ops + with no overlay translation (`transfer`/`restore`/`purge`) stay refused on + package rows. In a kernel without a metadata overlay layer the legacy full + refusal applies (there is nothing to carry a customization); +- cross-package composition remains a POSITION concern (bind several + packages' sets to one position — the union model adds; an overlay narrows), + and package-first authoring (ADR-0070) gives runtime-created sets a home + package, so the loose `managed_by:'user'` category can retire over time. + +**The risk this accepts, deliberately.** ADR-0005 overlays are whole-document: +an env overlay can widen a vendor baseline, and a vendor's later baseline +*tightening* does not reach a name that is pinned by an overlay until the +overlay is reset or re-authored. Mitigations: the Studio layered view diffs +code-vs-overlay; upgrade flows should surface "customized packaged sets" for +review; ADR-0091 recertification covers overlays like any other grant source. +This is the same trade every overlayable type makes — permission sets no +longer get a bespoke, stricter rule that the rest of the platform contradicts. ## Consequences diff --git a/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts b/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts index d0f07132b5..0c5bdf6b57 100644 --- a/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts +++ b/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts @@ -14,8 +14,10 @@ // 3. Deleting a runtime-only set retires its record; deleting an // artifact-backed set RESETS it to the declared body (the definition // ships with the app and cannot be removed from the environment). -// 4. [framework#2898 / ADR-0094 D5] An environment-door metadata save that -// targets a package-owned set is rejected at authoring time. +// 4. [ADR-0094, direction 2026-07-14] An environment-door metadata save that +// targets a package-owned set is a FIRST-CLASS overlay customization: +// the record projects the effective body with its package provenance +// preserved, and deleting the overlay resets to the shipped declaration. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -118,22 +120,32 @@ describe('sys_permission_set pure projection (ADR-0094)', () => { expect(after.description ?? null).not.toBe('customized via Setup (ADR-0094)'); }); - // ── 4. Authoring gate — env door refuses a package-owned set (#2898) ────── - it('an environment-door metadata save targeting a package-owned set is rejected (ADR-0094 D5)', async () => { + // ── 4. Env overlay of a PACKAGE set is first-class (ADR-0094) ───────────── + it('an environment-door metadata save on a package-owned set customizes it and projects immediately', async () => { const contributor = await findSet('showcase_contributor'); expect(contributor?.managed_by, 'showcase_contributor is package-owned').toBe('package'); + const layeredBefore = await protocol.getMetaItemLayered({ type: 'permission', name: 'showcase_contributor' }); + const baseline = layeredBefore?.code ?? null; + expect(baseline, 'the packaged declaration is the code layer').toBeTruthy(); - await expect( - protocol.saveMetaItem({ - type: 'permission', - name: 'showcase_contributor', - item: { name: 'showcase_contributor', label: 'hijack-via-env-door', objects: {} }, - }), - 'the environment door must refuse a package-owned set', - ).rejects.toMatchObject({ code: 'package_owned' }); - - // The record stays at its package baseline. - expect((await findSet('showcase_contributor')).label).toBe(contributor.label); + await protocol.saveMetaItem({ + type: 'permission', + name: 'showcase_contributor', + item: { ...baseline, label: 'Contributor (env customized)' }, + }); + + // Awaited projection: the record already reflects the overlay, and the + // package provenance is untouched. + const after = await findSet('showcase_contributor'); + expect(after.label).toBe('Contributor (env customized)'); + expect(after.managed_by).toBe('package'); + expect(after.package_id).toBe(contributor.package_id); + + // Deleting the overlay resets the record to the shipped declaration. + await protocol.deleteMetaItem({ type: 'permission', name: 'showcase_contributor' }); + const reset = await findSet('showcase_contributor'); + expect(reset.label).toBe(contributor.label); + expect(reset.managed_by).toBe('package'); }); it('a brand-new environment set authored through the metadata door appears as a Setup record', async () => { diff --git a/packages/dogfood/test/two-doors-permission.dogfood.test.ts b/packages/dogfood/test/two-doors-permission.dogfood.test.ts index ff077376a4..722f8a5680 100644 --- a/packages/dogfood/test/two-doors-permission.dogfood.test.ts +++ b/packages/dogfood/test/two-doors-permission.dogfood.test.ts @@ -9,10 +9,12 @@ // `package_id` (publish-time, not just at boot). A draft alone // materializes nothing — only publish makes it live. // -// 块2 — the ADMIN door: the generic data-plane write path -// (`PATCH /data/sys_permission_set/:id`) refuses to mutate a -// package-managed row — even for the platform admin — so the two doors -// never overwrite each other. An env-authored row stays freely editable. +// 块2 — the ADMIN door (evolved by ADR-0094, direction 2026-07-14): a +// data-plane edit of a package-managed row is TRANSLATED into an +// env-scope metadata OVERLAY (the standard ADR-0005 customization) — +// the record projects the effective body while the package keeps +// owning the row, and "delete" resets to the shipped declaration. +// Forging package provenance through the admin door stays refused. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -70,19 +72,38 @@ describe('two-doors permission separation (ADR-0086 P2)', () => { }); }); - // ── 块2 — admin door: write gate on package-managed rows ────────────────── - it('块2: the admin data door CANNOT edit a package-managed set (403), even as platform admin', async () => { + // ── 块2 — admin door: package rows customize via overlay (ADR-0094) ─────── + it('块2: an admin edit of a package-managed set becomes an env OVERLAY; provenance is preserved', async () => { const contributor = await findSet('showcase_contributor'); expect(contributor?.managed_by, 'showcase_contributor is package-owned').toBe('package'); const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${contributor.id}`, { - label: 'hijacked-through-admin-door', + label: 'Contributor (customized)', }); - expect(res.status).toBe(403); + expect(res.status).toBeLessThan(300); + + // The customization landed as a metadata overlay of the packaged definition… + const layered = await protocol.getMetaItemLayered({ type: 'permission', name: 'showcase_contributor' }); + expect(layered?.overlay, 'edit persisted as an env-scope overlay').toBeTruthy(); + expect(layered.overlay.label).toBe('Contributor (customized)'); + // …the record projects the effective body, and the package still owns it. + const after = await findSet('showcase_contributor'); + expect(after.label).toBe('Contributor (customized)'); + expect(after.managed_by).toBe('package'); + expect(after.package_id).toBe(contributor.package_id); + }); + + it('块2: "deleting" the customized package set removes the overlay and RESETS to the shipped declaration', async () => { + const before = await findSet('showcase_contributor'); + const res = await stack.apiAs(adminToken, 'DELETE', `/data/sys_permission_set/${before.id}`); + expect(res.status).toBeLessThan(300); - // And the row is untouched. const after = await findSet('showcase_contributor'); - expect(after.label).toBe(contributor.label); + expect(after, 'a packaged definition is never removed by the env door').toBeTruthy(); + expect(after.label, 'label reset to the shipped declaration').not.toBe('Contributor (customized)'); + expect(after.managed_by).toBe('package'); + const layered = await protocol.getMetaItemLayered({ type: 'permission', name: 'showcase_contributor' }); + expect(layered?.overlay, 'overlay gone after the reset').toBeFalsy(); }); it('块2: the admin door CAN still edit an env-authored set (isolates the gate to package rows)', async () => { diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 4eb7cd30db..f449252b53 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -3,7 +3,6 @@ export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata } from './protocol.js'; export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js'; export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js'; -export type { MetadataAuthoringGate, AuthoringGateRejection } from './protocol.js'; export { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; export type { diff --git a/packages/metadata-protocol/src/mutation-listeners.test.ts b/packages/metadata-protocol/src/mutation-listeners.test.ts index 8d58962f0d..fdcdec9cd6 100644 --- a/packages/metadata-protocol/src/mutation-listeners.test.ts +++ b/packages/metadata-protocol/src/mutation-listeners.test.ts @@ -116,43 +116,3 @@ describe('ObjectStackProtocolImplementation.registerMutationProjector (ADR-0094) expect(out).toEqual({ success: false, error: 'projection boom' }); }); }); - -// ADR-0094 D5 / framework#2898 — the per-type authoring gate seam. Run inside -// saveMetaItem before persistence; a returned rejection becomes a thrown Error -// carrying code/status. A gate that itself throws is fail-open (logged). -describe('ObjectStackProtocolImplementation.registerAuthoringGate (ADR-0094 D5)', () => { - const gateArgs = (over: Record = {}) => ({ - type: 'permission', name: 'crm_rep', item: {}, organizationId: null, packageId: null, mode: 'publish' as const, ...over, - }); - - it('no-ops when no gate is registered for the type', async () => { - const p = makeProtocol(); - await expect((p as any).runAuthoringGate(gateArgs())).resolves.toBeUndefined(); - }); - - it('throws a structured Error when the gate rejects', async () => { - const p = makeProtocol(); - p.registerAuthoringGate('permission', async () => ({ code: 'package_owned', status: 403, message: 'nope' })); - await expect((p as any).runAuthoringGate(gateArgs())).rejects.toMatchObject({ message: 'nope', code: 'package_owned', status: 403 }); - }); - - it('passes when the gate returns null/void', async () => { - const p = makeProtocol(); - p.registerAuthoringGate('permission', async () => null); - await expect((p as any).runAuthoringGate(gateArgs())).resolves.toBeUndefined(); - }); - - it('normalizes plural type names and passes the singular type to the gate', async () => { - const p = makeProtocol(); - let seenType = ''; - p.registerAuthoringGate('permissions', async (a: any) => { seenType = a.type; return null; }); - await (p as any).runAuthoringGate(gateArgs({ type: 'permission' })); - expect(seenType).toBe('permission'); - }); - - it('is FAIL-OPEN when the gate throws (allows the save, logged)', async () => { - const p = makeProtocol(); - p.registerAuthoringGate('permission', async () => { throw new Error('lookup down'); }); - await expect((p as any).runAuthoringGate(gateArgs())).resolves.toBeUndefined(); - }); -}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0bdc972024..1043db6527 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -792,37 +792,6 @@ export interface MutationProjectionOutcome { error?: string; } -/** - * Structured rejection a {@link MetadataAuthoringGate} returns to refuse a - * save. Surfaced by `saveMetaItem` as a thrown Error carrying `code`/`status`, - * so an authoring surface (Studio / REST) renders an actionable message. - */ -export interface AuthoringGateRejection { - code: string; - status: number; - message: string; -} - -/** - * Per-type authoring gate (ADR-0094 D5, framework#2898). Invoked by - * `saveMetaItem` BEFORE persistence; returning an {@link AuthoringGateRejection} - * refuses the write. The generic protocol layer must not know a domain's - * data-plane shape (e.g. `sys_permission_set`'s `managed_by`), so the owning - * plugin registers a gate that reads whatever it needs and decides. The - * package door (a save carrying the owning `packageId`) and system/boot writes - * are already distinguishable via the gate's args, so a gate can scope itself - * to the environment door precisely. - */ -export type MetadataAuthoringGate = (args: { - type: string; - name: string; - item: unknown; - organizationId: string | null; - packageId: string | null; - mode: 'draft' | 'publish'; - actor?: string; -}) => Promise; - export class ObjectStackProtocolImplementation implements ObjectStackProtocol { private engine: MetadataHostEngine; private getServicesRegistry?: () => Map; @@ -871,14 +840,6 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { */ private mutationProjectors = new Map(); - /** - * Per-type authoring gates (ADR-0094 D5, framework#2898), keyed by singular - * metadata type. Run in `saveMetaItem` before persistence; a returned - * rejection refuses the write. One per type; a second registration - * replaces the first (idempotent re-init). - */ - private authoringGates = new Map(); - constructor( engine: IDataEngine, getServicesRegistry?: () => Map, @@ -926,56 +887,6 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { this.mutationProjectors.set(singular, projector); } - /** - * Register the authoring gate for a metadata type (ADR-0094 D5, - * framework#2898). Called by the plugin that owns the type's data-plane - * projection (e.g. plugin-security gates env-scope `permission` saves that - * target a package-owned set). Singular or plural type names both resolve. - */ - registerAuthoringGate(type: string, gate: MetadataAuthoringGate): void { - const singular = PLURAL_TO_SINGULAR[type] ?? type; - this.authoringGates.set(singular, gate); - } - - /** - * Run the registered authoring gate for a save (ADR-0094 D5). Throws a - * structured Error when the gate rejects; a no-op when no gate is - * registered. A gate that itself throws is treated as FAIL-OPEN (logged): - * this gate closes an inert-metadata hole, not a hard security boundary - * (the data-plane two-doors gate + the projector's package-owned refusal - * already protect the record), so a transient lookup failure must not - * block a legitimate save. - */ - private async runAuthoringGate(args: { - type: string; - name: string; - item: unknown; - organizationId: string | null; - packageId: string | null; - mode: 'draft' | 'publish'; - actor?: string; - }): Promise { - const singular = PLURAL_TO_SINGULAR[args.type] ?? args.type; - const gate = this.authoringGates.get(singular); - if (!gate) return; - let rejection: AuthoringGateRejection | null | void; - try { - rejection = await gate({ ...args, type: singular }); - } catch (e) { - console.warn( - `[Protocol] authoring gate for ${singular}/${args.name} threw — allowing the save (fail-open): ` - + `${e instanceof Error ? e.message : String(e)}`, - ); - return; - } - if (rejection) { - const err = new Error(rejection.message); - (err as any).code = rejection.code; - (err as any).status = rejection.status; - throw err; - } - } - /** * Run the registered projector for a just-persisted mutation (ADR-0094). * Returns `undefined` when no projector is registered for the type; @@ -3963,24 +3874,6 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } } - // [ADR-0094 D5 / framework#2898] Per-type authoring gate. Runs after the - // basic authorization/lock/destructive checks and before persistence, so - // a plugin that owns a type's data-plane projection can refuse a save the - // generic layer can't reason about — e.g. plugin-security rejects an - // environment-door `permission` overlay that targets a package-owned set - // (which would persist an overlay that neither projects nor enforces — - // the inert-metadata hole ADR-0049 forbids). The gate sees `packageId` - // and `mode`, so the package door re-authoring its own set is allowed. - await this.runAuthoringGate({ - type: request.type, - name: request.name, - item: request.item, - organizationId: request.organizationId ?? null, - packageId: request.packageId ?? null, - mode, - ...(request.actor ? { actor: request.actor } : {}), - }); - // Defense-in-depth: reject the layered *read* envelope as a write body. // // `getMetaItemLayered` returns a 3-state diagnostic shape diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index ac50180451..0e4346c392 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -33,8 +33,6 @@ export { upsertEnvPermissionSet, projectPermissionMutation, registerPermissionSetProjection, - registerPermissionAuthoringGate, - assertEnvPermissionSaveAllowed, createPermissionSetWriteThrough, reconcilePermissionSetProjection, } from './permission-set-projection.js'; diff --git a/packages/plugins/plugin-security/src/permission-set-projection.test.ts b/packages/plugins/plugin-security/src/permission-set-projection.test.ts index 0ce547c177..0e95bfdea0 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -17,8 +17,6 @@ import { upsertEnvPermissionSet, projectPermissionMutation, registerPermissionSetProjection, - registerPermissionAuthoringGate, - assertEnvPermissionSaveAllowed, createPermissionSetWriteThrough, reconcilePermissionSetProjection, } from './permission-set-projection.js'; @@ -196,14 +194,19 @@ describe('upsertEnvPermissionSet (ADR-0094 — record is a pure projection)', () expect(r.updated).toBe(1); }); - it('refuses to touch a package-owned row (record stays the package baseline)', async () => { + it('projects onto a PACKAGE-OWNED row (overlay customization) while preserving its provenance', async () => { + // Direction confirmed 2026-07-14: an env overlay of a packaged set is the + // platform's standard ADR-0005 customization — the record follows the + // effective body; the package still owns the row. const ql = makeQl(); ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg"]' }); - const warns: string[] = []; - const r = await upsertEnvPermissionSet(ql, envBody(), { warn: (m) => warns.push(m) }); - expect(r.skippedForeign).toBe(1); - expect(ql.permRows[0].system_permissions).toBe('["pkg"]'); - expect(warns.some((w) => w.includes('package-owned'))).toBe(true); + const r = await upsertEnvPermissionSet(ql, envBody()); + expect(r.updated).toBe(1); + const row = ql.permRows[0]; + expect(JSON.parse(row.system_permissions)).toEqual(['setup.access', 'manage_org_users']); + expect(row.managed_by).toBe('package'); // provenance preserved + expect(row.package_id).toBe('com.example.crm'); + expect(row.id).toBe('ps_pkg'); // id stable }); }); @@ -303,47 +306,41 @@ describe('registerPermissionSetProjection', () => { }); }); -// ── Authoring gate (ADR-0094 D5 / framework#2898) ─────────────────────────── +// ── Package-set customization via overlay (ADR-0094, direction 2026-07-14) ── -describe('assertEnvPermissionSaveAllowed', () => { - it('rejects an env-door save targeting a package-owned record', async () => { +describe('package-owned set customization lifecycle (env overlay)', () => { + it('a Studio env-scope save on a PACKAGE name customizes the record and keeps provenance', async () => { const ql = makeQl(); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' }); - const r = await assertEnvPermissionSaveAllowed(ql, { name: 'crm_rep', packageId: null }); - expect(r?.code).toBe('package_owned'); - expect(r?.status).toBe(403); - }); + const declaredBody = envBody({ systemPermissions: ['pkg.baseline'] }); + (ql as any)._registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; + const protocol = makeProtocol(ql, { organization_admin: declaredBody }); + registerPermissionSetProjection(protocol, { ql }); + ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); - it('allows the package door (save carries the owning packageId)', async () => { - const ql = makeQl(); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' }); - expect(await assertEnvPermissionSaveAllowed(ql, { name: 'crm_rep', packageId: 'com.example.crm' })).toBeNull(); - }); + await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['customized'] }) }); - it('allows an env-authored set and a brand-new name', async () => { - const ql = makeQl(); - ql.permRows.push({ id: 'ps_env', name: 'my_custom', managed_by: 'user' }); - expect(await assertEnvPermissionSaveAllowed(ql, { name: 'my_custom', packageId: null })).toBeNull(); - expect(await assertEnvPermissionSaveAllowed(ql, { name: 'does_not_exist', packageId: null })).toBeNull(); + const row = ql.permRows[0]; + expect(JSON.parse(row.system_permissions)).toEqual(['customized']); + expect(row.managed_by).toBe('package'); + expect(row.package_id).toBe('com.example.crm'); }); - it('registers the gate on a capable protocol and refuses the save through it', async () => { + it('deleting the overlay RESETS the package record to its declared baseline', async () => { const ql = makeQl(); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' }); - let gate: any = null; - const protocol = { registerAuthoringGate: (_t: string, fn: any) => { gate = fn; } }; - expect(registerPermissionAuthoringGate(protocol, ql)).toBe(true); - expect(typeof gate).toBe('function'); - const rej = await gate({ type: 'permission', name: 'crm_rep', item: {}, organizationId: null, packageId: null, mode: 'publish' }); - expect(rej?.code).toBe('package_owned'); - // env-owned name passes - ql.permRows.push({ id: 'ps_env', name: 'ok_set', managed_by: 'user' }); - expect(await gate({ type: 'permission', name: 'ok_set', item: {}, organizationId: null, packageId: null, mode: 'publish' })).toBeNull(); - }); + const declaredBody = envBody({ systemPermissions: ['pkg.baseline'] }); + (ql as any)._registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; + const protocol = makeProtocol(ql, { organization_admin: declaredBody }); + registerPermissionSetProjection(protocol, { ql }); + ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); - it('returns false on a protocol without registerAuthoringGate', () => { - expect(registerPermissionAuthoringGate({}, makeQl())).toBe(false); - expect(registerPermissionAuthoringGate(null, makeQl())).toBe(false); + await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['customized'] }) }); + expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['customized']); + + await protocol.deleteMetaItem({ type: 'permission', name: 'organization_admin' }); + const row = ql.permRows[0]; + expect(row, 'a packaged definition is never removed by an overlay reset').toBeTruthy(); + expect(JSON.parse(row.system_permissions)).toEqual(['pkg.baseline']); + expect(row.managed_by).toBe('package'); }); }); @@ -478,6 +475,67 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['declared.only']); // …reset to the declaration }); + it('UPDATE of a PACKAGE-OWNED set becomes an env overlay; the record keeps its provenance', async () => { + const ql = makeQl(); + const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); + (ql as any)._registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; + const protocol = makeProtocol(ql, { crm_rep: declaredBody }); + registerPermissionSetProjection(protocol, { ql }); + ql.permRows.push({ + id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', + system_permissions: '["pkg.baseline"]', + }); + const mw = makeMiddleware(ql, protocol); + const opCtx: any = { + object: 'sys_permission_set', operation: 'update', context: userCtx, + data: { id: 'ps_pkg', system_permissions: '["customized"]' }, + }; + const nextCalled = await run(mw, opCtx); + expect(nextCalled).toBe(false); + // The customization lives in the metadata overlay… + expect(JSON.parse(ql.metaRows[0].metadata).systemPermissions).toEqual(['customized']); + // …the record projects it, and the package still owns the row. + const row = ql.permRows[0]; + expect(JSON.parse(row.system_permissions)).toEqual(['customized']); + expect(row.managed_by).toBe('package'); + expect(row.package_id).toBe('com.example.crm'); + }); + + it('DELETE of a customized PACKAGE set removes the overlay and resets to the declared baseline', async () => { + const ql = makeQl(); + const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); + (ql as any)._registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; + const protocol = makeProtocol(ql, { crm_rep: declaredBody }); + registerPermissionSetProjection(protocol, { ql }); + ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); + const mw = makeMiddleware(ql, protocol); + // customize first + await run(mw, { object: 'sys_permission_set', operation: 'update', context: userCtx, data: { id: 'ps_pkg', system_permissions: '["customized"]' } }); + expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['customized']); + // "delete" = reset + const nextCalled = await run(mw, { object: 'sys_permission_set', operation: 'delete', options: { where: { id: 'ps_pkg' } }, context: userCtx }); + expect(nextCalled).toBe(false); + expect(ql.metaRows.length).toBe(0); // overlay gone + expect(ql.permRows.length).toBe(1); // record survives + expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['pkg.baseline']); + expect(ql.permRows[0].managed_by).toBe('package'); + }); + + it('SINGLE-STORE kernel (no protocol): package rows keep the legacy two-doors refusal', async () => { + const ql = makeQl(); + ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' }); + const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => null }); + await expect( + run(mw, { object: 'sys_permission_set', operation: 'update', data: { id: 'ps_pkg', label: 'hijack' }, context: userCtx }), + ).rejects.toMatchObject({ status: 403 }); + await expect( + run(mw, { object: 'sys_permission_set', operation: 'delete', options: { where: { id: 'ps_pkg' } }, context: userCtx }), + ).rejects.toMatchObject({ status: 403 }); + // env rows still pass through to the driver in single-store kernels + ql.permRows.push({ id: 'ps_env', name: 'my_custom', managed_by: 'user' }); + expect(await run(mw, { object: 'sys_permission_set', operation: 'update', data: { id: 'ps_env', label: 'ok' }, context: userCtx })).toBe(true); + }); + it('leaves non-sys_permission_set objects and unrelated operations alone', async () => { const ql = makeQl(); const mw = makeMiddleware(ql, makeProtocol(ql)); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 6a56b7492b..404ff7826a 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -25,9 +25,16 @@ * drift left by historic writes and migrates legacy data-door-created * records into the metadata store (one-time backfill). * - * Package-owned records (`managed_by:'package'`) remain the package door's - * territory (ADR-0086): their baseline is the shipped declaration, projected - * by boot seeding / publish materialization; the env door refuses them. + * Package-owned records (`managed_by:'package'`) keep their shipped + * declaration as the BASELINE (boot seeding / publish materialization), and — + * direction confirmed 2026-07-14 — the environment customizes them through + * the platform's standard ADR-0005 metadata overlay: a data-door edit of a + * package set becomes an env-scope overlay, the record projects the EFFECTIVE + * (overlay-wins) body with its package provenance preserved, and deleting the + * overlay (the data-door "delete") resets the record to the declaration. + * Cross-package composition stays a POSITION concern (bind several packages' + * sets to one position); package-first authoring (ADR-0070) gives + * runtime-created sets a home package. */ export const SYSTEM_CTX = { isSystem: true }; @@ -179,21 +186,24 @@ function hasSchemaRegistry(ql: any): boolean { } /** - * Project an ENVIRONMENT-authored PermissionSet body onto its - * `sys_permission_set` row — the env-door counterpart of - * `upsertPackagePermissionSet` (ADR-0086 two-doors). + * Project a PermissionSet body onto its `sys_permission_set` row from the + * ENVIRONMENT side. * - * [ADR-0094] The record is a pure projection now, so a missing row is - * CREATED (`managed_by:'user'`) — a Studio-authored set appears in Setup — - * where the #2867 band-aid declined to create. Ownership is still decided by - * the EXISTING RECORD's `managed_by`, never the body (the layered read stamps - * `_packageId` provenance on env-authored sets too): a package-owned row is - * refused — its baseline is the shipped declaration. + * [ADR-0094] The record is a pure projection, so a missing row is CREATED + * (`managed_by:'user'` — a Studio-authored set appears in Setup, where the + * #2867 band-aid declined to create). A PACKAGE-OWNED row is also projected — + * an env-scope overlay is the platform's standard customization of a packaged + * definition (ADR-0005; direction confirmed 2026-07-14, reversing the earlier + * refuse-the-env-door rule): the facets update to the EFFECTIVE (overlay-wins) + * body while the `managed_by:'package'` + `package_id` provenance is + * PRESERVED — the row still belongs to the package; the overlay is a + * customization of it, and deleting the overlay resets the row to the shipped + * declaration (the layered read reveals the baseline again). */ export async function upsertEnvPermissionSet( ql: any, ps: any, - logger?: ProjectionLogger, + _logger?: ProjectionLogger, ): Promise { const out: PermissionSeedOutcome = { seeded: 0, updated: 0, skippedEnvAuthored: 0, skippedForeign: 0 }; if (!ql || typeof ql.find !== 'function' || !ps?.name) return out; @@ -211,15 +221,9 @@ export async function upsertEnvPermissionSet( return out; } - // A package-owned record is the package's declared baseline (re-seeded at - // boot / on publish); an env override lives in the overlay/effective layer, - // not this row. Refusing here keeps the two doors from fighting. - if (existing.managed_by === 'package') { - out.skippedForeign += 1; - logger?.warn?.('[security] env permission save targets a package-owned set — record left at package baseline', { name: ps.name }); - return out; - } - + // Facets follow the effective body; provenance columns are never touched + // here — a package-owned row keeps its owner while carrying the overlay's + // customization, and an env row keeps its user/platform/legacy provenance. const patch: Record = { id: existing.id, ...permissionSetRowFields(ps) }; if (ps.active != null) patch.active = asBool(ps.active); if (await tryUpdate(ql, 'sys_permission_set', patch)) { @@ -421,61 +425,6 @@ export function registerPermissionSetProjection( return false; } -// ───────────────────────────────────────────────────────────────────────────── -// Authoring gate (ADR-0094 D5, framework#2898) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Reject an ENVIRONMENT-door `permission` save whose target name is a - * package-owned `sys_permission_set` record. A package set's definition lives - * in its package (edited via the package door → publish); an env-scope overlay - * of it would persist a layered overlay that the projector refuses to apply - * (ADR-0086 D4) and the resolver never enforces as an override — the exact - * declared-but-inert surface ADR-0049 forbids. Rejecting at authoring time - * turns that silent no-op into an actionable error. - * - * Scoped to the ENV door precisely: a save carrying the owning `packageId` - * (the package door re-authoring its own set, draft→publish) is allowed, and - * so is any save for a name with no package-owned record (new env sets, - * env-authored sets, package sets being materialized by the system path — - * which never routes through here). Fail-open on lookup error: this closes an - * inert-metadata hole, not a hard boundary (the data-plane two-doors gate and - * the projector's refusal already protect the record). - */ -export async function assertEnvPermissionSaveAllowed( - ql: any, - args: { name: string; packageId: string | null; item?: unknown }, -): Promise<{ code: string; status: number; message: string } | null> { - // Package door — the package re-authoring its own metadata; not the env door. - if (args.packageId) return null; - if (!ql || typeof ql.find !== 'function' || !args.name) return null; - const existing = (await tryFind(ql, 'sys_permission_set', { name: args.name }, 1))[0]; - if (existing?.managed_by === 'package') { - return { - code: 'package_owned', - status: 403, - message: - `[Security] '${args.name}' is a package-managed permission set (managed_by:'package') — ` + - `it cannot be customized through the environment door. Edit it in its package and re-publish, ` + - `or clone it to a new name to author an environment-owned set (ADR-0094 D5 / ADR-0086 two-doors).`, - }; - } - return null; -} - -/** - * Register the `permission` authoring gate on the protocol (ADR-0094 D5). - * Returns `true` when wired, `false` on a protocol that predates - * `registerAuthoringGate`. - */ -export function registerPermissionAuthoringGate(protocol: any, ql: any): boolean { - if (!protocol || typeof protocol.registerAuthoringGate !== 'function') return false; - protocol.registerAuthoringGate('permission', async (a: any) => - assertEnvPermissionSaveAllowed(ql, { name: a.name, packageId: a.packageId ?? null, item: a.item }), - ); - return true; -} - // ───────────────────────────────────────────────────────────────────────────── // Data-door write-through (ADR-0094 D3) // ───────────────────────────────────────────────────────────────────────────── @@ -550,16 +499,20 @@ export interface WriteThroughDeps extends ProjectionDeps { /** * Engine middleware: redirect every non-system data-door write on * `sys_permission_set` into the metadata store (ADR-0094 D3). Registered - * INSIDE the security middleware (later in the onion), so the two-doors gate, - * the delegated-admin gate, and the ordinary CRUD/FLS checks have all passed - * before a write is translated. The driver write never executes — `opCtx.result` - * is the projected record — so no data-plane path can desync record from - * metadata. + * INSIDE the security middleware (later in the onion), so the provenance- + * forging gate, the delegated-admin gate, and the ordinary CRUD/FLS checks + * have all passed before a write is translated. The driver write never + * executes — `opCtx.result` is the projected record — so no data-plane path + * can desync record from metadata. * + * A PACKAGE-OWNED row is writable through here too: its update/delete + * translate into env-scope overlay operations (customize / reset) — the + * ADR-0005 layering carries the two doors now, instead of a flat refusal. * System-context writes pass through untouched: they ARE the projector / * seeder channel. Kernels without a capable metadata protocol (minimal - * embeddings, unit-test stubs) also pass through — a single store has no - * split brain to prevent. + * embeddings, unit-test stubs) pass through for env rows and keep the legacy + * two-doors refusal for package rows — with no overlay layer there is + * nothing to carry a customization. */ export function createPermissionSetWriteThrough( deps: WriteThroughDeps, @@ -585,7 +538,28 @@ export function createPermissionSetWriteThrough( && typeof protocol.saveMetaItem === 'function' && typeof protocol.deleteMetaItem === 'function' && typeof protocol.getMetaItemLayered === 'function'; - if (!capable) return next(); + if (!capable) { + // Single-store kernel: there is no overlay layer to translate a + // package-set customization into, so the legacy ADR-0086 two-doors + // protection applies HERE (the outer security gate delegates the + // update/delete package-row check to this middleware): a + // package-managed row stays read-only through the data door. + if (op === 'update' || op === 'delete') { + const targets = await resolveTargetRows(ql, opCtx); + const pkg = targets.find((t: any) => t?.managed_by === 'package'); + if (pkg) { + const err: any = new Error( + `[Security] Access denied: '${String(pkg.name ?? pkg.id)}' is a package-managed permission set ` + + `(managed_by:'package') and this kernel has no metadata overlay layer to carry an environment ` + + `customization — change it by editing its package and re-publishing (ADR-0086 two-doors).`, + ); + err.name = 'PermissionDeniedError'; + err.status = 403; + throw err; + } + } + return next(); + } const actor = opCtx?.context?.userId ? String(opCtx.context.userId) : undefined; const actorArg = actor ? { actor } : {}; diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 04b6abc82a..8cf070b788 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -1091,20 +1091,25 @@ describe('SecurityPlugin', () => { return harness.run(opCtx); }; - it('DENIES an admin update of a package-managed set (even with modifyAllRecords)', async () => { + it('PASSES an admin update of a package-managed set (ADR-0094: the write-through turns it into an env overlay)', async () => { + // Direction confirmed 2026-07-14: update/delete on a package row are no + // longer refused at this gate — the ADR-0094 write-through downstream + // translates them into env-scope overlay operations (customize / reset). + // The single-store refusal lives in the write-through itself, covered in + // permission-set-projection.test.ts. const opCtx: any = { object: 'sys_permission_set', operation: 'update', - data: { id: 'ps_pkg', label: 'hijack' }, options: { where: { id: 'ps_pkg' } }, + data: { id: 'ps_pkg', label: 'customize' }, options: { where: { id: 'ps_pkg' } }, context: adminCtx, }; await expect( runGate(opCtx, () => ({ id: 'ps_pkg', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + ).resolves.toBeDefined(); }); - it('DENIES an admin delete of a package-managed set', async () => { + it('still DENIES lifecycle ops with no overlay translation (purge) on a package-managed set', async () => { const opCtx: any = { - object: 'sys_permission_set', operation: 'delete', + object: 'sys_permission_set', operation: 'purge', options: { where: { id: 'ps_pkg' } }, context: adminCtx, }; @@ -1158,9 +1163,9 @@ describe('SecurityPlugin', () => { ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); }); - it('DENIES even a principal-less write to a package row (gate is before the fall-open)', async () => { + it('DENIES even a principal-less lifecycle write to a package row (gate is before the fall-open)', async () => { const opCtx: any = { - object: 'sys_permission_set', operation: 'delete', + object: 'sys_permission_set', operation: 'purge', options: { where: { id: 'ps_pkg' } }, context: {}, // no roles, no permissions, no userId, not isSystem }; @@ -1178,9 +1183,9 @@ describe('SecurityPlugin', () => { await expect(runGate(opCtx)).resolves.toBeDefined(); }); - it('DENIES a filter write whose filter matches a package-managed row', async () => { + it('DENIES a filter LIFECYCLE write whose filter matches a package-managed row', async () => { const opCtx: any = { - object: 'sys_permission_set', operation: 'delete', + object: 'sys_permission_set', operation: 'purge', options: { where: { active: true } }, // no single id → filter path context: adminCtx, }; @@ -1190,16 +1195,13 @@ describe('SecurityPlugin', () => { ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); }); - it('ALLOWS a filter write that matches only env-authored rows (no over-broad block)', async () => { + it('ALLOWS a filter update (bulk edits route through the write-through downstream)', async () => { const opCtx: any = { object: 'sys_permission_set', operation: 'update', data: { label: 'bulk-rename' }, options: { where: { managed_by: 'user' } }, context: adminCtx, }; - await expect( - // the gate's package-row probe finds nothing within the filter → allow - runGate(opCtx, () => null), - ).resolves.toBeDefined(); + await expect(runGate(opCtx, () => null)).resolves.toBeDefined(); }); it('lets system/boot writes through (isSystem bypass) even on a package row', async () => { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 13ddbd2310..f4728fa254 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -18,7 +18,6 @@ import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './boot import { createPermissionSetWriteThrough, registerPermissionSetProjection, - registerPermissionAuthoringGate, reconcilePermissionSetProjection, } from './permission-set-projection.js'; import { @@ -493,17 +492,21 @@ export class SecurityPlugin implements Plugin { ); } - // [ADR-0086 P2 — 块2] Two-doors write gate. A permission set stamped - // `managed_by:'package'` is owned by the PACKAGE door: it is authored in - // the package and lands via publish (块1). The ADMIN door (this data-plane - // write path) must NOT edit, delete, or forge that provenance — otherwise - // the next boot re-seed silently reverts the admin's change and the - // provenance axis becomes a lie. Placed BEFORE the empty-principal - // fall-open and the CRUD check so it is a real, unconditional data-layer - // boundary — it holds even for a principal-less context and even for a - // superuser with modifyAllRecords. System/boot writes carry `isSystem` and - // already short-circuited the whole middleware above, so the seeder and - // the publish materializer pass straight through. + // [ADR-0086 P2 — 块2, evolved by ADR-0094] Two-doors write gate. A + // permission set stamped `managed_by:'package'` is owned by the PACKAGE + // door: its BASELINE is authored in the package and lands via publish + // (块1). The admin door must never FORGE that provenance (insert or + // update), and the lifecycle ops with no overlay translation + // (transfer/restore/purge) stay refused on package rows. Ordinary + // `update`/`delete` on a package row are handled downstream by the + // ADR-0094 write-through, which translates them into env-scope OVERLAY + // operations (customize / reset via the standard ADR-0005 layering) — + // the boot re-seed can no longer revert an admin's change, because the + // change lives in the overlay and the record projects overlay-wins. + // Placed BEFORE the empty-principal fall-open and the CRUD check so the + // forging boundary holds even for a principal-less context and a + // superuser with modifyAllRecords. System/boot writes carry `isSystem` + // and already short-circuited the whole middleware above. await this.assertPackageManagedWriteGate(opCtx); // [ADR-0090 D5/D9] Audience-anchor binding guard — like the package @@ -1218,9 +1221,6 @@ export class SecurityPlugin implements Plugin { envProjectionWired = registerPermissionSetProjection(protocol, { ql, metadata: this.metadata, logger: ctx.logger, }); - // [ADR-0094 D5 / framework#2898] Reject env-door overlays of - // package-owned sets at authoring time (idempotent registration). - registerPermissionAuthoringGate(protocol, ql); } // [ADR-0094 D4] Converge record ↔ metadata: project env overlays // onto records (creating missing ones), backfill legacy data-door @@ -1552,16 +1552,21 @@ export class SecurityPlugin implements Plugin { * `*Many` paths, out of scope for the by-id pre-image check). */ /** - * [ADR-0086 P2 — 块2] Two-doors data-layer write gate for `sys_permission_set`. + * [ADR-0086 P2 — 块2, evolved by ADR-0094] Two-doors data-layer gate for + * `sys_permission_set`. * - * A row with `managed_by:'package'` is owned by the package door (authored in - * the package, materialized on publish). The admin door — the generic - * `/api/v1/data/sys_permission_set` write path this middleware guards — must - * not mutate or delete it, nor may it forge that provenance on insert. Fails - * CLOSED and never depends on the caller's grants, so a platform admin with - * `modifyAllRecords` is blocked just the same. System/boot writes never reach - * here (the middleware short-circuits on `isSystem`), so the seeder and the - * publish materializer are unaffected. + * A row with `managed_by:'package'` is owned by the package door (its + * baseline is authored in the package, materialized on publish). This gate + * refuses (a) FORGING that provenance through the admin door — insert or + * update, single object or array — and (b) the lifecycle ops with no + * overlay translation (`transfer`/`restore`/`purge`) on package rows. + * Ordinary `update`/`delete` pass through: the ADR-0094 write-through + * downstream translates them into env-scope overlay operations (customize / + * reset), and re-asserts the refusal itself when the kernel has no metadata + * overlay layer. Fails CLOSED and never depends on the caller's grants, so + * a platform admin with `modifyAllRecords` cannot forge provenance either. + * System/boot writes never reach here (the middleware short-circuits on + * `isSystem`), so the seeder and the publish materializer are unaffected. */ /** * [ADR-0090 D5/D9] Reject binding a HIGH-PRIVILEGE permission set to an @@ -1641,6 +1646,16 @@ export class SecurityPlugin implements Plugin { } if (op === 'insert') return; // no existing row to protect + // [ADR-0094, direction confirmed 2026-07-14] `update`/`delete` on a + // package-managed row are no longer refused here: the write-through + // middleware (which runs after this gate + the delegated-admin gate + + // the CRUD checks) translates them into env-scope OVERLAY operations — + // customize / reset via the standard ADR-0005 layering — and itself + // re-asserts the legacy refusal when the kernel has no metadata overlay + // layer to carry the customization. The lifecycle ops below have no + // overlay translation, so the package-row protection stays for them. + if (op === 'update' || op === 'delete') return; + if (!this.ql) return; const targetId = this.extractSingleId(opCtx); From 2574493c91cf70d7fd27720700f4fc13135aa460 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 09:22:50 +0000 Subject: [PATCH 5/5] docs(permissions): fix role-word guard violation in permission-sets.mdx (ADR-0090 D3) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q --- content/docs/permissions/permission-sets.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 8303800560..19aeccb01a 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -230,8 +230,8 @@ has three author-visible consequences: dropped), and the row remains. Uninstall the owning package to remove a packaged set entirely. -Cross-package roles need no hand-authored cross-package set: bind each -package's own sets to one **position** (the union model adds), and use an +A cross-package job function needs no hand-authored cross-package set: bind +each package's own sets to one **position** (the union model adds), and use an overlay where a packaged set must be narrowed. ---