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 cf42fa8c32..19aeccb01a 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -192,13 +192,47 @@ 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) + +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 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. +- **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. + +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. --- diff --git a/docs/adr/0094-sys-permission-set-pure-projection.md b/docs/adr/0094-sys-permission-set-pure-projection.md index 8cbee401ba..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 @@ -215,3 +249,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. 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..0c5bdf6b57 --- /dev/null +++ b/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts @@ -0,0 +1,165 @@ +// 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. [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'; +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. 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 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 () => { + 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/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/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', }), 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..0e95bfdea0 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -194,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 }); }); @@ -301,6 +306,44 @@ describe('registerPermissionSetProjection', () => { }); }); +// ── Package-set customization via overlay (ADR-0094, direction 2026-07-14) ── + +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(); + 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"]' }); + + await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['customized'] }) }); + + 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('deleting the overlay RESETS the package record to its declared baseline', async () => { + const ql = makeQl(); + 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"]' }); + + 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'); + }); +}); + // ── Data-door write-through (ADR-0094 D3) ─────────────────────────────────── function makeMiddleware(ql: any, protocol: any, metadata?: any) { @@ -432,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 fe369616ea..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)) { @@ -495,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, @@ -530,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 8d11ce17a3..f4728fa254 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -492,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 @@ -1548,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 @@ -1637,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); 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: `/`. */