From 728ffe49e469053af907a67080ad58d85de854d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 06:45:30 +0000 Subject: [PATCH 1/2] refactor(plugin-security): make sys_permission_set a pure projection of the metadata layer (ADR-0094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire the two-store split-brain behind the #2857 display-freshness class (#2875): the metadata layer (packaged declarations + sys_metadata overlay, merged overlay-wins) is now the ONE authoritative store for permission-set definitions, and the queryable sys_permission_set record is a derived read-model — enforced structurally, not by a subscriber a new write path might forget. - metadata-protocol: new registerMutationProjector(type, fn) — an awaited, best-effort per-type hook run after persistence inside saveMetaItem / publishMetaItem / deleteMetaItem, surfaced as `projectionApplied` on the response. A derived read-model is consistent before the write returns (the #2867 onMetadataMutation subscriber was fire-and-forget). - plugin-security: new permission-set-projection module: * data-door write-through — every non-system CRUD write on sys_permission_set is redirected into the metadata store at the engine middleware choke point (after the two-doors + delegated-admin + CRUD/FLS gates); the record is written only by the projector; * the projector re-reads the layered effective body, upserts/creates the env record (Studio-authored sets finally appear in Setup), retires it when the definition is gone, resets it to the declared body when an overlay tombstone reveals the artifact baseline, and syncs the metadata manager's in-memory `permission` entry so evaluator resolution (registry-first list) can no longer disagree with the display; * boot reconciliation — env overlays project onto records, legacy data-door-only records are backfilled into metadata once, and records that drifted from an existing definition are re-projected (metadata wins; the drift was never enforced); * declared (artifact) baselines are read from the engine SchemaRegistry, skipping runtime shadows and projection echoes, so a deleted runtime-only definition cannot zombie back as "declared". Behavior changes: deleting an artifact-backed set through the data door now RESETS it to its declared body instead of removing the row; renames through the data door are rejected (400); pre-existing record drift shadowed by a metadata definition is discarded loudly at first boot. Follow-up: rejecting env-scope overlays of package-owned sets at authoring time is tracked in #2898. Closes #2875 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q --- .changeset/permission-set-pure-projection.md | 13 + ...0094-sys-permission-set-pure-projection.md | 217 +++++ packages/metadata-protocol/src/index.ts | 1 + .../src/mutation-listeners.test.ts | 52 ++ packages/metadata-protocol/src/protocol.ts | 112 +++ .../bootstrap-declared-permissions.test.ts | 135 +--- .../src/bootstrap-declared-permissions.ts | 167 +--- packages/plugins/plugin-security/src/index.ts | 16 + .../src/permission-set-projection.test.ts | 529 +++++++++++++ .../src/permission-set-projection.ts | 747 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 70 +- 11 files changed, 1761 insertions(+), 298 deletions(-) create mode 100644 .changeset/permission-set-pure-projection.md create mode 100644 docs/adr/0094-sys-permission-set-pure-projection.md create mode 100644 packages/plugins/plugin-security/src/permission-set-projection.test.ts create mode 100644 packages/plugins/plugin-security/src/permission-set-projection.ts 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/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 }); From 85bca3038df92dc7c336f6be69a147331dce37b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 06:47:32 +0000 Subject: [PATCH 2/2] docs(permissions): document the ADR-0094 pure-projection env door in authorization.mdx Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q --- content/docs/permissions/authorization.mdx | 9 +++++++++ 1 file changed, 9 insertions(+) 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