diff --git a/.changeset/permission-set-pure-projection.md b/.changeset/permission-set-pure-projection.md new file mode 100644 index 0000000000..2a5d24fbbf --- /dev/null +++ b/.changeset/permission-set-pure-projection.md @@ -0,0 +1,13 @@ +--- +"@objectstack/metadata-protocol": minor +"@objectstack/plugin-security": minor +--- + +Make the `sys_permission_set` data record a pure projection of the metadata layer (ADR-0094; framework#2875) — one authoritative store for permission-set definitions, retiring the two-store split-brain behind the #2857 display-freshness class. + +- **`@objectstack/metadata-protocol`**: new `registerMutationProjector(type, fn)` — an awaited, best-effort per-type hook invoked after persistence inside `saveMetaItem` / `publishMetaItem` / `deleteMetaItem`, so a derived data-plane read-model is already consistent when the write returns (outcome surfaced as `projectionApplied` on the response). Complements the fire-and-forget `onMetadataMutation` listeners. +- **`@objectstack/plugin-security`**: every non-system data-door write on `sys_permission_set` (Setup CRUD, bulk imports, any ObjectQL path) is redirected into the metadata store by an engine middleware; the record is written only by the projector. Boot reconciliation projects env overlays onto records (Studio-created sets now appear in Setup), backfills legacy data-door-only records into metadata once, and re-projects drifted records from the effective body (metadata wins). The projector also syncs the metadata manager's in-memory `permission` entry, so evaluator resolution and the Setup display can no longer disagree. + +Behavior changes: "deleting" an artifact-backed permission set through the data door now resets it to its declared body instead of removing the row; renaming a set through the data door is rejected (`400`) — clone to a new name instead; record edits that predate this change and are shadowed by a metadata definition are discarded (loud warning) at first boot, since they were never enforced. + +Moved exports (from `@objectstack/plugin-security`): `upsertEnvPermissionSet` now lives in `permission-set-projection.js` (still re-exported from the package root) and **creates** missing records; `projectEnvPermissionOnMutation` / `subscribeEnvPermissionProjection` are replaced by `projectPermissionMutation` / `registerPermissionSetProjection`. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index d378c9a5fa..9443155e17 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -133,6 +133,15 @@ one of two doors, each writing only what it owns: matrix plus subject **assignment** (`sys_user_permission_set`, `sys_position_permission_set`), edited **live** (config). It owns env-authored sets (`managedBy` `platform`/`user`) and assignments — not package sets. + Since ADR-0094 the set **definition** itself has one authoritative store — + the metadata layer: an env-door write to `sys_permission_set` (the Setup + CRUD) is transparently redirected into an env-scope metadata save + (`saveMetaItem`), and the data record is a **pure projection** the platform + re-derives on every metadata mutation (awaited — no staleness window) and at + boot. Deleting an artifact-backed set through this door **resets** it to its + 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 diff --git a/docs/adr/0094-sys-permission-set-pure-projection.md b/docs/adr/0094-sys-permission-set-pure-projection.md new file mode 100644 index 0000000000..8cbee401ba --- /dev/null +++ b/docs/adr/0094-sys-permission-set-pure-projection.md @@ -0,0 +1,217 @@ +# ADR-0094: Permission-Set Definitions Have One Authoritative Store — `sys_permission_set` Becomes a Pure Projection + +**Status**: Accepted (2026-07-14) +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (overlay store), [ADR-0056](./0056-permission-model-landing-verification.md) (landing verification), [ADR-0086](./0086-authz-metadata-config-boundary-and-cross-package-composition.md) (two doors / provenance) +**Closes**: framework#2875 (root cause behind the #2857 display-freshness class) +**Consumers**: `@objectstack/plugin-security`, `@objectstack/metadata-protocol`, Setup/Studio surfaces + +--- + +## TL;DR + +A permission-set **definition** (label, description, the six facet groups, `adminScope`, +`active`) now has exactly **one authoritative store: the metadata layer** — packaged +declarations plus the `sys_metadata` overlay, merged overlay-wins by the protocol's +layered read. The queryable `sys_permission_set` data record is a **derived read-model +(projection)**, never independently authoritative. This is enforced **structurally**, +not by a subscriber a new write path might forget to trigger: + +1. **Write-through at the engine choke point.** Every non-system data-plane write to + `sys_permission_set` (the Setup UI's generic CRUD, bulk imports, any future API that + goes through ObjectQL) is intercepted by an engine middleware and **redirected into a + metadata write** (`saveMetaItem` / `deleteMetaItem`). The driver write never executes, + so no data-door path can produce a record the metadata doesn't back. +2. **Awaited projection.** The metadata protocol gains a per-type **mutation projector** + (`registerMutationProjector`) that is **awaited inside** `saveMetaItem` / + `publishMetaItem` / `deleteMetaItem`, after persistence and before the write returns. + The projector is the **only writer** of the record. A Studio save therefore returns + only after the record already reflects it — no projection race (the #2867 subscriber + was fire-and-forget). +3. **Boot reconciliation + one-time backfill.** At `kernel:ready` the projection is + 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. + +--- + +## Context + +`sys_permission_set` had **two writable stores** that were only loosely synchronized: + +- **The metadata layer** — declarations registered by packages, plus env-scope edits + written to the `sys_metadata` overlay by Studio (`saveMetaItem`). This is what the + layered read shows and (mostly — see below) what enforcement resolves. +- **The data record** — snake_case JSON-string columns that Setup reads for lists and + user assignment, and *wrote* through the generic data CRUD endpoint. + +They were synced at boot and on publish (ADR-0086 D5/P2), and — after #2867 — by an +`onMetadataMutation` subscriber projecting env-scope metadata saves onto the record. +That subscriber is eventually-consistent glue: any write path that bypasses it (a bulk +import, a migration, a future API) desyncs the two stores with no single winner. + +The audit for this ADR found the split-brain is worse than a stale display: + +- **Enforcement is metadata-first.** `PermissionEvaluator.resolvePermissionSets` + resolves names from `metadata.list('permission')` first, the DB record last. A Setup + edit of a *declared* set (e.g. `member_default`) therefore updated the record — and + was **silently enforcement-inert**: the evaluator kept using the declared body. The + record lied in *both* directions. +- **The manager's `list()` is registry-first**, while the protocol's layered read is + overlay-wins. An env-scope Studio edit of a declared set displayed (layered read, + and — after #2867 — the record) but the evaluator still resolved the *declared* body + from the in-memory registry. Display and enforcement disagreed with no error anywhere. +- **Studio-created env sets never appeared in Setup** (the #2867 projection declined to + create records), and Setup-created sets never existed in metadata at all — the record + was their *only* store, resolvable solely through the evaluator's DB fallback loader. + +## Decision + +### D1 — The metadata layer is the only authoritative store for definitions + +The authoritative body of a permission set named `X` is the protocol's **layered +effective read** for `permission/X` (env-scope overlay wins over packaged declaration). +The `sys_permission_set` record for `X` is a projection of exactly that body, keyed by +`name`. Row `id`s are stable (junction tables `sys_user_permission_set` / +`sys_position_permission_set` reference them); the projector updates in place and never +recreates ids. + +**Assignments and bindings stay data-plane.** Which users/positions hold a set is +environment *state*, not part of the definition; those tables are unchanged. + +### D2 — The record is written only by the projector, awaited by the protocol + +`@objectstack/metadata-protocol` gains `registerMutationProjector(type, fn)`: an +awaited, best-effort per-type hook invoked after persistence inside `saveMetaItem` +(active saves), `publishMetaItem`, and `deleteMetaItem`, receiving +`{ type, name, state, organizationId, body? }`. A projector failure is surfaced on the +write's response (`projectionApplied: { success:false, error }`) and logged — never +thrown, the metadata write itself succeeded and boot reconciliation heals on next start. + +`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); +- 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 + display-vs-enforcement divergence described above; +- on a mutation whose layered read yields **no body at all** (a runtime-only definition + was deleted), retires the record (engine delete; trash semantics apply) and drops the + in-memory entry. + +The existing `onMetadataMutation` subscription remains only as a compatibility fallback +when the protocol predates `registerMutationProjector`. + +### D3 — Data-door writes are redirected into metadata (write-through) + +An engine middleware (registered by `plugin-security`, object-filtered to +`sys_permission_set`, running **inside** the security middleware so all existing +authorization — the ADR-0086 two-doors gate, the ADR-0090 D12 delegated-admin gate, +CRUD/FLS checks — applies first) translates every **non-system** write: + +| Data-door operation | Redirected to | +| :-- | :-- | +| `insert` (Setup "New" / clone) | `saveMetaItem('permission', name, body)` → projector creates the record | +| `update` (facet/label/active edits) | merge patch into the layered effective body → `saveMetaItem` → projector updates the record | +| `delete` of a **runtime-only** set | `deleteMetaItem` (hard delete) → projector retires the record (trash applies) | +| `delete` of an **artifact-backed** set | `deleteMetaItem` (overlay tombstone = reset, ADR-0005) → projector re-projects the **declared** body; the record resets instead of vanishing | +| `restore` (un-trash) | record restore proceeds, then the definition is re-authored into metadata from the restored row | + +The driver write for insert/update/delete never executes; `opCtx.result` is the +projected record. Renaming a set through the data door is rejected (the name is the +metadata identity; clone-then-delete is the supported flow). System-context writes +(`isSystem`) pass through untouched — they *are* the projector/seeder channel. + +Kernels without a metadata protocol capable of `saveMetaItem` /`getMetaItemLayered` +(minimal embeddings, unit-test stubs) fall back to the direct write: with a single +store there is no split brain to prevent. + +### D4 — Boot reconciliation and the migration/backfill path + +At `kernel:ready`, after the ADR-0086 D5 package seeding, `plugin-security` runs a +convergence pass: + +1. **Overlays → records.** Every active env-scope `permission` overlay is projected + (creating missing records). Metadata wins. +2. **Backfill (one-time migration).** An env-authored record (`managed_by` ≠ + `'package'`) whose name has **no metadata presence** (no declaration, no overlay) is + a legacy data-door creation — its body is written into the metadata store via + `saveMetaItem`. Enforcement is unchanged by construction: the evaluator's DB + fallback loader was already resolving exactly this body. After the backfill the + record is derived like every other. +3. **Drift healing.** An env-authored record whose name *has* metadata presence but + whose columns differ from the effective body is re-projected from metadata, with a + loud warning. Metadata wins deliberately: for such names the evaluator already + resolved the metadata body, so the record drift was **display-only and never + enforced** — promoting it into metadata would silently *change* effective + permissions at upgrade, which is worse than discarding a lie. + +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. + +## Consequences + +**Positive.** +- One truth. No write path — present or future — can desync the record from metadata + through the data plane: the choke point is the engine middleware every ObjectQL write + traverses, not an opt-in subscriber. +- Setup edits of declared sets finally **enforce** (they become env overlays), and + Studio edits/creations appear in Setup **before the save returns** (awaited + projection — acceptance criterion "no projection race"). +- Display and enforcement can no longer disagree: both derive from the layered + effective body (projection + in-memory registry sync). +- Legacy data is migrated, not stranded (D4 backfill). + +**Negative / behavior changes.** +- "Deleting" an artifact-backed set through Setup now **resets** it to the declared + body instead of deleting the row (the definition ships with the app and cannot be + deleted from the env — the honest semantic; previously the delete produced a ghost: + row gone, enforcement unchanged). +- Record drift authored through the data door **before** this change and shadowed by + metadata is discarded at first boot (loud warn). It was never enforced. +- Renames through the data door are rejected. +- Engine object hooks / realtime `data.record.*` events no longer fire for redirected + `sys_permission_set` writes from the data door (the projector's system writes fire + them instead). + +**Neutral / open.** +- Multi-node: the in-memory registry sync is per-node; cross-node convergence rides on + the existing metadata watch/boot mechanisms (pre-existing posture, unchanged). +- Whether the *record* store can eventually be dropped entirely (queries served from + metadata) stays open; junction FKs and Setup's list/query surface make the projection + the pragmatic shape today. + +## Alternatives considered + +- **Keep hardening the #2867 subscriber** (more events, more call sites). Rejected — + eventually-consistent glue between two writable stores can always be bypassed; the + issue explicitly asks for a structural fix. +- **Deny all data-door writes and move Setup to the metadata API.** Rejected for now — + breaks the Setup surface (sibling repo) and every existing integration; write-through + preserves the API while changing the store underneath. +- **Make the record authoritative and project into metadata.** Rejected — the metadata + layer is the platform-wide authoritative store for every other type (ADR-0005), is + versioned/auditable, and is what enforcement already prefers. + +## References + +- framework#2875 (this ADR), #2857 / #2867 (display-freshness gap and projection + band-aid), ADR-0005, ADR-0086 (D3/D4/D5/P2), ADR-0090 (D12), ADR-0056. +- 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). diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 9b17cc5a0a..f449252b53 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -2,6 +2,7 @@ export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata } from './protocol.js'; export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js'; +export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } 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 c0d66a5252..fdcdec9cd6 100644 --- a/packages/metadata-protocol/src/mutation-listeners.test.ts +++ b/packages/metadata-protocol/src/mutation-listeners.test.ts @@ -64,3 +64,55 @@ describe('ObjectStackProtocolImplementation.onMetadataMutation', () => { expect(after).toHaveBeenCalledTimes(1); }); }); + +// ADR-0094 — the AWAITED per-type projector seam. Unlike the listeners above +// (fire-and-forget), a registered projector runs inside the metadata write and +// its outcome is surfaced as `projectionApplied`. These tests pin the seam's +// contract (dispatch, plural normalization, replace-on-reregister, failure +// isolation); the save/publish/delete invocation points are exercised by the +// plugin-security projection suite against a mock protocol. +describe('ObjectStackProtocolImplementation.registerMutationProjector (ADR-0094)', () => { + it('runs the registered projector for its type and reports success', async () => { + const p = makeProtocol(); + const seen: any[] = []; + p.registerMutationProjector('permission', async (e) => { seen.push(e); }); + + const out = await (p as any).runMutationProjector(evt({ type: 'permission', body: { name: 'x' } })); + expect(out).toEqual({ success: true }); + expect(seen).toHaveLength(1); + expect(seen[0].type).toBe('permission'); + expect(seen[0].body).toEqual({ name: 'x' }); + }); + + it('returns undefined when no projector is registered for the type', async () => { + const p = makeProtocol(); + p.registerMutationProjector('permission', async () => {}); + expect(await (p as any).runMutationProjector(evt({ type: 'view' }))).toBeUndefined(); + }); + + it('normalizes plural type names on registration', async () => { + const p = makeProtocol(); + const projector = vi.fn(async () => {}); + p.registerMutationProjector('permissions', projector); + await (p as any).runMutationProjector(evt({ type: 'permission' })); + expect(projector).toHaveBeenCalledTimes(1); + }); + + it('a second registration replaces the first (idempotent re-init)', async () => { + const p = makeProtocol(); + const first = vi.fn(async () => {}); + const second = vi.fn(async () => {}); + p.registerMutationProjector('permission', first); + p.registerMutationProjector('permission', second); + await (p as any).runMutationProjector(evt({ type: 'permission' })); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('a throwing projector is surfaced as { success:false, error }, never thrown', async () => { + const p = makeProtocol(); + p.registerMutationProjector('permission', async () => { throw new Error('projection boom'); }); + const out = await (p as any).runMutationProjector(evt({ type: 'permission' })); + expect(out).toEqual({ success: false, error: 'projection boom' }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index cb7fd814e1..1043db6527 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -759,6 +759,39 @@ export interface MetadataMutationEvent { organizationId?: string | null; } +/** + * Awaited per-type mutation projector (ADR-0094). Invoked AFTER a metadata + * mutation persists — `saveMetaItem` (draft AND active saves), + * `publishMetaItem`, `deleteMetaItem` — and AWAITED before the write returns, + * so a data-plane read-model derived from the metadata (e.g. `permission` → + * `sys_permission_set`) is already consistent when the caller's next read + * lands. This is what makes such a read-model a PURE projection: the + * projector is its only writer, and it runs in the same awaited operation as + * every metadata write, instead of a fire-and-forget subscriber a new write + * path might race or forget. + * + * Complements (does not replace) {@link MetadataMutationEvent} listeners, + * which stay fire-and-forget for cache-invalidation consumers. + * + * Best-effort: a projector failure is surfaced on the write's response + * (`projectionApplied: { success:false, error }`) and logged, never thrown — + * the metadata write itself already succeeded, and boot reconciliation heals + * the projection on next start. + * + * `body` carries the just-persisted item when the mutation has one in hand + * (save/publish); projectors that need the EFFECTIVE (layered) body should + * re-read it — a delete, for instance, may reveal the artifact baseline. + */ +export type MetadataMutationProjector = ( + evt: MetadataMutationEvent & { body?: unknown }, +) => Promise; + +/** Per-write outcome of the awaited mutation projector (ADR-0094). */ +export interface MutationProjectionOutcome { + success: boolean; + error?: string; +} + export class ObjectStackProtocolImplementation implements ObjectStackProtocol { private engine: MetadataHostEngine; private getServicesRegistry?: () => Map; @@ -797,6 +830,16 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { /** [#2747] Named uninstall cleanups, run by {@link deletePackage}. */ private uninstallCleanups = new Map(); + /** + * Awaited per-type mutation projectors (ADR-0094), keyed by singular + * metadata type. Unlike {@link publishMaterializers} (publish-only, + * package door) a projector runs on EVERY persisted mutation of its type + * — save, publish, delete — so a derived data-plane read-model can be a + * pure projection with no unsynchronized door. One per type; a second + * registration replaces the first (idempotent re-init). + */ + private mutationProjectors = new Map(); + constructor( engine: IDataEngine, getServicesRegistry?: () => Map, @@ -833,6 +876,40 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { this.uninstallCleanups.set(name, cleanup); } + /** + * Register the awaited mutation projector for a metadata type (ADR-0094). + * Called by the domain plugin that owns the derived read-model (e.g. + * plugin-security registers the `permission` → `sys_permission_set` + * projector). Singular or plural type names both resolve. + */ + registerMutationProjector(type: string, projector: MetadataMutationProjector): void { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + this.mutationProjectors.set(singular, projector); + } + + /** + * Run the registered projector for a just-persisted mutation (ADR-0094). + * Returns `undefined` when no projector is registered for the type; + * otherwise a {@link MutationProjectionOutcome} that callers attach to + * the write's response as `projectionApplied`. Never throws. + */ + private async runMutationProjector( + evt: MetadataMutationEvent & { body?: unknown }, + ): Promise { + const projector = this.mutationProjectors.get(evt.type); + if (!projector) return undefined; + try { + await projector(evt); + return { success: true }; + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + console.warn( + `[Protocol] mutation projector failed for ${evt.type}/${evt.name} (state=${evt.state}): ${error}`, + ); + return { success: false, error }; + } + } + /** * Runtime-mutation listeners (#2588). Every metadata mutation that lands * through this protocol — `saveMetaItem` (draft AND direct-active saves), @@ -4020,6 +4097,16 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { source: 'protocol.saveMetaItem', note: mode === 'draft' ? 'draft' : 'active', }); + // [ADR-0094] Awaited projection BEFORE the fire-and-forget + // listeners: a derived read-model (e.g. sys_permission_set) + // is already consistent when this save returns. + const projectionApplied = await this.runMutationProjector({ + type: singularTypeForRepo, + name: request.name, + state: mode === 'draft' ? 'draft' : 'active', + organizationId: orgId, + body: request.item, + }); this.emitMetadataMutation({ type: singularTypeForRepo, name: request.name, @@ -4030,6 +4117,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { success: true, version: result.version, seq: result.seq, + ...(projectionApplied ? { projectionApplied } : {}), state: mode === 'draft' ? 'draft' : 'active', message: orgId ? `Saved customization overlay (org=${orgId}, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]` @@ -4282,6 +4370,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { message?: string; seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }; materializeApplied?: PublishMaterializeResult; + projectionApplied?: MutationProjectionOutcome; } = { success: true, version: result.version, @@ -4319,6 +4408,17 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { }; } } + // [ADR-0094] Awaited projection: runs AFTER the package-door + // materializer (which stamps package provenance) so the projector + // sees final record state; refuses/no-ops per its own rules. + const publishProjection = await this.runMutationProjector({ + type: singularType, + name: request.name, + state: 'active', + organizationId: orgId, + body: result.item.body, + }); + if (publishProjection) response.projectionApplied = publishProjection; this.emitMetadataMutation({ type: singularType, name: request.name, @@ -5514,6 +5614,8 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { message?: string; reset?: boolean; seq?: number; + /** [ADR-0094] Outcome of the awaited mutation projector, when one is registered. */ + projectionApplied?: MutationProjectionOutcome; }> { // Two-tier authorization for delete (mirrors saveMetaItem). // • Artifact-backed item → delete becomes a tombstone overlay, @@ -5643,6 +5745,15 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { note: targetState, }); + // [ADR-0094] Awaited projection: a delete may retire the + // derived record OR reset it to the artifact baseline — the + // projector re-reads the layered state and decides. + const deleteProjection = await this.runMutationProjector({ + type: singularTypeForRepo, + name: request.name, + state: 'deleted', + organizationId: orgId, + }); this.emitMetadataMutation({ type: singularTypeForRepo, name: request.name, @@ -5653,6 +5764,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { success: true, reset: true, seq: result.seq, + ...(deleteProjection ? { projectionApplied: deleteProjection } : {}), message: (request.state === 'draft') ? `Draft discarded — ${request.type}/${request.name}. [seq=${result.seq}]` : `Customization overlay deleted — ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]`, diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts index 909cbd89a9..eaad4472b4 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts @@ -4,9 +4,6 @@ import { describe, it, expect } from 'vitest'; import { bootstrapDeclaredPermissions, upsertPackagePermissionSet, - upsertEnvPermissionSet, - projectEnvPermissionOnMutation, - subscribeEnvPermissionProjection, } from './bootstrap-declared-permissions.js'; /** Minimal in-memory ql + registry for sys_permission_set seeding. */ @@ -175,133 +172,5 @@ describe('upsertPackagePermissionSet (ADR-0086 P2 — publish materialization)', }); }); -// framework#2857 — the environment door. An env-scope `save('permission', …)` -// writes only the sys_metadata overlay; upsertEnvPermissionSet projects the -// saved facets onto the queryable sys_permission_set record so the admin/Setup -// surface stops going stale. Mirror of upsertPackagePermissionSet (env rows -// only; refuses package-owned records). -describe('upsertEnvPermissionSet (framework#2857 — env-door projection)', () => { - const envBody = (over: Record = {}) => ({ - name: 'organization_admin', - label: 'Organization Administrator', - objects: { crm_lead: { allowRead: true, allowEdit: true } }, - fields: { 'crm_lead.amount': { readable: true, editable: false } }, - systemPermissions: ['setup.access', 'manage_org_users'], - rowLevelSecurity: [{ name: 'tenant', object: '*', operation: 'all', using: 'org == current_user.org', enabled: true }], - tabPermissions: { crm_leads: 'visible' }, - adminScope: { businessUnit: 'Sales', includeSubtree: true, assignablePermissionSets: ['member_default'] }, - ...over, - }); - - it('projects all six facets onto an existing env-authored row (update)', async () => { - const ql = makeQl(); - ql.rows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]' }); - const r = await upsertEnvPermissionSet(ql, envBody()); - expect(r.updated).toBe(1); - const row = ql.rows[0]; - expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true, allowEdit: true } }); - expect(JSON.parse(row.field_permissions)).toEqual({ 'crm_lead.amount': { readable: true, editable: false } }); - expect(JSON.parse(row.system_permissions)).toEqual(['setup.access', 'manage_org_users']); - expect(JSON.parse(row.row_level_security)[0].using).toBe('org == current_user.org'); - expect(JSON.parse(row.tab_permissions)).toEqual({ crm_leads: 'visible' }); - expect(JSON.parse(row.admin_scope).businessUnit).toBe('Sales'); - }); - - it('projects onto a legacy row with ABSENT provenance (platform default)', async () => { - const ql = makeQl(); - ql.rows.push({ id: 'ps_legacy', name: 'organization_admin', system_permissions: '[]' }); - const r = await upsertEnvPermissionSet(ql, envBody()); - expect(r.updated).toBe(1); - expect(JSON.parse(ql.rows[0].system_permissions)).toEqual(['setup.access', 'manage_org_users']); - }); - - it('does nothing when no data record exists (creation goes through the data API)', async () => { - const ql = makeQl(); - const r = await upsertEnvPermissionSet(ql, envBody()); - expect(r.seeded + r.updated).toBe(0); - expect(ql.rows.length).toBe(0); - }); - - it('refuses to touch a package-owned row (record stays the package baseline)', async () => { - const ql = makeQl(); - ql.rows.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(), { info: () => {}, warn: (m) => warns.push(m) }); - expect(r.skippedForeign).toBe(1); - expect(r.seeded + r.updated).toBe(0); - expect(ql.rows[0].system_permissions).toBe('["pkg"]'); - expect(warns.some((w) => w.includes('package-owned'))).toBe(true); - }); - - it('projects an env record even when the layered body carries _packageId provenance (record decides, not the body)', async () => { - // Regression for framework#2857: the layered read stamps `_packageId` on - // env-authored sets too, so the body must NOT gate projection — the record's - // managed_by does. - const ql = makeQl(); - ql.rows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]' }); - const r = await upsertEnvPermissionSet(ql, envBody({ _packageId: 'com.example.app', _provenance: 'x' })); - expect(r.updated).toBe(1); - expect(JSON.parse(ql.rows[0].system_permissions)).toEqual(['setup.access', 'manage_org_users']); - }); -}); - -// framework#2857 — the mutation-driven wiring (onMetadataMutation → layered -// re-read → project). Exercised without the dev server via a mock protocol. -describe('projectEnvPermissionOnMutation / subscribeEnvPermissionProjection', () => { - const body = () => ({ - name: 'organization_admin', - // the layered read stamps provenance on env sets too — must not gate. - _packageId: 'com.objectstack.showcase', - _provenance: { source: 'env' }, - objects: { crm_lead: { allowRead: true } }, - systemPermissions: ['setup.access', 'manage_metadata'], - }); - const evt = (over = {}) => ({ type: 'permission', state: 'active', name: 'organization_admin', ...over }); - const envRow = () => ({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]' }); - - it('re-reads the fresh body via getMetaItemLayered (ENVELOPE shape) and projects it', async () => { - const ql = makeQl(); ql.rows.push(envRow()); - const protocol = { getMetaItemLayered: async () => ({ effective: body(), code: null }) }; - const r = await projectEnvPermissionOnMutation(protocol, ql, evt()); - expect(r?.updated).toBe(1); - expect(JSON.parse(ql.rows[0].system_permissions)).toEqual(['setup.access', 'manage_metadata']); - }); - - it('accepts a DIRECT body from getMetaItemLayered (no effective/code envelope)', async () => { - // Regression: the layered read can return the effective body directly; a - // `?? null` fallback would drop it and silently skip the projection. - const ql = makeQl(); ql.rows.push(envRow()); - const protocol = { getMetaItemLayered: async () => body() }; - const r = await projectEnvPermissionOnMutation(protocol, ql, evt()); - expect(r?.updated).toBe(1); - expect(JSON.parse(ql.rows[0].system_permissions)).toEqual(['setup.access', 'manage_metadata']); - }); - - it('skips draft saves and non-permission events', async () => { - const ql = makeQl(); ql.rows.push(envRow()); - const protocol = { getMetaItemLayered: async () => body() }; - expect(await projectEnvPermissionOnMutation(protocol, ql, evt({ state: 'draft' }))).toBeNull(); - expect(await projectEnvPermissionOnMutation(protocol, ql, evt({ type: 'object' }))).toBeNull(); - expect(ql.rows[0].system_permissions).toBe('[]'); - }); - - it('subscribeEnvPermissionProjection wires a listener that projects on an active permission save', async () => { - const ql = makeQl(); ql.rows.push(envRow()); - let listener: any = null; - const protocol = { - onMetadataMutation: (fn: any) => { listener = fn; return () => {}; }, - getMetaItemLayered: async () => body(), - }; - const unsub = subscribeEnvPermissionProjection(protocol, ql); - expect(typeof unsub).toBe('function'); - expect(typeof listener).toBe('function'); - listener(evt()); - await new Promise((r) => setTimeout(r, 0)); // let the fire-and-forget settle - expect(JSON.parse(ql.rows[0].system_permissions)).toEqual(['setup.access', 'manage_metadata']); - }); - - it('returns null (no wiring) when the protocol lacks onMetadataMutation', () => { - expect(subscribeEnvPermissionProjection({}, makeQl())).toBeNull(); - expect(subscribeEnvPermissionProjection(null, makeQl())).toBeNull(); - }); -}); +// The environment door (env-scope saves, the data-door write-through, boot +// reconciliation) moved to permission-set-projection.test.ts (ADR-0094). diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts index 83eb1e5841..9a12d782fc 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts @@ -27,31 +27,28 @@ * Runs on `kernel:ready` after `bootstrapPlatformAdmin` (so the platform * defaults keep their existing insert-once shape) and alongside * `bootstrapDeclaredPositions`. + * + * The ENVIRONMENT door (env-scope metadata saves, the Setup data-door + * write-through, boot reconciliation) lives in `permission-set-projection.ts` + * (ADR-0094) — this module is the PACKAGE door only. Both project through the + * shared {@link permissionSetRowFields} row shape so they can never hydrate + * differently. */ -const SYSTEM_CTX = { isSystem: true }; - -function genId(prefix: string): string { - const rand = Math.random().toString(36).slice(2, 10); - const ts = Date.now().toString(36); - return `${prefix}_${ts}${rand}`; -} +import { + genId, + permissionSetRowFields, + tryFind, + tryInsert, + tryUpdate, + type PermissionSeedOutcome, + type ProjectionLogger, +} from './permission-set-projection.js'; -async function tryFind(ql: any, object: string, where: any, limit = 100): Promise { - try { - const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); - return Array.isArray(rows) ? rows : []; - } catch { return []; } -} -async function tryInsert(ql: any, object: string, data: any): Promise { - try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch { return null; } -} -async function tryUpdate(ql: any, object: string, data: any): Promise { - try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } catch { return false; } -} +export type { PermissionSeedOutcome } from './permission-set-projection.js'; interface SeedOptions { - logger?: { info: (m: string, meta?: Record) => void; warn: (m: string, meta?: Record) => void }; + logger?: ProjectionLogger; } /** @@ -71,30 +68,6 @@ export function readDeclared(engine: any, type: string): any[] { return []; } -/** Serialize a declared PermissionSet into the sys_permission_set row shape - * (mirrors bootstrapPlatformAdmin so both seed paths hydrate identically). */ -function toRowFields(ps: any): Record { - return { - label: ps.label ?? ps.name, - description: ps.description ?? null, - object_permissions: JSON.stringify(ps.objects ?? {}), - field_permissions: JSON.stringify(ps.fields ?? {}), - system_permissions: JSON.stringify(ps.systemPermissions ?? []), - row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), - tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), - // [ADR-0090 D12] Delegated-admin scope travels with the set row so the - // delegated-admin gate can resolve a DB-loaded delegate's authority. - admin_scope: ps.adminScope ? JSON.stringify(ps.adminScope) : null, - }; -} - -export interface PermissionSeedOutcome { - seeded: number; - updated: number; - skippedEnvAuthored: number; - skippedForeign: number; -} - /** * Upsert ONE declared/published PermissionSet body into `sys_permission_set` * under the owning `packageId`, applying the ADR-0086 provenance rules @@ -124,7 +97,7 @@ export async function upsertPackagePermissionSet( const created = await tryInsert(ql, 'sys_permission_set', { id: genId('ps'), name: ps.name, - ...toRowFields(ps), + ...permissionSetRowFields(ps), active: true, package_id: packageId, managed_by: 'package', @@ -137,7 +110,7 @@ export async function upsertPackagePermissionSet( if (existing.package_id === packageId) { // Our own row — re-seed so the record always reflects the shipped/published // declaration (idempotent; covers version bumps without bookkeeping). - if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...toRowFields(ps) })) { + if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...permissionSetRowFields(ps) })) { out.updated += 1; } } else { @@ -158,108 +131,6 @@ export async function upsertPackagePermissionSet( return out; } -/** - * Project an ENVIRONMENT-authored PermissionSet body onto its - * `sys_permission_set` row — the mirror image of {@link upsertPackagePermissionSet} - * for the environment door (ADR-0086 two-doors; framework#2857). - * - * An env-scope `save('permission', name, body)` writes only the `sys_metadata` - * overlay; nothing projected the six facet columns onto the queryable - * `sys_permission_set` record, so the admin/Setup surface (which reads the - * record) went stale while the layered read showed the edit — split-brain. - * This closes that gap for env-authored sets: it owns rows whose `managed_by` - * is NOT `'package'` (i.e. `platform`/`user`/absent) and REFUSES to touch a - * package-owned row — a package's record mirrors its declaration and changes - * only via boot re-seed / publish, never through an env override. - */ -export async function upsertEnvPermissionSet( - ql: any, - ps: any, - logger?: SeedOptions['logger'], -): Promise { - const out: PermissionSeedOutcome = { seeded: 0, updated: 0, skippedEnvAuthored: 0, skippedForeign: 0 }; - if (!ql || typeof ql.find !== 'function' || !ps?.name) return out; - - // Ownership is decided by the EXISTING RECORD's `managed_by`, never the body: - // the layered read stamps `_packageId` provenance on env-authored sets too - // (a declared-then-env-overridden set), so the body cannot tell the two doors - // apart — only the record's provenance can. - const existing = (await tryFind(ql, 'sys_permission_set', { name: ps.name }, 1))[0]; - if (!existing?.id) { - // No data record. A set's admin-surface row is created through the data API - // (the Setup "New" flow), not the metadata door, so there is nothing to - // project here — leave creation to that path / the boot seeder. - 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; - } - - // Env-authored row (platform / user / absent provenance): project the saved - // facets so the record matches the layered read the editor shows. - if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...toRowFields(ps) })) { - out.updated += 1; - } - return out; -} - -/** - * Handle one `permission` metadata-mutation event (framework#2857): re-read the - * FRESH effective body via the protocol's layered read — the boot-time metadata - * registry would hand back a stale declared body — and project it onto the env - * record. Exported (and Promise-returning) so the wiring is unit-testable - * without the dev server. Returns the projection outcome, or `null` when the - * event is skipped (draft, non-permission, or no readable body). - */ -export async function projectEnvPermissionOnMutation( - protocol: any, - ql: any, - evt: { type?: string; name?: string; state?: string; organizationId?: string | null } | null | undefined, - logger?: SeedOptions['logger'], -): Promise { - if (evt?.type !== 'permission' || evt.state === 'draft' || !evt.name) return null; - let body: any = null; - if (protocol && typeof protocol.getMetaItemLayered === 'function') { - const layered = await protocol.getMetaItemLayered({ - type: 'permission', - name: evt.name, - ...(evt.organizationId ? { environmentId: evt.organizationId } : {}), - }); - // `getMetaItemLayered` may return a layered envelope (`{ effective | code }`) - // OR the effective body directly (top-level `name`/`systemPermissions`) — - // accept both so a body isn't silently dropped. - body = layered?.effective ?? layered?.code ?? layered ?? null; - } - if (!body?.name) return null; - return upsertEnvPermissionSet(ql, body, logger); -} - -/** - * Subscribe env-permission projection to the protocol's post-persistence - * mutation choke point (framework#2857). Returns the unsubscribe fn, or `null` - * when the protocol doesn't expose `onMetadataMutation`. - */ -export function subscribeEnvPermissionProjection( - protocol: any, - ql: any, - logger?: SeedOptions['logger'], -): (() => void) | null { - if (!protocol || typeof protocol.onMetadataMutation !== 'function') return null; - return protocol.onMetadataMutation((evt: any) => { - void projectEnvPermissionOnMutation(protocol, ql, evt, logger).catch((err: any) => { - logger?.warn?.('[security] env permission projection after save failed', { - name: evt?.name, error: err?.message, - }); - }); - }); -} - export async function bootstrapDeclaredPermissions( ql: any, metadataService: any, diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 613079419e..0e4346c392 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -26,6 +26,22 @@ export { } from './auto-org-admin-grant.js'; export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; +// [ADR-0094] sys_permission_set pure-projection machinery. +export { + permissionSetRowFields, + permissionSetBodyFromRow, + upsertEnvPermissionSet, + projectPermissionMutation, + registerPermissionSetProjection, + createPermissionSetWriteThrough, + reconcilePermissionSetProjection, +} from './permission-set-projection.js'; +export type { + PermissionSeedOutcome, + ProjectionDeps, + ProjectionReconcileOutcome, + WriteThroughDeps, +} from './permission-set-projection.js'; export { cleanupPackagePermissions } from './cleanup-package-permissions.js'; export type { PackagePermissionCleanupOutcome } from './cleanup-package-permissions.js'; export { claimSeedOwnership } from './claim-seed-ownership.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 new file mode 100644 index 0000000000..1a57a63c8f --- /dev/null +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -0,0 +1,529 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0094 — sys_permission_set as a pure projection of the metadata layer. + * Covers the env-door projector (create/update/reset/retire + evaluator + * registry sync), the data-door write-through middleware, and the boot + * reconciliation/backfill pass. The package door stays covered in + * bootstrap-declared-permissions.test.ts. + */ + +import { describe, it, expect } from 'vitest'; +import { + permissionSetRowFields, + permissionSetBodyFromRow, + mergeRowPatchIntoBody, + recordDiffersFromBody, + upsertEnvPermissionSet, + projectPermissionMutation, + registerPermissionSetProjection, + createPermissionSetWriteThrough, + reconcilePermissionSetProjection, +} from './permission-set-projection.js'; + +/** In-memory ql over sys_permission_set + sys_metadata. */ +function makeQl() { + const permRows: any[] = []; + const metaRows: any[] = []; + const tableFor = (object: string): any[] | null => + object === 'sys_permission_set' ? permRows : object === 'sys_metadata' ? metaRows : null; + const matches = (r: any, where: any) => + Object.entries(where ?? {}).every(([k, v]) => (v === null ? (r[k] ?? null) === null : r[k] === v)); + return { + permRows, + metaRows, + async find(object: string, q: any) { + const rows = tableFor(object); + return rows ? rows.filter((r) => matches(r, q?.where)) : []; + }, + async findOne(object: string, q: any) { + const rows = tableFor(object); + return rows?.find((r) => matches(r, q?.where)) ?? null; + }, + async insert(object: string, data: any) { + const rows = tableFor(object); + if (!rows) return null; + rows.push({ ...data }); + return { id: data.id }; + }, + async update(object: string, data: any) { + const rows = tableFor(object); + const r = rows?.find((x) => x.id === data.id); + if (r) Object.assign(r, data); + }, + async delete(object: string, opts: any) { + const rows = tableFor(object); + if (!rows) return false; + const id = opts?.where?.id; + const i = rows.findIndex((x) => x.id === id); + if (i >= 0) rows.splice(i, 1); + return i >= 0; + }, + }; +} + +/** + * Mock metadata protocol over the ql's sys_metadata table: env-scope active + * overlays, layered read (overlay-wins over `declared`), and the ADR-0094 + * awaited mutation-projector seam. + */ +function makeProtocol(ql: any, declared: Record = {}) { + let projector: ((evt: any) => Promise) | null = null; + const overlayFor = (name: string) => + ql.metaRows.find( + (r: any) => + r.type === 'permission' && r.name === name && r.state === 'active' && (r.organization_id ?? null) === null, + ); + const protocol = { + saves: [] as any[], + deletes: [] as any[], + registerMutationProjector(_type: string, fn: (evt: any) => Promise) { + projector = fn; + }, + async saveMetaItem(req: { type: string; name: string; item: any; actor?: string }) { + const existing = overlayFor(req.name); + if (existing) existing.metadata = JSON.stringify(req.item); + else { + ql.metaRows.push({ + id: `meta_${req.name}`, type: 'permission', name: req.name, state: 'active', + organization_id: null, metadata: JSON.stringify(req.item), + }); + } + protocol.saves.push({ ...req }); + if (projector) await projector({ type: 'permission', name: req.name, state: 'active', organizationId: null, body: req.item }); + return { success: true }; + }, + async deleteMetaItem(req: { type: string; name: string; actor?: string }) { + const i = ql.metaRows.findIndex( + (r: any) => r.type === 'permission' && r.name === req.name && (r.organization_id ?? null) === null, + ); + if (i >= 0) ql.metaRows.splice(i, 1); + protocol.deletes.push({ ...req }); + if (projector) await projector({ type: 'permission', name: req.name, state: 'deleted', organizationId: null }); + return { success: true, reset: true }; + }, + async getMetaItemLayered(req: { type: string; name: string }) { + const code = declared[req.name] ?? null; + const o = overlayFor(req.name); + const overlay = o ? JSON.parse(o.metadata) : null; + return { + type: 'permission', name: req.name, code, overlay, + overlayScope: overlay ? 'env' : null, effective: overlay ?? code, + }; + }, + }; + return protocol; +} + +/** Metadata-manager facade stub for the evaluator-registry sync. */ +function makeMetadataFacade() { + const registry = new Map(); + return { + registry, + registerInMemory(type: string, name: string, body: any) { + registry.set(`${type}/${name}`, body); + }, + async get(type: string, name: string) { + return registry.get(`${type}/${name}`); + }, + unregister(type: string, name: string) { + registry.delete(`${type}/${name}`); + }, + }; +} + +const envBody = (over: Record = {}) => ({ + name: 'organization_admin', + label: 'Organization Administrator', + objects: { crm_lead: { allowRead: true, allowEdit: true } }, + fields: { 'crm_lead.amount': { readable: true, editable: false } }, + systemPermissions: ['setup.access', 'manage_org_users'], + rowLevelSecurity: [{ name: 'tenant', object: '*', operation: 'all', using: 'org == current_user.org', enabled: true }], + tabPermissions: { crm_leads: 'visible' }, + adminScope: { businessUnit: 'Sales', includeSubtree: true, assignablePermissionSets: ['member_default'] }, + ...over, +}); + +describe('permissionSetBodyFromRow / permissionSetRowFields (round-trip)', () => { + it('rebuilds the body a row was projected from', () => { + const fields = permissionSetRowFields(envBody()); + const row = { id: 'ps_1', name: 'organization_admin', active: true, ...fields }; + const body = permissionSetBodyFromRow(row); + expect(body.name).toBe('organization_admin'); + expect(body.label).toBe('Organization Administrator'); + expect(body.objects).toEqual(envBody().objects); + expect(body.fields).toEqual(envBody().fields); + expect(body.systemPermissions).toEqual(envBody().systemPermissions); + expect(body.rowLevelSecurity[0].using).toBe('org == current_user.org'); + expect(body.tabPermissions).toEqual({ crm_leads: 'visible' }); + expect(body.adminScope.businessUnit).toBe('Sales'); + expect(body.active).toBe(true); + // and projecting the rebuilt body changes nothing + expect(recordDiffersFromBody(row, body)).toBe(false); + }); +}); + +describe('upsertEnvPermissionSet (ADR-0094 — record is a pure projection)', () => { + it('CREATES a missing record (managed_by user) — Studio-authored sets appear in Setup', async () => { + const ql = makeQl(); + const r = await upsertEnvPermissionSet(ql, envBody()); + expect(r.seeded).toBe(1); + const row = ql.permRows[0]; + expect(row.name).toBe('organization_admin'); + expect(row.managed_by).toBe('user'); + expect(row.active).toBe(true); + expect(JSON.parse(row.object_permissions)).toEqual(envBody().objects); + }); + + it('projects all facets (and active) onto an existing env-authored row', async () => { + const ql = makeQl(); + ql.permRows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]', active: true }); + const r = await upsertEnvPermissionSet(ql, envBody({ active: false })); + expect(r.updated).toBe(1); + const row = ql.permRows[0]; + expect(row.id).toBe('ps_env'); // id stable — junction FKs stay valid + expect(JSON.parse(row.system_permissions)).toEqual(['setup.access', 'manage_org_users']); + expect(JSON.parse(row.admin_scope).businessUnit).toBe('Sales'); + expect(row.active).toBe(false); + }); + + it('projects onto a legacy row with ABSENT provenance (platform default)', async () => { + const ql = makeQl(); + ql.permRows.push({ id: 'ps_legacy', name: 'organization_admin', system_permissions: '[]' }); + const r = await upsertEnvPermissionSet(ql, envBody()); + expect(r.updated).toBe(1); + }); + + it('refuses to touch a package-owned row (record stays the package baseline)', async () => { + 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); + }); +}); + +describe('projectPermissionMutation (the awaited projector)', () => { + it('re-reads the fresh layered body and projects it (record + evaluator registry)', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); + ql.metaRows.push({ id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', organization_id: null, metadata: JSON.stringify(envBody()) }); + const metadata = makeMetadataFacade(); + const r = await projectPermissionMutation(protocol, { ql, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' }); + expect(r?.seeded).toBe(1); + expect(ql.permRows[0].managed_by).toBe('user'); + // evaluator's registry-first list('permission') now resolves the same body, + // marked as a projection echo so it can never masquerade as an artifact + const entry = metadata.registry.get('permission/organization_admin'); + expect(entry?.systemPermissions).toEqual(['setup.access', 'manage_org_users']); + expect(entry?._envProjection).toBe(true); + }); + + it('overlay delete on a DECLARED set heals the registry echo back to the declared body', async () => { + const ql = makeQl(); + const declaredBody = envBody({ systemPermissions: ['declared.only'] }); + const declared = { organization_admin: declaredBody }; + // engine SchemaRegistry — the artifact source the projection never writes + (ql as any)._registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; + const protocol = makeProtocol(ql, declared); + const metadata = makeMetadataFacade(); + const deps = { ql, metadata }; + // env overlay shadows the declaration → registry synced with marked overlay body + ql.metaRows.push({ id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', organization_id: null, metadata: JSON.stringify(envBody({ systemPermissions: ['overlaid'] })) }); + await projectPermissionMutation(protocol, deps, { type: 'permission', name: 'organization_admin', state: 'active' }); + expect(metadata.registry.get('permission/organization_admin')?.systemPermissions).toEqual(['overlaid']); + // overlay deleted → record resets to declared AND the echo heals to declared + ql.metaRows.length = 0; + await projectPermissionMutation(protocol, deps, { type: 'permission', name: 'organization_admin', state: 'deleted' }); + expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['declared.only']); + const healed = metadata.registry.get('permission/organization_admin'); + expect(healed?.systemPermissions).toEqual(['declared.only']); + expect(healed?._envProjection).toBeUndefined(); + }); + + it('skips draft saves and non-permission events', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); + expect(await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'x', state: 'draft' })).toBeNull(); + expect(await projectPermissionMutation(protocol, { ql }, { type: 'object', name: 'x', state: 'active' })).toBeNull(); + }); + + it('a delete that leaves NO body retires the record and drops the registry entry', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); // no declared artifact, no overlay + const metadata = makeMetadataFacade(); + metadata.registry.set('permission/organization_admin', envBody()); + ql.permRows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user' }); + const r = await projectPermissionMutation(protocol, { ql, metadata }, { type: 'permission', name: 'organization_admin', state: 'deleted' }); + expect(r?.deleted).toBe(1); + expect(ql.permRows.length).toBe(0); + expect(metadata.registry.has('permission/organization_admin')).toBe(false); + }); + + it('a delete that reveals the artifact baseline RESETS the record instead (ADR-0005 reset)', async () => { + const ql = makeQl(); + const declared = { organization_admin: envBody({ systemPermissions: ['declared.only'] }) }; + const protocol = makeProtocol(ql, declared); + ql.permRows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '["overlaid"]' }); + const r = await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'deleted' }); + expect(r?.updated).toBe(1); + expect(ql.permRows.length).toBe(1); + expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['declared.only']); + }); +}); + +describe('registerPermissionSetProjection', () => { + it('prefers the awaited registerMutationProjector seam — a save projects before it returns', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); + expect(registerPermissionSetProjection(protocol, { ql })).toBe(true); + await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody() }); + // no timers, no event loop yield — the record is already there + expect(ql.permRows.length).toBe(1); + expect(ql.permRows[0].name).toBe('organization_admin'); + }); + + it('falls back to onMetadataMutation on older protocols, and returns false with neither', async () => { + const ql = makeQl(); + let listener: any = null; + const older = { + onMetadataMutation: (fn: any) => { listener = fn; return () => {}; }, + getMetaItemLayered: async () => ({ effective: envBody(), code: null }), + }; + expect(registerPermissionSetProjection(older, { ql })).toBe(true); + listener({ type: 'permission', name: 'organization_admin', state: 'active' }); + await new Promise((r) => setTimeout(r, 0)); + expect(ql.permRows.length).toBe(1); + expect(registerPermissionSetProjection({}, { ql })).toBe(false); + expect(registerPermissionSetProjection(null, { ql })).toBe(false); + }); +}); + +// ── Data-door write-through (ADR-0094 D3) ─────────────────────────────────── + +function makeMiddleware(ql: any, protocol: any, metadata?: any) { + return createPermissionSetWriteThrough({ ql, metadata, getProtocol: () => protocol }); +} + +async function run(mw: any, opCtx: any): Promise { + let nextCalled = false; + await mw(opCtx, async () => { nextCalled = true; }); + return nextCalled; +} + +describe('createPermissionSetWriteThrough (data door → metadata store)', () => { + const userCtx = { userId: 'usr_admin' }; + + it('passes system-context writes through (the projector/seeder channel)', async () => { + const ql = makeQl(); + const mw = makeMiddleware(ql, makeProtocol(ql)); + const nextCalled = await run(mw, { object: 'sys_permission_set', operation: 'insert', data: { name: 'x' }, context: { isSystem: true } }); + expect(nextCalled).toBe(true); + }); + + it('passes through when the protocol is missing/incapable (single store — no split brain)', async () => { + const ql = makeQl(); + const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => null }); + const nextCalled = await run(mw, { object: 'sys_permission_set', operation: 'insert', data: { name: 'x' }, context: userCtx }); + expect(nextCalled).toBe(true); + }); + + it('INSERT authors the definition into metadata; the record is projector-created (no driver write)', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); + registerPermissionSetProjection(protocol, { ql }); + const mw = makeMiddleware(ql, protocol); + const opCtx: any = { + object: 'sys_permission_set', operation: 'insert', context: userCtx, + data: { + name: 'support_agent', label: 'Support Agent', + object_permissions: JSON.stringify({ ticket: { allowRead: true } }), + system_permissions: '["support.use"]', + }, + }; + const nextCalled = await run(mw, opCtx); + expect(nextCalled).toBe(false); // driver write skipped + expect(protocol.saves.length).toBe(1); + expect(protocol.saves[0].actor).toBe('usr_admin'); + expect(protocol.saves[0].item.objects).toEqual({ ticket: { allowRead: true } }); + expect(ql.permRows.length).toBe(1); + expect(ql.permRows[0].managed_by).toBe('user'); + expect(opCtx.result?.name).toBe('support_agent'); + }); + + it('INSERT of a duplicate name is rejected with 409', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); + ql.permRows.push({ id: 'ps_1', name: 'support_agent', managed_by: 'user' }); + const mw = makeMiddleware(ql, protocol); + await expect( + run(mw, { object: 'sys_permission_set', operation: 'insert', data: { name: 'support_agent' }, context: userCtx }), + ).rejects.toMatchObject({ status: 409 }); + }); + + it('UPDATE merges the column patch into the layered effective body and saves metadata', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); + registerPermissionSetProjection(protocol, { ql }); + // existing env set: record + overlay + await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody() }); + const rowId = ql.permRows[0].id; + const mw = makeMiddleware(ql, protocol); + const opCtx: any = { + object: 'sys_permission_set', operation: 'update', context: userCtx, + data: { id: rowId, system_permissions: '["setup.access"]', active: false }, + }; + const nextCalled = await run(mw, opCtx); + expect(nextCalled).toBe(false); + // metadata is the store that changed… + const overlay = JSON.parse(ql.metaRows[0].metadata); + expect(overlay.systemPermissions).toEqual(['setup.access']); + expect(overlay.active).toBe(false); + expect(overlay.objects).toEqual(envBody().objects); // unmentioned facets preserved + // …and the record followed via projection + expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['setup.access']); + expect(ql.permRows[0].active).toBe(false); + expect(ql.permRows[0].id).toBe(rowId); + expect(opCtx.result?.id).toBe(rowId); + }); + + it('UPDATE that renames is rejected (the name is the metadata identity)', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); + ql.permRows.push({ id: 'ps_1', name: 'organization_admin', managed_by: 'user' }); + const mw = makeMiddleware(ql, protocol); + await expect( + run(mw, { object: 'sys_permission_set', operation: 'update', data: { id: 'ps_1', name: 'renamed' }, context: userCtx }), + ).rejects.toMatchObject({ status: 400 }); + }); + + it('DELETE of a runtime-only set hard-deletes the definition and retires the record', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); + registerPermissionSetProjection(protocol, { ql }); + await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody() }); + const rowId = ql.permRows[0].id; + const mw = makeMiddleware(ql, protocol); + const nextCalled = await run(mw, { + object: 'sys_permission_set', operation: 'delete', options: { where: { id: rowId } }, context: userCtx, + }); + expect(nextCalled).toBe(false); + expect(protocol.deletes.length).toBe(1); + expect(ql.metaRows.length).toBe(0); + expect(ql.permRows.length).toBe(0); + }); + + it('DELETE of an artifact-backed set resets the record to the declared body instead of removing it', async () => { + const ql = makeQl(); + const declared = { organization_admin: envBody({ systemPermissions: ['declared.only'] }) }; + const protocol = makeProtocol(ql, declared); + registerPermissionSetProjection(protocol, { ql }); + // env overlay shadows the declaration; record projected from the overlay + await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['overlaid'] }) }); + expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['overlaid']); + const mw = makeMiddleware(ql, protocol); + const nextCalled = await run(mw, { + object: 'sys_permission_set', operation: 'delete', options: { where: { id: ql.permRows[0].id } }, context: userCtx, + }); + expect(nextCalled).toBe(false); + expect(ql.permRows.length).toBe(1); // record survives… + expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['declared.only']); // …reset to the declaration + }); + + it('leaves non-sys_permission_set objects and unrelated operations alone', async () => { + const ql = makeQl(); + const mw = makeMiddleware(ql, makeProtocol(ql)); + expect(await run(mw, { object: 'sys_user', operation: 'insert', data: {}, context: { userId: 'u' } })).toBe(true); + expect(await run(mw, { object: 'sys_permission_set', operation: 'find', context: { userId: 'u' } })).toBe(true); + }); +}); + +// ── Boot reconciliation + one-time backfill (ADR-0094 D4) ─────────────────── + +describe('reconcilePermissionSetProjection', () => { + it('projects env overlays onto records, creating missing ones', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); + ql.metaRows.push({ id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', organization_id: null, metadata: JSON.stringify(envBody()) }); + const out = await reconcilePermissionSetProjection(protocol, { ql }); + expect(out.projectedFromMetadata).toBe(1); + expect(ql.permRows[0]?.name).toBe('organization_admin'); + expect(ql.permRows[0]?.managed_by).toBe('user'); + }); + + it('backfills a legacy data-door-only record into the metadata store ONCE', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); + ql.permRows.push({ + id: 'ps_legacy', name: 'support_agent', managed_by: 'user', active: true, + label: 'Support Agent', object_permissions: JSON.stringify({ ticket: { allowRead: true } }), + system_permissions: '["support.use"]', + }); + const out = await reconcilePermissionSetProjection(protocol, { ql }); + expect(out.backfilledIntoMetadata).toBe(1); + expect(ql.metaRows.length).toBe(1); + const body = JSON.parse(ql.metaRows[0].metadata); + expect(body.objects).toEqual({ ticket: { allowRead: true } }); + // second run: the overlay now exists — nothing to backfill again + const out2 = await reconcilePermissionSetProjection(protocol, { ql }); + expect(out2.backfilledIntoMetadata).toBe(0); + }); + + it('heals a record that drifted from an EXISTING metadata definition (metadata wins)', async () => { + const ql = makeQl(); + const declared = { member_default: envBody({ name: 'member_default', systemPermissions: ['declared.baseline'] }) }; + const protocol = makeProtocol(ql, declared); + // record drifted via a historic data-door edit that was never enforced + ql.permRows.push({ + id: 'ps_md', name: 'member_default', label: 'Organization Administrator', + object_permissions: JSON.stringify(envBody().objects), + field_permissions: JSON.stringify(envBody().fields), + system_permissions: '["drifted.edit"]', + row_level_security: JSON.stringify(envBody().rowLevelSecurity), + tab_permissions: JSON.stringify(envBody().tabPermissions), + admin_scope: JSON.stringify(envBody().adminScope), + active: true, + }); + const warns: string[] = []; + const out = await reconcilePermissionSetProjection(protocol, { ql, logger: { warn: (m) => warns.push(m), info: () => {} } }); + expect(out.driftHealed).toBe(1); + expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['declared.baseline']); + expect(warns.some((w) => w.includes('drifted'))).toBe(true); + expect(out.backfilledIntoMetadata).toBe(0); // drift is never promoted into metadata + }); + + it('never touches package-owned records', async () => { + const ql = makeQl(); + const protocol = makeProtocol(ql); + ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg"]' }); + const out = await reconcilePermissionSetProjection(protocol, { ql }); + expect(out.backfilledIntoMetadata).toBe(0); + expect(out.driftHealed).toBe(0); + expect(ql.metaRows.length).toBe(0); + expect(ql.permRows[0].system_permissions).toBe('["pkg"]'); + }); +}); + +describe('mergeRowPatchIntoBody', () => { + it('maps snake_case column patches onto body keys, preserving unmentioned facets', () => { + const merged = mergeRowPatchIntoBody(envBody(), { label: 'Renamed Label', tab_permissions: '{"crm_leads":"hidden"}' }); + expect(merged.label).toBe('Renamed Label'); + expect(merged.tabPermissions).toEqual({ crm_leads: 'hidden' }); + expect(merged.objects).toEqual(envBody().objects); + expect(merged.adminScope).toEqual(envBody().adminScope); + }); + + it('accepts object-typed facet values and clears adminScope on null', () => { + const merged = mergeRowPatchIntoBody(envBody(), { object_permissions: { ticket: { allowRead: true } }, admin_scope: null }); + expect(merged.objects).toEqual({ ticket: { allowRead: true } }); + expect('adminScope' in merged).toBe(false); + }); + + it('strips layered-read decorations from the base body', () => { + const merged = mergeRowPatchIntoBody({ ...envBody(), _packageId: 'com.x', _provenance: { a: 1 } }, {}); + expect('_packageId' in merged).toBe(false); + expect('_provenance' in merged).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts new file mode 100644 index 0000000000..fe369616ea --- /dev/null +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -0,0 +1,747 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_permission_set` as a PURE PROJECTION of the metadata layer (ADR-0094; + * framework#2875 — retires the two-store split-brain behind #2857/#2867). + * + * The metadata layer (packaged declarations + the `sys_metadata` overlay, + * merged overlay-wins by the protocol's layered read) is the ONLY + * authoritative store for a permission-set DEFINITION. The queryable + * `sys_permission_set` record is a derived read-model: + * + * - every non-system data-door write (the Setup UI's generic CRUD, bulk + * imports, any future API routed through ObjectQL) is REDIRECTED into a + * metadata write at the engine-middleware choke point + * ({@link createPermissionSetWriteThrough}) — the driver write never + * executes, so no data-plane path can produce a record the metadata + * doesn't back; + * - the record (and the metadata manager's in-memory `permission` entry, + * which the evaluator's registry-first `list('permission')` resolution + * reads) is written ONLY by the projector + * ({@link projectPermissionMutation}), which the protocol AWAITS on every + * save / publish / delete (`registerMutationProjector`) — no projection + * race; + * - boot reconciliation ({@link reconcilePermissionSetProjection}) heals + * 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. + */ + +export const SYSTEM_CTX = { isSystem: true }; + +export function genId(prefix: string): string { + const rand = Math.random().toString(36).slice(2, 10); + const ts = Date.now().toString(36); + return `${prefix}_${ts}${rand}`; +} + +export async function tryFind(ql: any, object: string, where: any, limit = 100): Promise { + try { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : []; + } catch { return []; } +} +export async function tryInsert(ql: any, object: string, data: any): Promise { + try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch { return null; } +} +export async function tryUpdate(ql: any, object: string, data: any): Promise { + try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } catch { return false; } +} + +export interface ProjectionLogger { + info?: (m: string, meta?: Record) => void; + warn?: (m: string, meta?: Record) => void; +} + +/** Aggregated outcome of a projection pass (shared with the boot seeders). */ +export interface PermissionSeedOutcome { + seeded: number; + updated: number; + skippedEnvAuthored: number; + skippedForeign: number; + /** Records retired because their definition was deleted from metadata. */ + deleted?: number; +} + +/** + * Serialize a PermissionSet body into the `sys_permission_set` facet/identity + * columns. THE one row shape both doors project through — package boot-seed / + * publish materialization and the env projector — so the two can never + * hydrate differently. + */ +export function permissionSetRowFields(ps: any): Record { + return { + label: ps.label ?? ps.name, + description: ps.description ?? null, + object_permissions: JSON.stringify(ps.objects ?? {}), + field_permissions: JSON.stringify(ps.fields ?? {}), + system_permissions: JSON.stringify(ps.systemPermissions ?? []), + row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), + tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), + // [ADR-0090 D12] Delegated-admin scope travels with the set row so the + // delegated-admin gate can resolve a DB-loaded delegate's authority. + admin_scope: ps.adminScope ? JSON.stringify(ps.adminScope) : null, + }; +} + +const parseMaybeJson = (v: any, fallback: any): any => { + if (typeof v !== 'string') return v ?? fallback; + try { + const parsed = JSON.parse(v === '' ? 'null' : v); + return parsed ?? fallback; + } catch { return fallback; } +}; + +const asBool = (v: any): boolean => !(v === false || v === 0 || v === '0' || v === 'false'); + +/** + * Inverse of {@link permissionSetRowFields}: rebuild a PermissionSet body from + * a `sys_permission_set` row (snake_case JSON-string columns → camelCase + * body). Used by the one-time boot backfill (a legacy data-door-created + * record becomes a metadata item) and by the data-door update merge when a + * name has no metadata presence yet. + */ +export function permissionSetBodyFromRow(row: any): any { + const adminScope = row?.admin_scope ? parseMaybeJson(row.admin_scope, undefined) : undefined; + return { + name: row?.name, + label: row?.label ?? row?.name, + ...(row?.description != null ? { description: row.description } : {}), + objects: parseMaybeJson(row?.object_permissions, {}), + fields: parseMaybeJson(row?.field_permissions, {}), + systemPermissions: parseMaybeJson(row?.system_permissions, []), + rowLevelSecurity: parseMaybeJson(row?.row_level_security, []), + tabPermissions: parseMaybeJson(row?.tab_permissions, {}), + ...(adminScope ? { adminScope } : {}), + ...(row?.active != null ? { active: asBool(row.active) } : {}), + }; +} + +/** + * Marker stamped on bodies this module writes into the metadata manager's + * in-memory registry ({@link syncEvaluatorRegistry}). The manager's `get`/ + * `list` are registry-first, so without a marker our own synced copy would be + * indistinguishable from a real packaged artifact — and after the overlay is + * deleted, the layered read's `code` layer would keep echoing it, turning a + * retire into a bogus "reset" (the definition would be undeletable). + */ +const ENV_PROJECTION_MARKER = '_envProjection'; + +/** Strip layered-read / registry decorations so a re-authored body is clean. */ +function stripDecorations(body: any): any { + if (!body || typeof body !== 'object') return body; + const { _packageId, _provenance, _diagnostics, _lock, _lockReason, _lockSource, [ENV_PROJECTION_MARKER]: _mark, ...clean } = body; + return clean; +} + +/** True when a layered-read layer is just our own registry echo, not a real artifact. */ +const isProjectionEcho = (v: any): boolean => + !!(v && typeof v === 'object' && (v as any)[ENV_PROJECTION_MARKER]); + +/** + * Read the DECLARED (artifact) body for a permission set from the engine's + * SchemaRegistry — the same source `bootstrapDeclaredPermissions` seeds from, + * and the one store the env projection never writes, so it can't be poisoned + * by our own registry sync. Used as the reset target when an env overlay is + * deleted off a declared set. + * + * Items tagged `_packageId: 'sys_metadata'` are RUNTIME SHADOWS — hydrated + * into the registry from overlay rows (loadMetaFromDb / getMetaItems), not + * shipped artifacts — and are skipped: after a runtime-only definition is + * deleted, its shadow may linger (`removeRuntimeShadow` only drops shadows + * that cover a packaged artifact), and treating it as declared would make the + * definition undeletable. + */ +function readDeclaredBody(ql: any, name: string): any { + try { + const items = ql?._registry?.listItems?.('permission') ?? []; + for (const i of items) { + const body = i?.content ?? i; + // Skip projection echoes too: deleteMetaItem's registry heal + // (restoreArtifactRegistryView) can re-register the metadata manager's + // view — which may be OUR marked copy — into the engine registry as a + // plain item; without this skip a deleted runtime-only definition + // would zombie back as "declared" and become undeletable. + if (body?.name === name && body?._packageId !== 'sys_metadata' && !isProjectionEcho(body)) { + return body; + } + } + } catch { /* fall through */ } + return null; +} + +/** Whether the engine exposes a SchemaRegistry we can read declared bodies from. */ +function hasSchemaRegistry(ql: any): boolean { + return typeof ql?._registry?.listItems === 'function'; +} + +/** + * Project an ENVIRONMENT-authored PermissionSet body onto its + * `sys_permission_set` row — the env-door counterpart of + * `upsertPackagePermissionSet` (ADR-0086 two-doors). + * + * [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. + */ +export async function upsertEnvPermissionSet( + ql: any, + ps: any, + logger?: ProjectionLogger, +): Promise { + const out: PermissionSeedOutcome = { seeded: 0, updated: 0, skippedEnvAuthored: 0, skippedForeign: 0 }; + if (!ql || typeof ql.find !== 'function' || !ps?.name) return out; + + const existing = (await tryFind(ql, 'sys_permission_set', { name: ps.name }, 1))[0]; + if (!existing?.id) { + const created = await tryInsert(ql, 'sys_permission_set', { + id: genId('ps'), + name: ps.name, + ...permissionSetRowFields(ps), + active: ps.active != null ? asBool(ps.active) : true, + managed_by: 'user', + }); + if (created) out.seeded += 1; + 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; + } + + 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)) { + out.updated += 1; + } + return out; +} + +/** + * Sync the metadata manager's in-memory `permission` entry with the effective + * body just projected. The evaluator's `resolvePermissionSets` resolves from + * `metadata.list('permission')`, which is REGISTRY-FIRST — without this, an + * env overlay of a declared set would display (layered read + record) while + * the evaluator kept enforcing the stale declared body. + * + * Only runs while an OVERLAY actually exists (`overlayBacked`) — an + * overlay-less name needs no shadow (the registry already holds the declared + * body, or the DatabaseLoader serves the runtime row) and writing one would + * clobber the pristine declared entry. The synced copy is stamped with + * {@link ENV_PROJECTION_MARKER} so it can never masquerade as a packaged + * artifact after the overlay is gone. When the overlay disappears, a stale + * echo is healed back to `restoreTo` (the declared body) or dropped. + * + * Best-effort: when the facade lacks `registerInMemory`, overlay-only names + * still resolve via the DatabaseLoader / record dbLoader. + */ +async function syncEvaluatorRegistry( + metadata: any, + name: string, + body: any, + overlayBacked: boolean, +): Promise { + try { + if (!metadata || typeof metadata.registerInMemory !== 'function' || !name) return; + if (overlayBacked && body?.name) { + metadata.registerInMemory('permission', name, { + ...stripDecorations(body), + [ENV_PROJECTION_MARKER]: true, + }); + return; + } + // Overlay gone: heal a stale echo of ours back to the real body, or drop it. + const current = typeof metadata.get === 'function' ? await metadata.get('permission', name) : undefined; + if (!isProjectionEcho(current)) return; + if (body?.name) { + metadata.registerInMemory('permission', name, stripDecorations(body)); + } else { + dropEvaluatorRegistryEntry(metadata, name); + } + } catch { /* best-effort */ } +} + +/** Drop the in-memory `permission` entry for a retired definition. */ +function dropEvaluatorRegistryEntry(metadata: any, name: string): void { + try { + if (metadata && typeof metadata.unregister === 'function' && name) { + // unregister() also asks writable DB loaders to delete — the overlay + // row is already gone (deleteMetaItem ran first), so this is a no-op + // there and an in-memory removal here. + void metadata.unregister('permission', name); + } + } catch { /* best-effort */ } +} + +/** Retire the record of a definition deleted from metadata (trash applies). */ +async function retirePermissionSetRecord( + ql: any, + metadata: any, + name: string, + logger?: ProjectionLogger, +): Promise { + const out: PermissionSeedOutcome = { seeded: 0, updated: 0, skippedEnvAuthored: 0, skippedForeign: 0, deleted: 0 }; + const existing = (await tryFind(ql, 'sys_permission_set', { name }, 1))[0]; + if (!existing?.id) return out; + if (existing.managed_by === 'package') { + out.skippedForeign += 1; + logger?.warn?.('[security] metadata delete targets a package-owned set record — left to the package door', { name }); + return out; + } + try { + await ql.delete('sys_permission_set', { where: { id: existing.id }, context: SYSTEM_CTX }); + out.deleted = 1; + dropEvaluatorRegistryEntry(metadata, name); + // Drop any engine-registry ghost of the retired definition (a runtime + // shadow, or a projection echo re-registered by the delete-time registry + // heal) so metadata lists don't keep showing a deleted set. + try { ql?._registry?.unregisterItem?.('permission', name); } catch { /* best-effort */ } + } catch (e) { + logger?.warn?.('[security] failed to retire sys_permission_set record after metadata delete', { + name, error: (e as Error)?.message, + }); + } + return out; +} + +export interface ProjectionDeps { + ql: any; + /** Metadata manager facade (`getService('metadata')`) for the evaluator-registry sync. */ + metadata?: any; + logger?: ProjectionLogger; +} + +/** + * THE `permission` mutation projector (ADR-0094): re-read the FRESH effective + * body via the protocol's layered read (overlay-wins; the boot-time registry + * would hand back a stale declared body) and project it onto the record + + * evaluator registry. A mutation whose layered read yields NO body (a + * runtime-only definition was deleted) retires the record; a delete that + * reveals the artifact baseline (overlay tombstone) re-projects the declared + * body — the "reset" semantic. + * + * Returns the projection outcome, or `null` when the event is skipped + * (draft, non-permission, or unnamed). + */ +export async function projectPermissionMutation( + protocol: any, + deps: ProjectionDeps, + evt: { type?: string; name?: string; state?: string; organizationId?: string | null } | null | undefined, +): Promise { + if (evt?.type !== 'permission' || evt.state === 'draft' || !evt.name) return null; + const { ql, metadata, logger } = deps; + let body: any = null; + let overlayBacked = false; + if (protocol && typeof protocol.getMetaItemLayered === 'function') { + const layered = await protocol.getMetaItemLayered({ + type: 'permission', + name: evt.name, + ...(evt.organizationId ? { organizationId: evt.organizationId } : {}), + }); + // `getMetaItemLayered` may return a layered envelope (`{ effective | code }`) + // OR the effective body directly (top-level `name`) — accept both. The + // envelope carries `name` too, so detect it by its layer keys: an envelope + // whose layers are all null means the definition is GONE (retire), and + // must not be mistaken for a body. Layers that are just our own registry + // echo ({@link ENV_PROJECTION_MARKER}) don't count as a definition either — + // the declared (artifact) baseline is read from the engine SchemaRegistry, + // which this module never writes. + const isEnvelope = layered && typeof layered === 'object' + && ('effective' in layered || 'overlay' in layered || 'code' in layered); + if (isEnvelope) { + const overlay = layered.overlay ?? null; + overlayBacked = !!overlay; + const declared = readDeclaredBody(ql, evt.name); + // The envelope's `code`/`effective` layers are only a fallback for + // kernels without a readable SchemaRegistry: they can echo a deleted + // definition (tombstoned overlay row via the DatabaseLoader, a lingering + // runtime shadow, or our own registry sync), so where the registry is + // available, overlay ?? declared is the whole truth — an empty result + // means the definition is GONE (retire). + if (hasSchemaRegistry(ql)) { + body = overlay ?? declared; + } else { + const code = isProjectionEcho(layered.code) ? null : (layered.code ?? null); + const effective = isProjectionEcho(layered.effective) ? null : (layered.effective ?? null); + body = overlay ?? code ?? effective; + } + } else { + body = layered ?? null; + } + } + if (!body?.name) { + await syncEvaluatorRegistry(metadata, evt.name, null, false); + return retirePermissionSetRecord(ql, metadata, evt.name, logger); + } + const out = await upsertEnvPermissionSet(ql, body, logger); + if (out.seeded + out.updated > 0) { + await syncEvaluatorRegistry(metadata, evt.name, body, overlayBacked); + } + return out; +} + +/** + * Register the permission projector on the protocol. Prefers the AWAITED + * `registerMutationProjector` seam (ADR-0094 — no projection race); falls + * back to the fire-and-forget `onMetadataMutation` subscription (#2867) for + * protocol implementations that predate the projector. Returns `true` when + * wired. + */ +export function registerPermissionSetProjection( + protocol: any, + deps: ProjectionDeps, +): boolean { + if (!protocol) return false; + const handler = (evt: any) => projectPermissionMutation(protocol, deps, evt); + if (typeof protocol.registerMutationProjector === 'function') { + protocol.registerMutationProjector('permission', async (evt: any) => { await handler(evt); }); + return true; + } + if (typeof protocol.onMetadataMutation === 'function') { + protocol.onMetadataMutation((evt: any) => { + void handler(evt).catch((err: any) => { + deps.logger?.warn?.('[security] env permission projection after save failed', { + name: evt?.name, error: err?.message, + }); + }); + }); + return true; + } + return false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Data-door write-through (ADR-0094 D3) +// ───────────────────────────────────────────────────────────────────────────── + +const scalarId = (v: unknown): v is string | number | bigint => + v !== null && (typeof v === 'string' || typeof v === 'number' || typeof v === 'bigint'); + +/** Resolve the rows a data-door update/delete targets (single-id or filtered). */ +async function resolveTargetRows(ql: any, opCtx: any): Promise { + const data = opCtx?.data; + if (data && typeof data === 'object' && !Array.isArray(data) && scalarId(data.id)) { + return tryFind(ql, 'sys_permission_set', { id: data.id }, 1); + } + const where = opCtx?.options?.where; + if (where && typeof where === 'object' && scalarId((where as any).id)) { + return tryFind(ql, 'sys_permission_set', { id: (where as any).id }, 1); + } + if (where && typeof where === 'object') { + try { + const rows = await ql.find('sys_permission_set', { where, limit: 500 }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : []; + } catch { return []; } + } + return []; +} + +/** Column-patch → body-key merge for the data-door update redirect. */ +export function mergeRowPatchIntoBody(base: any, patch: Record): any { + const body: any = { ...stripDecorations(base) }; + if ('label' in patch) body.label = patch.label; + if ('description' in patch) { + if (patch.description == null) delete body.description; + else body.description = patch.description; + } + if ('active' in patch) body.active = asBool(patch.active); + if ('object_permissions' in patch) body.objects = parseMaybeJson(patch.object_permissions, {}); + if ('field_permissions' in patch) body.fields = parseMaybeJson(patch.field_permissions, {}); + if ('system_permissions' in patch) body.systemPermissions = parseMaybeJson(patch.system_permissions, []); + if ('row_level_security' in patch) body.rowLevelSecurity = parseMaybeJson(patch.row_level_security, []); + if ('tab_permissions' in patch) body.tabPermissions = parseMaybeJson(patch.tab_permissions, {}); + if ('admin_scope' in patch) { + const scope = patch.admin_scope == null ? undefined : parseMaybeJson(patch.admin_scope, undefined); + if (scope === undefined) delete body.adminScope; + else body.adminScope = scope; + } + if (!body.objects || typeof body.objects !== 'object') body.objects = {}; + return body; +} + +/** Effective (layered, overlay-wins) body for a record's name, else the row itself. */ +async function effectiveBodyForRow(protocol: any, ql: any, row: any): Promise { + try { + const layered = await protocol.getMetaItemLayered({ type: 'permission', name: row.name }); + let body: any; + if (hasSchemaRegistry(ql)) { + body = layered?.overlay ?? readDeclaredBody(ql, row.name); + } else { + body = layered?.overlay + ?? (isProjectionEcho(layered?.code) ? null : layered?.code) + ?? (isProjectionEcho(layered?.effective) ? null : layered?.effective); + } + if (body?.name) return body; + } catch { /* fall through */ } + return permissionSetBodyFromRow(row); +} + +export interface WriteThroughDeps extends ProjectionDeps { + /** Lazy protocol handle — the protocol service may register after start(). */ + getProtocol: () => any; +} + +/** + * 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. + * + * 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. + */ +export function createPermissionSetWriteThrough( + deps: WriteThroughDeps, +): (opCtx: any, next: () => Promise) => Promise { + const { ql, logger } = deps; + + const projectAndFetch = async (protocol: any, name: string): Promise => { + // The awaited projector inside saveMetaItem/deleteMetaItem normally did + // this already — re-running is an idempotent upsert, and covers the + // window where the projector isn't registered yet (pre-kernel:ready). + await projectPermissionMutation(protocol, deps, { type: 'permission', name, state: 'active', organizationId: null }); + return (await tryFind(ql, 'sys_permission_set', { name }, 1))[0] ?? null; + }; + + return async (opCtx: any, next: () => Promise): Promise => { + if (opCtx?.object !== 'sys_permission_set') return next(); + if (opCtx?.context?.isSystem) return next(); + const op = opCtx?.operation; + if (!['insert', 'update', 'delete', 'restore'].includes(op)) return next(); + + const protocol = deps.getProtocol?.(); + const capable = !!protocol + && typeof protocol.saveMetaItem === 'function' + && typeof protocol.deleteMetaItem === 'function' + && typeof protocol.getMetaItemLayered === 'function'; + if (!capable) return next(); + + const actor = opCtx?.context?.userId ? String(opCtx.context.userId) : undefined; + const actorArg = actor ? { actor } : {}; + + if (op === 'restore') { + // Let the engine un-trash the record, then re-author its definition + // into metadata (the delete removed it) so the stores converge live. + await next(); + const restored = await resolveTargetRows(ql, opCtx); + for (const row of restored) { + if (!row?.name) continue; + try { + await protocol.saveMetaItem({ type: 'permission', name: row.name, item: permissionSetBodyFromRow(row), ...actorArg }); + } catch (e) { + logger?.warn?.('[security] failed to re-author restored permission set into metadata', { + name: row.name, error: (e as Error)?.message, + }); + } + } + return; + } + + if (op === 'insert') { + const rows = Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]; + // A payload without a usable machine name gets the engine's own + // required-field validation error — don't mask it. + if (rows.some((r: any) => !r || typeof r !== 'object' || !r.name || typeof r.name !== 'string')) { + return next(); + } + const results: any[] = []; + for (const row of rows) { + const name = String(row.name); + const dup = (await tryFind(ql, 'sys_permission_set', { name }, 1))[0]; + if (dup) { + const err: any = new Error(`[Security] permission set '${name}' already exists`); + err.status = 409; + throw err; + } + // The metadata write is the authoritative one; spec validation + // (PermissionSetSchema) runs inside saveMetaItem and rejects an + // off-contract body with a structured 422. + await protocol.saveMetaItem({ type: 'permission', name, item: permissionSetBodyFromRow(row), ...actorArg }); + results.push((await projectAndFetch(protocol, name)) ?? { name }); + } + opCtx.result = Array.isArray(opCtx.data) ? results : results[0]; + return; // driver write intentionally skipped — the record is projector-owned + } + + const targets = await resolveTargetRows(ql, opCtx); + if (targets.length === 0 || targets.some((t: any) => !t?.name)) return next(); + + if (op === 'update') { + const patch = Array.isArray(opCtx.data) ? null : opCtx.data; + if (!patch || typeof patch !== 'object') return next(); + if (typeof patch.name === 'string' && targets.some((t: any) => t.name !== patch.name)) { + const err: any = new Error( + `[Security] renaming a permission set through the data door is not supported — the name is its ` + + `metadata identity (ADR-0094). Clone to a new name and delete the old set instead.`, + ); + err.status = 400; + throw err; + } + const results: any[] = []; + for (const row of targets) { + const base = await effectiveBodyForRow(protocol, ql, row); + const body = mergeRowPatchIntoBody(base, patch); + body.name = row.name; + await protocol.saveMetaItem({ type: 'permission', name: row.name, item: body, ...actorArg }); + results.push((await projectAndFetch(protocol, row.name)) ?? { id: row.id, name: row.name }); + } + opCtx.result = results.length === 1 ? results[0] : results; + return; + } + + // delete: remove the definition from the metadata store. Runtime-only + // definitions hard-delete (the projector then retires the record, trash + // semantics apply); artifact-backed definitions tombstone their overlay — + // an ADR-0005 RESET — and the record re-projects to the declared body + // instead of vanishing (a packaged definition cannot be deleted from the + // environment). + let lastOutcome: any = true; + for (const row of targets) { + await protocol.deleteMetaItem({ type: 'permission', name: row.name, ...actorArg }); + const res = await projectPermissionMutation(protocol, deps, { + type: 'permission', name: row.name, state: 'deleted', organizationId: null, + }); + if (res && (res.seeded + res.updated) > 0) { + logger?.info?.('[security] permission set reset to its declared baseline (artifact-backed; ADR-0094)', { name: row.name }); + } + lastOutcome = res?.deleted ? true : lastOutcome; + } + opCtx.result = lastOutcome; + return; + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Boot reconciliation + one-time backfill (ADR-0094 D4) +// ───────────────────────────────────────────────────────────────────────────── + +export interface ProjectionReconcileOutcome { + /** Records created/updated from env-scope metadata overlays. */ + projectedFromMetadata: number; + /** Legacy data-door-only records migrated into the metadata store. */ + backfilledIntoMetadata: number; + /** Records re-projected because they drifted from the effective body. */ + driftHealed: number; +} + +/** Compare a record's projected columns against a body — true when they differ. */ +export function recordDiffersFromBody(row: any, body: any): boolean { + const want = permissionSetRowFields(body); + const norm = (v: any) => JSON.stringify(parseMaybeJson(v, null)); + for (const key of ['object_permissions', 'field_permissions', 'system_permissions', 'row_level_security', 'tab_permissions', 'admin_scope'] as const) { + if (norm(row?.[key]) !== norm(want[key])) return true; + } + if ((row?.label ?? null) !== (want.label ?? null)) return true; + if ((row?.description ?? null) !== (want.description ?? null)) return true; + if (body?.active != null && asBool(row?.active) !== asBool(body.active)) return true; + return false; +} + +/** + * Converge `sys_permission_set` with the metadata layer at boot (idempotent): + * + * 1. every ACTIVE env-scope `permission` overlay projects onto its record + * (creating missing ones) — metadata wins; + * 2. an env-authored record whose name has NO metadata presence (no + * declaration, no overlay) is a legacy data-door creation — its body is + * backfilled into the metadata store ONCE (enforcement unchanged: the + * evaluator's db fallback already resolved exactly this body); + * 3. an env-authored record that drifted from an EXISTING effective body is + * re-projected from metadata, loudly — for such names the evaluator + * already resolved the metadata body, so the record drift was + * display-only and never enforced (promoting it would silently change + * effective permissions at upgrade). + */ +export async function reconcilePermissionSetProjection( + protocol: any, + deps: ProjectionDeps, +): Promise { + const out: ProjectionReconcileOutcome = { projectedFromMetadata: 0, backfilledIntoMetadata: 0, driftHealed: 0 }; + const { ql, logger } = deps; + if (!ql || typeof ql.find !== 'function' || !protocol || typeof protocol.getMetaItemLayered !== 'function') { + return out; + } + + // 1. env-scope overlays → records. + const overlayNames = new Set(); + for (const type of ['permission', 'permissions']) { + const rows = await tryFind(ql, 'sys_metadata', { type, state: 'active' }, 1000); + for (const r of rows) { + if ((r?.organization_id ?? null) !== null || !r?.name) continue; // env-wide overlays only + overlayNames.add(String(r.name)); + } + } + for (const name of overlayNames) { + const res = await projectPermissionMutation(protocol, deps, { + type: 'permission', name, state: 'active', organizationId: null, + }); + out.projectedFromMetadata += (res?.seeded ?? 0) + (res?.updated ?? 0); + } + + // 2 + 3. env-authored records: backfill or heal. + const records = await tryFind(ql, 'sys_permission_set', {}, 1000); + for (const row of records) { + if (!row?.name || row.managed_by === 'package') continue; + if (overlayNames.has(String(row.name))) continue; // governed + projected above + let layered: any = null; + try { + layered = await protocol.getMetaItemLayered({ type: 'permission', name: row.name }); + } catch { layered = null; } + // Same trust rule as the projector: with a readable SchemaRegistry the + // declared body is the whole truth for overlay-less names — the layered + // `code`/`effective` layers can echo tombstoned rows or runtime shadows + // and would suppress a legitimate backfill. + let effective: any = readDeclaredBody(ql, row.name); + if (!effective?.name && !hasSchemaRegistry(ql)) { + effective = (isProjectionEcho(layered?.effective) ? null : layered?.effective) + ?? (isProjectionEcho(layered?.code) ? null : layered?.code) + ?? null; + } + if (!effective?.name) { + const canSave = typeof protocol.saveMetaItem === 'function'; + if (!canSave) continue; + try { + await protocol.saveMetaItem({ + type: 'permission', name: row.name, item: permissionSetBodyFromRow(row), actor: 'system', + }); + out.backfilledIntoMetadata += 1; + } catch (e) { + logger?.warn?.('[security] permission-set backfill into metadata failed (ADR-0094 D4)', { + name: row.name, error: (e as Error)?.message, + }); + } + } else if (recordDiffersFromBody(row, effective)) { + // These names have NO env overlay (skipped above), so the effective + // body IS the declared one — the registry already enforces it; only + // the record needs healing. No registry sync (it would clobber the + // pristine declared entry with a projection copy). + logger?.warn?.( + '[security] sys_permission_set record drifted from its metadata definition — re-projected (metadata wins; ADR-0094 D4)', + { name: row.name }, + ); + const res = await upsertEnvPermissionSet(ql, stripDecorations(effective), logger); + if (res.updated + res.seeded > 0) { + out.driftHealed += 1; + } + } + } + + logger?.info?.('[security] sys_permission_set projection reconciled (ADR-0094 D4)', { ...out }); + return out; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index abb5f2353f..8d11ce17a3 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -14,7 +14,12 @@ import { } from './explain-engine.js'; import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; -import { bootstrapDeclaredPermissions, upsertPackagePermissionSet, subscribeEnvPermissionProjection } from './bootstrap-declared-permissions.js'; +import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './bootstrap-declared-permissions.js'; +import { + createPermissionSetWriteThrough, + registerPermissionSetProjection, + reconcilePermissionSetProjection, +} from './permission-set-projection.js'; import { syncAudienceBindingSuggestions, listAudienceBindingSuggestions, @@ -1047,16 +1052,37 @@ export class SecurityPlugin implements Plugin { ctx.logger.info('Security middleware registered on ObjectQL engine'); + // [ADR-0094] Data-door write-through: every non-system CRUD write on + // `sys_permission_set` is redirected into the metadata store (the ONE + // authoritative store for definitions); the record is projector-owned. + // Registered AFTER the security middleware, so it runs INSIDE it — the + // two-doors gate, the delegated-admin gate, and the CRUD/FLS checks have + // all passed before a write is translated. Kernels without a capable + // metadata protocol pass through to the legacy direct write (single + // store — no split brain to prevent). + ql.registerMiddleware( + createPermissionSetWriteThrough({ + ql, + metadata, + getProtocol: () => { + try { return (ctx as any).getService?.('protocol') ?? null; } catch { return null; } + }, + logger: ctx.logger, + }), + { object: 'sys_permission_set' }, + ); + // Defer platform admin bootstrap until all plugins finish starting — // sys_user / sys_permission_set objects must be registered (by // plugin-auth and platform-objects respectively) before we can // insert seed rows. Falls back to immediate execution when the // kernel does not expose `hook` (test stubs). let bootstrapRanOnce = false; - // [framework#2857] Guard so the env-projection mutation subscriber is wired - // exactly once even though runBootstrap re-runs (e.g. after the first user - // insert) — onMetadataMutation appends listeners, so re-wiring would project - // each save N times. + // [ADR-0094] Guard so the env-projection wiring runs exactly once even + // though runBootstrap re-runs (e.g. after the first user insert) — + // registerMutationProjector replaces idempotently, but the legacy + // onMetadataMutation fallback appends listeners, and re-wiring that would + // project each save N times. let envProjectionWired = false; const runBootstrap = async () => { try { @@ -1179,19 +1205,29 @@ export class SecurityPlugin implements Plugin { }, ); } - // [framework#2857] Environment door — project an env-scope permission - // metadata save onto its sys_permission_set record. saveMetaItem writes - // only the sys_metadata overlay, so without this the admin/Setup surface - // (which reads the record) went stale while the layered read showed the - // edit. onMetadataMutation fires post-persistence for active (non-draft) - // saves; the subscriber re-reads the FRESH effective body via the layered - // read (the MetadataManager registry would hand back the stale - // boot-declared body for a seeded set) and projects it. Env-authored - // rows only — a package record's baseline is its declaration, owned by - // boot re-seed / publish, never an env override. + // [ADR-0094] Environment door — the `permission` mutation projector. + // The protocol AWAITS it inside saveMetaItem / publishMetaItem / + // deleteMetaItem, so the sys_permission_set record (and the metadata + // manager's in-memory entry, which the evaluator's registry-first + // list('permission') resolution reads) already reflects a Studio + // save when it returns — no projection race. Falls back to the + // fire-and-forget onMetadataMutation subscription (#2857/#2867) on + // protocols that predate registerMutationProjector. if (!envProjectionWired) { - const unsub = subscribeEnvPermissionProjection(protocol, ql, ctx.logger); - if (unsub) envProjectionWired = true; + envProjectionWired = registerPermissionSetProjection(protocol, { + ql, metadata: this.metadata, logger: ctx.logger, + }); + } + // [ADR-0094 D4] Converge record ↔ metadata: project env overlays + // onto records (creating missing ones), backfill legacy data-door + // creations into the metadata store once, and heal drifted records + // from the effective body (metadata wins). Idempotent per boot. + try { + await reconcilePermissionSetProjection(protocol, { + ql, metadata: this.metadata, logger: ctx.logger, + }); + } catch (e) { + ctx.logger.warn('[security] permission-set projection reconciliation failed (ADR-0094)', { error: (e as Error).message }); } } catch (e) { ctx.logger.warn('[security] permission publish-materializer registration failed', { error: (e as Error).message });