From 6a081884cdf6338a4590c250825d386c44282b8e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:20:10 +0000 Subject: [PATCH 01/12] refactor(plugin-security): delete the permission-set projection, the seed-name lookup and the refusal sink with the suites that pin only them (ADR-0131 D3, C3 stage 7a; held) Carried over from the C3 build-ahead onto main after stage 8. Stage 8 stopped registering the sys_permission_set row object, the permission mutation projector and the data-door write-through, so these modules have no live caller left: - permission-set-projection.ts, seed-name-lookup.ts and seed-refusal-sink.ts are deleted, with their index exports and the projection's ADR anchor; - tryFind becomes a private helper of the overlay reading, its one live caller, and the reading's logger option asks only for warn; - the security middleware loses the writesExecutedByDataDoor stand-down, which only the retired data door could arm; - the suites that pin only the deleted modules go, and the packaged-set lock's pins move onto the metadata door's lock gate. Held, not ready for review: permission-set-echo-package-owner.test.ts still imports the projection. It is the only pin of the owner-carrying echo, which lets a runtime package's default permission set keep its owner and record its everyone suggestion on an environment kernel; stage 8 unwired that behaviour with the projector. How that behaviour is carried is a decision owed before this deletion lands. The census, ledgers, liveness registry and the epic's zero-row acceptance are not done yet. Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- ...04-7a-permission-set-projection-retired.md | 29 + content/docs/permissions/system-context.mdx | 1 - .../src/engine-find-bare-array.pin.test.ts | 83 +- packages/plugins/plugin-security/src/index.ts | 16 - .../src/packaged-permission-set-lock.test.ts | 772 +------ ...d-permission-set-overlay-detection.test.ts | 34 +- ...ckaged-permission-set-overlay-detection.ts | 20 +- ...ackaged-permission-set-restore-leg.test.ts | 470 ---- ...mission-set-duplicate-name-refusal.test.ts | 279 --- ...rmission-set-projection-heal-by-id.test.ts | 272 --- .../src/permission-set-projection.test.ts | 1886 ----------------- .../src/permission-set-projection.ts | 1821 ---------------- .../plugin-security/src/security-plugin.ts | 30 +- ...ed-name-lookup-degradation-audible.test.ts | 246 --- .../plugin-security/src/seed-name-lookup.ts | 506 ----- .../plugin-security/src/seed-refusal-sink.ts | 83 - packages/spec/liveness/permission.json | 2 +- ...ty__src__permission-set-projection.ts.json | 9 - scripts/measure-durability-swallow-family.mjs | 9 +- scripts/tenant-audit-census.mjs | 7 +- 20 files changed, 141 insertions(+), 6434 deletions(-) create mode 100644 .changeset/15204-7a-permission-set-projection-retired.md delete mode 100644 packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-projection.test.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-projection.ts delete mode 100644 packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts delete mode 100644 packages/plugins/plugin-security/src/seed-name-lookup.ts delete mode 100644 packages/plugins/plugin-security/src/seed-refusal-sink.ts delete mode 100644 scripts/adr-anchors/packages__plugins__plugin-security__src__permission-set-projection.ts.json diff --git a/.changeset/15204-7a-permission-set-projection-retired.md b/.changeset/15204-7a-permission-set-projection-retired.md new file mode 100644 index 00000000000..778210e2e31 --- /dev/null +++ b/.changeset/15204-7a-permission-set-projection-retired.md @@ -0,0 +1,29 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/spec': patch +--- + +feat(plugin-security)!: the permission-set projection, its seed-name lookup and refusal sink are deleted (ADR-0131 D3, C3 stage 7a) + +Clause-②: no (narrowing) + + + +**BREAKING** (retired exports of `@objectstack/plugin-security`), graded `minor` on the v18 prerelease line. + +Stage 8 stopped registering the `sys_permission_set` row object, the `permission` mutation projector and the data-door write-through. This change deletes the modules behind them: `permission-set-projection`, `seed-name-lookup` and `seed-refusal-sink`. A permission set is written through the metadata door alone (`PUT /api/v1/meta/permission/:name`), and the security catalog serves it by name. + +What a consumer sees: + +- FROM `createPermissionSetWriteThrough(deps)` TO nothing: no `sys_permission_set` data-door write exists to redirect. Write the set through the metadata door. Delete the call. +- FROM `registerPermissionSetProjection(protocol, deps)` TO nothing: no row mirrors a definition. Delete the call. +- FROM `reconcilePermissionSetProjection(...)` TO nothing: there is no projection to reconcile at boot. Delete the call. +- FROM `projectPermissionMutation(...)` TO nothing. Delete the call. +- FROM `upsertEnvPermissionSet(...)` TO `PUT /api/v1/meta/permission/:name`, or the metadata protocol's `saveMetaItem({ type: 'permission', name, item })`. +- FROM `permissionSetBodyFromRow(row)` TO `permissionSetDefinitionFromRow(row)` from `@objectstack/core`: the same mapping, for converting a stored `sys_permission_set` row into its definition. +- FROM `permissionSetRowFields(definition)` TO nothing: no row is written. Delete the call. +- FROM the types `PermissionSeedOutcome`, `ProjectionDeps`, `ProjectionReconcileOutcome` and `WriteThroughDeps` TO nothing. Delete the import. + +Unchanged: the packaged-permission-set lock still refuses a metadata-door save of a set a code package ships (`403 NOT_OVERRIDABLE`), and the boot still reports environment overlays that shadow such a set. `OverlayDetectionOptions.logger` now asks only for `warn`, the one channel the reading reports on. + +**`@objectstack/spec`** (patch): the `permission.tabPermissions` liveness entry no longer cites the deleted projection. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index c1084bb229f..9591226b30f 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -108,7 +108,6 @@ that silently does not happen. | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | | 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `packages/plugins/plugin-security/src/security-plugin.ts#checkControlledByParentWrite` | | 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` | -| 11 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `packages/plugins/plugin-security/src/permission-set-projection.ts#createPermissionSetWriteThrough` | | 11b | Position write-through skipped — a `sys_position` write is not mirrored into the environment ledger | plugin-security | Get: the package door writes rows for definitions that already have their home, untouched (no boot seeder writes a `sys_position` row, ADR-0131 D2). Lose: a system-written row has no environment definition, so the security catalog read does not resolve it until a data-door edit, or the one-time row-only position backfill, gives it one | `packages/plugins/plugin-security/src/position-write-through.ts#createPositionWriteThrough` | | 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | | 13 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | diff --git a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts index 32b750ed26a..d8683531edc 100644 --- a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts +++ b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts @@ -17,7 +17,7 @@ * Every seam answered `[object Array]`, with no own `records` key, on a * populated page and an empty one alike. * - * ⚠️ These are four pins, not one pin repeated. A single reading of "the engine + * ⚠️ These are two pins, not one pin repeated. A single reading of "the engine * returns an array" would say nothing about whether a given seam is even * reachable — some of them sit behind a trigger (a truncated page, a refused * bulk write), and a seam nothing reaches is @@ -25,9 +25,10 @@ * through the real function and asserts the answer that block produced, and * {@link assertBareArrayPage} then reads back the value the engine handed it. * - * Blocks 3 and 6 (the declared-permission seeder's per-name read and the - * `managed_by` normalizer's scan) retired with their modules (ADR-0131 D3: - * the boot writes no catalog row). The seventh seam, `security-plugin.ts`'s + * Blocks 1 and 2 (`seed-name-lookup`'s batched page and its per-item + * degradation read), 3 and 6 (the declared-permission seeder's per-name read + * and the `managed_by` normalizer's scan) retired with their modules (ADR-0131 + * D3: the boot writes no catalog row). The seventh seam, `security-plugin.ts`'s * `sys_permission_set` loader, is gone too: the plugin takes a set's body from the security catalog * (ADR-0131 D3/D4) and reads no `sys_permission_set` row, so it has no * engine page to pin. Its fault direction (a read that did not answer @@ -52,7 +53,6 @@ import { assertEngineDeleteDispatch, } from '@objectstack/metadata-core'; -import { buildExistingByName } from './seed-name-lookup.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { claimSeedOwnership } from './claim-seed-ownership.js'; @@ -170,79 +170,6 @@ function assertBareArrayPage(seen: Seen, label: string): void { } } -/** A logger that records what a seeder said, so a fallback can be asserted absent. */ -function recordingLogger() { - const warns: string[] = []; - const infos: string[] = []; - return { - warns, - infos, - logger: { - info: (m: string) => { infos.push(m); }, - warn: (m: string) => { warns.push(m); }, - debug: () => { /* noop */ }, - error: (m: string) => { warns.push(m); }, - }, - }; -} - -describe('[#15598] block 1 — seed-name-lookup readNamePage (the BATCHED page)', () => { - it('answers from one bare array, so the batched read is live and does not degrade', async () => { - const engine = await boot(); - await (engine as any).insert( - 'sys_permission_set', - { name: 'ps_batched', label: 'Batched', managed_by: 'admin', organization_id: ORG }, - SYS, - ); - const seen: Seen = []; - const { warns, logger } = recordingLogger(); - - const index = await buildExistingByName( - observed(engine, seen), 'sys_permission_set', ['ps_batched'], logger as any, ORG, - ); - const found = await index.get('ps_batched'); - - assertBareArrayPage(seen, 'readNamePage'); - // The batched page ANSWERED. This is the discriminating half: a normalizer - // that could not read the page degrades to the per-item oracle, which would - // still resolve `present` — so the result alone proves nothing here. - expect(warns.filter((w) => w.includes('falling back to one read per item'))).toEqual([]); - expect(found.status).toBe('present'); - }, 120_000); -}); - -describe('[#15598] block 2 — seed-name-lookup perItemIndex (the DEGRADATION read)', () => { - it('answers from one bare array on the per-item path', async () => { - const engine = await boot(); - await (engine as any).insert( - 'sys_permission_set', - { name: 'ps_peritem', label: 'Per item', managed_by: 'admin', organization_id: ORG }, - SYS, - ); - const seen: Seen = []; - // TRIGGER ONLY: the batched read is refused so the per-item path is taken. - // The per-item read itself reaches the real engine untouched — it is the - // subject, and nothing stands in for it. - const batchedRefused = observed(engine, seen, { - find: async (o: string, q?: any, opt?: any) => { - const name = q?.where?.name; - if (name && typeof name === 'object' && '$in' in name) throw new Error('batched read refused (trigger)'); - const r = await engine.find(o, q, opt); - seen.push(r); - return r; - }, - }); - - const index = await buildExistingByName(batchedRefused, 'sys_permission_set', ['ps_peritem'], undefined, ORG); - const found = await index.get('ps_peritem'); - - assertBareArrayPage(seen, 'perItemIndex'); - // `present`, never `unknown`: a seam that cannot read its page reports the - // read as un-happened, which is the outcome this pin discriminates against. - expect(found.status).toBe('present'); - }, 120_000); -}); - describe('[#15598] block 4 — auto-org-admin-grant tryFind', () => { it('reads the set and the membership out of bare arrays, and grants on them', async () => { const engine = await boot(); diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 1d87a5bf8e4..9d7ca1fc7fa 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -70,22 +70,6 @@ export type { CollisionReportSink, PermissionSetNameCollisionDiagnostic, } from './permission-set-name-collision.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 { objectPostureGate, registerObjectPostureGate } from './object-posture-gate.js'; diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts index 5c91a12dd0e..41a649751e6 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts @@ -8,268 +8,60 @@ * the server with a message that names the sanctioned path (clone it), so no * silent overlay row is ever minted again. * - * ## The harness models the hole, not the happy path - * - * The protocol double here has the `OS_METADATA_WRITABLE=permission` operator - * hatch OPEN — its `saveMetaItem` does NOT refuse an artifact-backed name. That - * is deliberate and it is the whole point: with the hatch closed the producer's - * own ADR-0005 tier gate already answers 403 `NOT_OVERRIDABLE`, so a suite - * written against a refusing double would score green on a door that is still - * wide open in exactly the deployment the field report came from. The hatch is - * documented, reachable, and is the one path that mints a fresh overlay of a - * packaged set today. - * - * ## The pins, and why each one is not optional - * - * 1. the refusal fires AND its MESSAGE names the clone path — the ruling's - * point is that the admin learns what to do instead, so asserting only the - * rejection would pass on a bare "no"; - * 2. ⭐ an ordinary org-owned set is STILL accepted. The control: an - * implementation that refuses everything satisfies pin 1 perfectly; - * 3. the clone path works end to end — org-owned row, no upgrade linkage — - * and the package-declared base is untouched by it; - * 5. fail-closed on ambiguity: a provenance read that cannot ANSWER refuses, - * never accepts; - * 6. ⭐ what the clone ACTION SENDS (commit 5cb62d88b) — pin 3 drives the door with a - * hand-written payload, so it could not see that the ACTION ITSELF listed - * only two of the row's six definition facets. Pin 6 reads the payload out - * of the action definition, so editing that params list is what moves it. - * - * (Pin 4 — the detection reading for overlays that already exist — lives in its - * own suite at the bottom of this file, because it reads rather than writes.) - * - * ## ⭐ The fail-open this must not inherit (repaired by commit e1d773eb7) - * - * `buildExistingByName`'s UNSCOPED page cap (`limit: names.length`, - * `seed-name-lookup.ts`) truncated as soon as one name could carry more than one - * row, and a truncated page read as "absent". Asking THAT oracle "is this set - * package-declared?" would turn a truncation into "not package-declared" — and - * the save this ruling exists to refuse would be accepted. A silent fork - * produced by the code written to stop silent forks. - * - * Commit e1d773eb7 has since repaired the cap itself — an overflowing page is now detected - * and degrades to the per-item read rather than answering — so the controls - * below no longer guard against THAT truncation reaching this verdict. They are - * kept, and they still pass, because what they actually pin is structural and - * outlives the repair: this verdict is not decided by a name-keyed page read at - * all. Unscoped, `buildExistingByName` still answers with the FIRST row by id - * for a name, so on a name several organizations hold it answers with somebody - * else's row — the same question, a different wrong answer, and one no page - * budget reaches. - * - * So the provenance question is decided from the engine's SchemaRegistry — the - * same source `readDeclared` already uses, - * an in-memory array with no page, no cap and no `$in`. Two controls prove the - * immunity structurally rather than asserting it: - * - * - CONTROL A builds the exact multi-row shape that truncated before commit e1d773eb7, in a - * double whose `find` HONOURS `limit` (the projection suite's double ignores - * it, so the trap cannot even be expressed there), shows the truncation is - * live, and pins that the refusal still fires; - * - CONTROL B makes every NAME-KEYED page read over `sys_permission_set` fail - * outright — the most extreme form of "this read did not answer" — while - * by-id reads keep working so the middleware still reaches the question. - * The verdict is unchanged, which is only possible if the question was never - * asked of that table. That is a structural proof, not an assertion. + * The lock's one door is the metadata door's authoring gate + * (`packaged-permission-set-lock-gate.ts`, pinned end to end in + * `packaged-permission-set-lock-gate.test.ts`). The `sys_permission_set` data + * door that also consulted it retired with the catalog row objects (ADR-0131 + * D3), and its pins with it. This file pins the two halves that do not depend + * on a door: + * + * - the CLASSIFIER — "shipped by a code package" is judged from the + * provenance the SchemaRegistry carries, never from a name-keyed page read + * over a table (#21789); + * - the refusal's END-USER copy — the guidance reaches the wire as + * `userMessage`, through the producer's own error resolution. */ import { describe, it, expect } from 'vitest'; -import { PermissionSetSchema } from '@objectstack/spec/security'; -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; -import { mapDataError, resolveThrownHttpError } from '@objectstack/types'; +import { resolveThrownHttpError } from '@objectstack/types'; import { classifyPackagedPermissionSet, PackagedPermissionSetLockedError, PackagedPermissionSetProvenanceUnknownError, } from './packaged-permission-set-lock.js'; import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; -import { - createPermissionSetWriteThrough, - permissionSetRowFields, - registerPermissionSetProjection, -} from './permission-set-projection.js'; // ───────────────────────────────────────────────────────────────────────────── // Doubles // ───────────────────────────────────────────────────────────────────────────── /** - * In-memory ql over `sys_permission_set` + `sys_metadata`. - * - * ⚠️ `find` HONOURS `limit`. The sibling double in - * `permission-set-projection.test.ts` does not, which is why the page-cap - * truncation commit e1d773eb7 repaired cannot be reproduced there — a page cap that the double - * ignores is a page cap that no test in that file can ever measure. + * The engine surface the classifier reads: its SchemaRegistry, and nothing + * else. `null` models a kernel with no readable registry. */ function makeQl(declared: any[] | null = null) { - 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]) => { - // Refuse combinators rather than reading `$and`/`$or` as a column name - // (check:where-matcher / #8494): no path under test issues one, and a - // matcher that treated a combinator as a field would answer the wrong - // rows while this suite stayed green. - if (k.startsWith('$')) throw new Error(`fake driver: unsupported combinator ${k}`); - if (v && typeof v === 'object' && !Array.isArray(v)) { - const inList = (v as any).$in; - if (Array.isArray(inList)) return inList.includes(r[k]); - throw new Error(`fake driver: unsupported operator ${Object.keys(v).join(',')}`); - } - return v === null ? (r[k] ?? null) === null : r[k] === v; - }); - /** Set to fail exactly the NAME-KEYED page reads — CONTROL B. */ - let nameKeyedFindThrows = false; - const ql: any = { - permRows, - metaRows, - /** - * Break precisely the read commit e1d773eb7 is about: a name-keyed page over - * `sys_permission_set`. Reads by `id` (target resolution) keep working, so - * the middleware still reaches the provenance question — which is the only - * way to observe what that question answers when the paged table is - * unavailable. - */ - breakNameKeyedFind() { nameKeyedFindThrows = true; }, - async find(object: string, q: any) { - if ( - nameKeyedFindThrows - && object === 'sys_permission_set' - && q?.where && Object.prototype.hasOwnProperty.call(q.where, 'name') - ) { - throw new Error('fake driver: name-keyed page read unavailable'); - } - const rows = tableFor(object); - if (!rows) return []; - const hit = rows.filter((r) => matches(r, q?.where)); - // The cap a real driver applies — and the one commit e1d773eb7 stopped turning into a false - // "absent". Modelled, not ignored. - return typeof q?.limit === 'number' ? hit.slice(0, q.limit) : hit; - }, - async findOne(object: string, q: any) { - assertEngineFindOnePredicate(object, q); - 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 }; - }, - // Routed through the PRODUCER's own dispatch predicates - // (check:engine-double-contract / #4434, #5480): a double looser than - // `ObjectQL.update` / `ObjectQL.delete` converts a green suite into no - // suite at all on precisely the paths a double was introduced for. - async update(object: string, data: any, options?: any) { - const dispatch = assertEngineUpdateDispatch(data, options); - const rows = tableFor(object) ?? []; - const targets = dispatch.kind === 'by-id' - ? rows.filter((r: any) => r.id === dispatch.id) - : rows.filter((r: any) => matches(r, options?.where)); - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - async delete(object: string, options?: any) { - const dispatch = assertEngineDeleteDispatch(options); - const rows = tableFor(object) ?? []; - const targets = dispatch.kind === 'by-id' - ? rows.filter((r: any) => r.id === dispatch.id) - : rows.filter((r: any) => matches(r, options?.where)); - for (const r of targets) rows.splice(rows.indexOf(r), 1); - return targets.length > 0; - }, - }; + const ql: any = {}; if (declared !== null) { ql.registry = { listItems: (type: string) => (type === 'permission' ? declared : []) }; } return ql; } -/** - * Metadata protocol double with the operator hatch OPEN. - * - * ⛔ `saveMetaItem` deliberately models NO ADR-0005 tier gate. That gate is - * what `OS_METADATA_WRITABLE=permission` switches off, and this suite exists to - * pin the door that stays open when it is off. `declared` still feeds the - * layered read's `code` layer, because that is a READ and the hatch does not - * change it. - */ -function makeHatchOpenProtocol(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: any = { - 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 }) { - // The REAL `PermissionSetSchema`, exactly as `saveMetaItem` runs it - // (`resolveOverlaySchema`), same `[invalid_metadata]` 422 envelope. Only - // the ADR-0005 TIER gate is absent here — that is the hatch. Without the - // schema the double would accept any object and this suite could stay - // green while every real write failed (#4001 / #4669). - const parsed = PermissionSetSchema.safeParse(req.item); - if (!parsed.success) { - const summary = parsed.error.issues - .map((i: any) => `${i.path.join('.') || ''}: ${i.message}`) - .join('; '); - const err: any = new Error(`[invalid_metadata] permission/${req.name} failed spec validation: ${summary}`); - err.code = 'INVALID_METADATA'; - err.status = 422; - throw err; - } - 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 }); - return { success: true }; - }, - async deleteMetaItem(req: { type: string; name: 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 }; - }, - 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, - }; - }, +/** Register the lock on a protocol double and hand back the gate it registered. */ +function lockGate( + ql: any, + getMetaItemLayered: (req: { name: string }) => Promise = async ({ name }) => + ({ type: 'permission', name, code: null, overlay: null, effective: null }), +) { + let gate: ((ctx: { type: string; name: string; body: unknown }) => Promise) | undefined; + const protocol = { + registerAuthoringGate: (_type: string, g: typeof gate) => { gate = g; }, + getMetaItemLayered, }; - return protocol; + expect(registerPackagedPermissionSetLockGate(protocol, ql)).toBe(true); + return gate!; } -const makeMiddleware = (ql: any, protocol: any) => - createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - -/** Drive one middleware call; reports whether the driver leg would have run. */ -async function run(mw: any, opCtx: any): Promise { - let nextCalled = false; - await mw(opCtx, async () => { nextCalled = true; }); - return nextCalled; -} - -const userCtx = { userId: 'usr_admin' }; - /** A shipped artifact: a body as the SchemaRegistry stamps it (ADR-0010). */ const packagedSet = (over: Record = {}) => ({ name: 'ehr_quality_inspector', @@ -289,365 +81,6 @@ const orgSet = (over: Record = {}) => ({ ...over, }); -/** The row the package door materializes for a shipped artifact. */ -const packagedRow = (over: Record = {}) => ({ - id: 'ps_pkg', - name: 'ehr_quality_inspector', - managed_by: 'package', - package_id: 'com.example.ehr', - active: true, - ...permissionSetRowFields(packagedSet()), - ...over, -}); - -// ───────────────────────────────────────────────────────────────────────────── -// PIN 1 — the silent fork path closes at the server, and the refusal TEACHES -// ───────────────────────────────────────────────────────────────────────────── - -describe('pin 1 — a save targeting a package-declared set is refused, and the message names the clone path', () => { - it('UPDATE through the data door is refused with the clone remedy, and mints NO overlay row', async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - const mw = makeMiddleware(ql, protocol); - - // Rejection-class assertion is the ENVELOPE (`code` + `status`), never a - // bare toThrow: an unfixed door throws nothing at all here, and a stub that - // threw for some other reason would read identical to a real refusal. - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', system_permissions: '["customized"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection, 'the save must be refused, not accepted').not.toBeNull(); - expect(rejection).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - // ⭐ The ruling's whole point: the admin has to learn what to do instead. - expect(String(rejection.message)).toMatch(/clone/i); - expect(String(rejection.message)).toContain('ehr_quality_inspector'); - expect(String(rejection.message)).toContain('com.example.ehr'); - - // Refused, not "refused after writing". - expect(ql.metaRows.length, 'no silent overlay row was minted').toBe(0); - expect(protocol.saves.length, 'the metadata write never even ran').toBe(0); - expect( - JSON.parse(ql.permRows[0].system_permissions), - 'the record still projects the shipped declaration', - ).toEqual(['pkg.baseline']); - }); - - it('INSERT of a name a package already declares is refused the same way (the other overlay-minting door)', async () => { - // Reachable whenever a declaration ships but its record was never - // materialized: the duplicate-name probe finds no row, so the insert would - // otherwise walk straight into `saveMetaItem` on a packaged name and mint - // a fresh overlay of it. - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - const mw = makeMiddleware(ql, protocol); - - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { name: 'ehr_quality_inspector', label: 'Mine', object_permissions: '{}' }, - }).then(() => null, (e: any) => e); - - expect(rejection).not.toBeNull(); - expect(rejection).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(String(rejection.message)).toMatch(/clone/i); - expect(ql.metaRows.length).toBe(0); - expect(ql.permRows.length).toBe(0); - }); - - it('a pure row-state patch (activate/deactivate) is NOT a save of the definition and still passes through', async () => { - // Over-denial guard inside pin 1's own subject: the lifecycle actions send - // `{ active }` and nothing else. Switching a packaged set off is not a - // customization of it (#4669), so the lock must not swallow the column - // write — a lock that refuses the on/off switch has broken the surface it - // was supposed to protect. - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - const mw = makeMiddleware(ql, protocol); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', active: false }, - }); - expect(nextCalled, 'the driver performs the column write with its ordinary semantics').toBe(true); - expect(ql.metaRows.length).toBe(0); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// ⭐ PIN 2 — THE CONTROL. Not optional: pin 1 alone scores green on a door -// that refuses everything. -// ───────────────────────────────────────────────────────────────────────────── - -describe('pin 2 (control) — an ordinary org-owned permission set is STILL accepted', () => { - it('UPDATE of an env-authored set lands in the metadata store and projects onto the record', async () => { - const ql = makeQl([packagedSet()]); // a package IS installed — just not this name - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - await protocol.saveMetaItem({ type: 'permission', name: 'org_support_agent', item: orgSet() }); - const row = ql.permRows.find((r: any) => r.name === 'org_support_agent'); - expect(row, 'precondition: the env-authored row exists').toBeTruthy(); - const savesBefore = protocol.saves.length; - const mw = makeMiddleware(ql, protocol); - - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: row.id, system_permissions: '["support.use","support.escalate"]' }, - }; - const nextCalled = await run(mw, opCtx); - - expect(nextCalled, 'the driver write is skipped — the record is projector-owned').toBe(false); - expect(protocol.saves.length, 'the definition write LANDED').toBe(savesBefore + 1); - expect(JSON.parse(ql.permRows.find((r: any) => r.name === 'org_support_agent').system_permissions)) - .toEqual(['support.use', 'support.escalate']); - expect(opCtx.result?.id).toBe(row.id); - }); - - it('INSERT of a brand-new env-authored set is accepted and lands org-owned', async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - const mw = makeMiddleware(ql, protocol); - - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { - name: 'org_support_agent', label: 'Support Agent', - object_permissions: JSON.stringify({ ticket: { allowRead: true } }), - }, - }; - await run(mw, opCtx); - - expect(protocol.saves.length).toBe(1); - const created = ql.permRows.find((r: any) => r.name === 'org_support_agent'); - expect(created?.managed_by).toBe('admin'); - expect(created?.package_id ?? null).toBeNull(); - }); - - it('a `managed_by:package` row with NO artifact behind it keeps editing in place (the ADR-0094 D5-R surviving tier)', async () => { - // The boundary the lock must NOT cross. This row was materialized by the - // ADR-0086 P2 publish path from a definition that lives only in - // `sys_metadata` (authored + published through the METADATA door, ADR-0070) - // — no code artifact backs the name, so an edit of it is a direct edit of - // the one stored definition and forks nothing. The ruling's subject is the - // package-DECLARED set; a `managed_by` column is measurably not that fact - // (`permission-set-projection.ts` header), and locking on it would take - // the surviving `allowRuntimeCreate` tier down with it. - const ql = makeQl([]); // registry present, nothing declared - const protocol = makeHatchOpenProtocol(ql, {}); // no artifact for the name - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ - id: 'ps_mat', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', - system_permissions: '["materialized.baseline"]', - }); - const mw = makeMiddleware(ql, protocol); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_mat', system_permissions: '["customized"]' }, - }); - expect(nextCalled).toBe(false); - expect(JSON.parse(ql.metaRows[0].metadata).systemPermissions).toEqual(['customized']); - expect(ql.permRows[0].managed_by, 'the package still owns the row').toBe('package'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// PIN 3 — clone-to-customize is the sanctioned path, and it works -// ───────────────────────────────────────────────────────────────────────────── - -describe('pin 3 — the clone path yields an org-owned set with no upgrade linkage, base untouched', () => { - it('cloning a package-declared set produces an admin-owned row and leaves the base byte-identical', async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - const baseBefore = JSON.stringify(ql.permRows[0]); - const mw = makeMiddleware(ql, protocol); - - // Exactly what the `clone_permission_set` action POSTs: a NEW machine name - // plus the base's facets, at /api/v1/data/sys_permission_set. - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { - name: 'ehr_quality_inspector_local', - label: 'Quality Inspector (local)', - active: true, - object_permissions: ql.permRows[0].object_permissions, - field_permissions: ql.permRows[0].field_permissions, - }, - }; - await run(mw, opCtx); - - const clone = ql.permRows.find((r: any) => r.name === 'ehr_quality_inspector_local'); - expect(clone, 'the clone exists').toBeTruthy(); - expect(clone.managed_by, "this org's row").toBe('admin'); - expect(clone.package_id ?? null, 'NO upgrade linkage — upgrades keep flowing to the base').toBeNull(); - expect(JSON.parse(clone.object_permissions), 'the grants came across').toEqual(packagedSet().objects); - - const base = ql.permRows.find((r: any) => r.id === 'ps_pkg'); - expect(JSON.stringify(base), 'the package-declared base is unchanged by the clone').toBe(baseBefore); - expect( - ql.metaRows.some((r: any) => r.name === 'ehr_quality_inspector'), - 'cloning mints no overlay of the base', - ).toBe(false); - }); - - it('the clone is an ordinary set: editing IT is accepted', async () => { - // Without this the "sanctioned path" could be a dead end — a clone that - // exists and then refuses every edit satisfies nothing the ruling asked for. - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - const mw = makeMiddleware(ql, protocol); - await run(mw, { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { name: 'ehr_quality_inspector_local', label: 'Local', object_permissions: '{}' }, - }); - const clone = ql.permRows.find((r: any) => r.name === 'ehr_quality_inspector_local'); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: clone.id, system_permissions: '["local.only"]' }, - }); - expect(nextCalled).toBe(false); - expect(JSON.parse(ql.permRows.find((r: any) => r.id === clone.id).system_permissions)).toEqual(['local.only']); - }); -}); - -// [C3 stage 8] Pin 6 pinned the `clone_permission_set` action declared on the -// retired `sys_permission_set` object; the clone is the security catalog's -// `POST /security/permission-sets/:name/clone` door now, pinned beside it -// (`runtime/src/domains/permission-set-clone.test.ts`). - -describe('pin 5 — provenance that cannot be DETERMINED refuses the save', () => { - it('a SchemaRegistry that throws is not the answer "not package-declared"', async () => { - const ql = makeQl([]); - ql.registry = { listItems: () => { throw new Error('registry unavailable'); } }; - // The layered read cannot answer either — so nothing on this kernel can - // say whether the name is packaged, and accepting would be a guess. - const protocol = makeHatchOpenProtocol(ql, {}); - protocol.getMetaItemLayered = async () => { throw new Error('metadata store unavailable'); }; - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_x', name: 'unknown_provenance', managed_by: 'admin', system_permissions: '[]' }); - const mw = makeMiddleware(ql, protocol); - - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_x', system_permissions: '["whatever"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection, 'an unanswerable provenance read must refuse, never accept').not.toBeNull(); - expect(rejection).toMatchObject({ status: 403 }); - expect(ql.metaRows.length, 'nothing was written on the way to the refusal').toBe(0); - }); - - it('a SchemaRegistry that answers with a non-list is the same ambiguity', async () => { - const ql = makeQl([]); - ql.registry = { listItems: () => undefined as any }; - const protocol = makeHatchOpenProtocol(ql, {}); - protocol.getMetaItemLayered = async () => { throw new Error('metadata store unavailable'); }; - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_x', name: 'unknown_provenance', managed_by: 'admin', system_permissions: '[]' }); - const mw = makeMiddleware(ql, protocol); - - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_x', system_permissions: '["whatever"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection).not.toBeNull(); - expect(rejection).toMatchObject({ status: 403 }); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// ⭐ CONTROLS — the provenance read cannot inherit the fail-open commit e1d773eb7 repaired -// ───────────────────────────────────────────────────────────────────────────── - -describe('control A — #11518 shape: a name carrying MORE THAN ONE row still refuses', () => { - it('positive control: this double truncates exactly the way #11518 describes', async () => { - // Harness-level and stable: it pins the DOUBLE, so it holds before and - // after the fix, and it is what makes the next test meaningful. Without - // it, "the refusal still fires" could be green simply because the trap was - // never armed. (The sibling double in `permission-set-projection.test.ts` - // ignores `limit` entirely — there, this control cannot even be written.) - const ql = makeQl([packagedSet()]); - ql.permRows.push(packagedRow()); - ql.permRows.push(packagedRow({ id: 'ps_pkg_residue', organization_id: null })); - - const names = ['ehr_quality_inspector']; - const capped = await ql.find('sys_permission_set', { - where: { name: { $in: names } }, - limit: names.length, // ← the UNSCOPED cap seed-name-lookup.ts carried before commit e1d773eb7 - }); - expect(ql.permRows.filter((r: any) => r.name === 'ehr_quality_inspector')).toHaveLength(2); - expect(capped, 'the page is truncated — half the rows for this name are invisible').toHaveLength(1); - }); - - it('the refusal fires on a name that carries two rows', async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - ql.permRows.push(packagedRow({ id: 'ps_pkg_residue' })); - const mw = makeMiddleware(ql, protocol); - - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', system_permissions: '["customized"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection, 'a truncating name-keyed oracle would have read "absent" here and ACCEPTED').not.toBeNull(); - expect(rejection).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(ql.metaRows.length).toBe(0); - }); -}); - -describe('control B — the provenance read is not a name-keyed table read at all', () => { - it('every NAME-KEYED page read fails, and the verdict is STILL "package-declared"', async () => { - // The structural proof of immunity. Commit e1d773eb7 repaired a defect of a name-keyed - // page read over `sys_permission_set`; here every such read is made to - // fail outright — the most extreme form of "this read did not answer" — - // while the by-id target resolution keeps working so the middleware still - // reaches the provenance question. The verdict is unchanged, which is - // only possible if the question was never asked of that table. - // - // If the provenance question is ever re-routed through - // `buildExistingByName` (or any other name-keyed page), this test goes RED - // instead of silently inheriting the fail-open. - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - ql.permRows.push(packagedRow()); - // Positive control for the break itself: without this, "the read failed" - // could quietly be "the read was never armed". - ql.breakNameKeyedFind(); - await expect( - ql.find('sys_permission_set', { where: { name: 'ehr_quality_inspector' }, limit: 1 }), - ).rejects.toThrow(/name-keyed page read unavailable/); - expect( - await ql.find('sys_permission_set', { where: { id: 'ps_pkg' }, limit: 1 }), - 'by-id reads still work, so the middleware reaches the provenance question', - ).toHaveLength(1); - - const mw = makeMiddleware(ql, protocol); - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', system_permissions: '["customized"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection, 'a failed name-keyed read must never soften the verdict to "not packaged"').not.toBeNull(); - expect(rejection).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(ql.metaRows.length).toBe(0); - }); -}); - // ───────────────────────────────────────────────────────────────────────────── // [#21789] The lock reads the ROW's provenance, not the presence of a package id // ───────────────────────────────────────────────────────────────────────────── @@ -660,9 +93,7 @@ describe('control B — the provenance read is not a name-keyed table read at al * written over it, and, only where a code artifact ships the name, the * artifact's envelope written over that. These builders produce exactly those * three bodies, so the verdicts below are about the shapes the boot really - * registers, measured on a booted showcase (the dogfood pin - * `permission-set-lock-row-provenance.dogfood.test.ts` asserts the hydrated - * shape before it asserts the edit). + * registers, measured on a booted showcase. */ const RUNTIME_PACKAGE = 'app.crm_workspace'; /** Shape 1: a set saved into a writable runtime package, as hydrated. */ @@ -710,38 +141,12 @@ describe('[#21789] the lock judges "shipped by a code package" from the row\'s p .toEqual({ status: 'packaged', packageId: 'com.example.ehr' }); }); - it('the data door accepts an edit of a runtime-package set, and the edit lands', async () => { - const ql = makeQl([hydratedRuntimePackageRow()]); - const protocol = makeHatchOpenProtocol(ql, {}); - registerPermissionSetProjection(protocol, { ql }); - await protocol.saveMetaItem({ type: 'permission', name: 'workspace_rep', item: orgSet({ name: 'workspace_rep' }) }); - const row = ql.permRows.find((r: any) => r.name === 'workspace_rep'); - expect(row, 'precondition: the projected row exists').toBeTruthy(); - const savesBefore = protocol.saves.length; - - const rejection = await run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: row.id, system_permissions: '["support.use","workspace.only"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection, 'the lock must not refuse a set no code package ships').toBeNull(); - expect(protocol.saves.length, 'the definition write LANDED').toBe(savesBefore + 1); - expect(JSON.parse(ql.permRows.find((r: any) => r.name === 'workspace_rep').system_permissions)) - .toEqual(['support.use', 'workspace.only']); - }); - it('the metadata door accepts a runtime-package set and still refuses a code-shipped one with 403 NOT_OVERRIDABLE', async () => { - const ql = makeQl([hydratedRuntimePackageRow(), shippedArtifact()]); - let gate: ((ctx: { type: string; name: string; body: unknown }) => Promise) | undefined; - const protocol = { - registerAuthoringGate: (_type: string, g: typeof gate) => { gate = g; }, - getMetaItemLayered: async ({ name }: { name: string }) => ({ type: 'permission', name, code: null, overlay: null, effective: null }), - }; - expect(registerPackagedPermissionSetLockGate(protocol, ql)).toBe(true); + const gate = lockGate(makeQl([hydratedRuntimePackageRow(), shippedArtifact()])); - await expect(gate!({ type: 'permission', name: 'workspace_rep', body: orgSet({ name: 'workspace_rep' }) })) + await expect(gate({ type: 'permission', name: 'workspace_rep', body: orgSet({ name: 'workspace_rep' }) })) .resolves.toBeUndefined(); - await expect(gate!({ type: 'permission', name: 'ehr_quality_inspector', body: packagedSet() })) + await expect(gate({ type: 'permission', name: 'ehr_quality_inspector', body: packagedSet() })) .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); }); }); @@ -759,13 +164,8 @@ describe('[#21789] the lock judges "shipped by a code package" from the row\'s p * the whole point of the ruling the lock implements. * * The wire envelope is computed by the producer's own mapping, not restated: - * - * - `mapDataError` is the call the REST `/data` door makes in its catch - * (`PATCH` and `POST /data/:object[/:id]`, `rest-server.ts`) — the door that - * serves Setup's save, measured on a booted showcase as the flat - * `{ error, code, object }` body; - * - `resolveThrownHttpError` is the dispatcher door's resolution of the same - * throw. + * `resolveThrownHttpError` is the dispatcher door's resolution of the throw the + * metadata door's gate raises. * * Every case asserts the envelope (`status` + `code`) AND the `userMessage`: * the code and status are the refusal's identity and must not move, and the @@ -788,106 +188,40 @@ describe('the lock refusal carries its guidance as `userMessage` on the wire env expect(text, 'userMessage must not carry an API path').not.toMatch(/\/api\//); }; - const refusedUpdate = async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { [SET_NAME]: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - return run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', description: 'edit' }, - }).then(() => null, (e: any) => e); - }; - - it('UPDATE at the data door: 403 NOT_OVERRIDABLE with the clone guidance in `userMessage`', async () => { - const rejection = await refusedUpdate(); - expect(rejection).toBeInstanceOf(PackagedPermissionSetLockedError); - - const wire = mapDataError(rejection, 'sys_permission_set'); - expect(wire.status).toBe(403); - expect(wire.body.code).toBe('NOT_OVERRIDABLE'); - expectEndUserCopy(wire.body.userMessage, SET_NAME, PACKAGE_ID, 'sys_permission_set'); - expect(String(wire.body.userMessage)).toMatch(/clone it instead/i); - // The diagnostic channel is untouched: it still names the set and the - // package for logs and developers. The mark never replaces `message`. - expect(String(wire.body.error)).toContain(SET_NAME); - expect(String(wire.body.error)).toContain(PACKAGE_ID); - }); - - it('INSERT of a packaged name at the data door: 403 NOT_OVERRIDABLE with its own guidance (choose another name, or clone)', async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { [SET_NAME]: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - const rejection = await run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { name: SET_NAME, label: 'Mine', object_permissions: '{}' }, - }).then(() => null, (e: any) => e); - expect(rejection).toBeInstanceOf(PackagedPermissionSetLockedError); - - const wire = mapDataError(rejection, 'sys_permission_set'); - expect(wire.status).toBe(403); - expect(wire.body.code).toBe('NOT_OVERRIDABLE'); - expectEndUserCopy(wire.body.userMessage, SET_NAME, PACKAGE_ID, 'sys_permission_set'); - expect(String(wire.body.userMessage)).toMatch(/different name/i); - expect(String(wire.body.userMessage)).toMatch(/clone/i); - - // Each operation carries the guidance that fits it. - const update = mapDataError(await refusedUpdate(), 'sys_permission_set'); - expect(wire.body.userMessage).not.toBe(update.body.userMessage); - }); - - it('the dispatcher door resolves the same throw to the same status, code and `userMessage`', async () => { - const rejection = await refusedUpdate(); - const rest = mapDataError(rejection, 'sys_permission_set'); - const resolved = resolveThrownHttpError(rejection); - expect(resolved.status).toBe(403); - expect(resolved.code).toBe('NOT_OVERRIDABLE'); - expectEndUserCopy(resolved.userMessage, SET_NAME, PACKAGE_ID); - expect(resolved.userMessage).toBe(rest.body.userMessage); - }); - - it('the metadata door\'s registered lock throws the same class with the same `userMessage`', async () => { - const ql = makeQl([shippedArtifact()]); - let gate: ((ctx: { type: string; name: string; body: unknown }) => Promise) | undefined; - const protocol = { - registerAuthoringGate: (_type: string, g: typeof gate) => { gate = g; }, - getMetaItemLayered: async ({ name }: { name: string }) => ({ type: 'permission', name, code: null, overlay: null, effective: null }), - }; - expect(registerPackagedPermissionSetLockGate(protocol, ql)).toBe(true); - - const rejection = await gate!({ type: 'permission', name: SET_NAME, body: packagedSet() }) + it('a save of a package-declared set: 403 NOT_OVERRIDABLE with the clone guidance in `userMessage`', async () => { + const gate = lockGate(makeQl([shippedArtifact()])); + const rejection = await gate({ type: 'permission', name: SET_NAME, body: packagedSet() }) .then(() => null, (e: any) => e); expect(rejection).toBeInstanceOf(PackagedPermissionSetLockedError); + const resolved = resolveThrownHttpError(rejection); expect(resolved.status).toBe(403); expect(resolved.code).toBe('NOT_OVERRIDABLE'); expectEndUserCopy(resolved.userMessage, SET_NAME, PACKAGE_ID); - expect(resolved.userMessage).toBe(mapDataError(await refusedUpdate(), 'sys_permission_set').body.userMessage); + expect(String(resolved.userMessage)).toMatch(/clone it instead/i); + // The diagnostic channel is untouched: it still names the set and the + // package for logs and developers. The mark never replaces `message`. + expect(String(rejection.message)).toContain(SET_NAME); + expect(String(rejection.message)).toContain(PACKAGE_ID); }); it('the fail-closed sibling (provenance unknown): 403 NOT_OVERRIDABLE with retry-or-clone guidance, and no diagnostic reason', async () => { const ql = makeQl([]); ql.registry = { listItems: () => { throw new Error('registry unavailable'); } }; - const protocol = makeHatchOpenProtocol(ql, {}); - protocol.getMetaItemLayered = async () => { throw new Error('metadata store unavailable'); }; - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_x', name: 'unknown_provenance', managed_by: 'admin', system_permissions: '[]' }); - const rejection = await run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_x', system_permissions: '["whatever"]' }, - }).then(() => null, (e: any) => e); + const gate = lockGate(ql, async () => { throw new Error('metadata store unavailable'); }); + const rejection = await gate({ type: 'permission', name: 'unknown_provenance', body: {} }) + .then(() => null, (e: any) => e); expect(rejection).toBeInstanceOf(PackagedPermissionSetProvenanceUnknownError); - const wire = mapDataError(rejection, 'sys_permission_set'); - expect(wire.status).toBe(403); - expect(wire.body.code).toBe('NOT_OVERRIDABLE'); + const resolved = resolveThrownHttpError(rejection); + expect(resolved.status).toBe(403); + expect(resolved.code).toBe('NOT_OVERRIDABLE'); expectEndUserCopy( - wire.body.userMessage, 'unknown_provenance', 'sys_permission_set', - 'registry unavailable', 'metadata store unavailable', + resolved.userMessage, 'unknown_provenance', 'registry unavailable', 'metadata store unavailable', ); - expect(String(wire.body.userMessage)).toMatch(/try again/i); - expect(String(wire.body.userMessage)).toMatch(/clone/i); + expect(String(resolved.userMessage)).toMatch(/try again/i); + expect(String(resolved.userMessage)).toMatch(/clone/i); // The reason stays where it was: the diagnostic channel. - expect(String(wire.body.error)).toContain('registry unavailable'); + expect(String(rejection.message)).toContain('registry unavailable'); }); }); diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts index aecb8940a46..deca1cefeeb 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts @@ -41,7 +41,6 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { permissionSetRowFields } from './permission-set-projection.js'; import { detectPackagedPermissionSetOverlays, reportPackagedPermissionSetOverlays, @@ -89,7 +88,8 @@ const rowFor = (ps: any, over: Record = {}) => ({ name: ps.name, managed_by: 'package', package_id: ps._packageId, - ...permissionSetRowFields(ps), + label: ps.label, + object_permissions: JSON.stringify(ps.objects ?? {}), ...over, }); @@ -269,3 +269,33 @@ describe('pin 4 — the reading is LOUD, and quiet when there is nothing to say' expect(log.warn).not.toHaveBeenCalled(); }); }); + +describe('the overlay sweep reads `sys_metadata` through `tryFind`', () => { + it('a refused overlay read is a reading of nothing — the boot is not failed by a diagnostic', async () => { + // `tryFind`'s contract, pinned at the one door that still calls it: a read + // that throws answers no rows, so the reading reports no fork rather than + // throwing into the boot that runs it. A diagnostic that could fail a boot + // would be worse than the fork it reports. + const a = declaredSet(); + const ql = makeQl([a]); + ql.metaRows.push(overlayFor('ehr_quality_inspector')); + const control = await detectPackagedPermissionSetOverlays(ql); + expect(control.names, 'control: the same overlay is found when the read answers').toEqual(['ehr_quality_inspector']); + + const seen: unknown[] = []; + const refused: any = { + ...ql, + async find(object: string, q: any) { + seen.push({ object, where: q?.where, limit: q?.limit }); + throw new Error('fake driver: sys_metadata unavailable'); + }, + }; + const reading = await detectPackagedPermissionSetOverlays(refused); + expect(reading).toEqual({ count: 0, names: [], findings: [] }); + // Both type spellings were asked, each under the shared page cap. + expect(seen).toEqual([ + { object: 'sys_metadata', where: { type: 'permission', state: 'active' }, limit: 1000 }, + { object: 'sys_metadata', where: { type: 'permissions', state: 'active' }, limit: 1000 }, + ]); + }); +}); diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts index 5197d6e66b2..f5229fbd0de 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts @@ -65,8 +65,23 @@ * silent fork, which is why the LOCK's provenance read is not a page at all. */ -import { tryFind, type ProjectionLogger } from './permission-set-projection.js'; import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; +import { seedCtx } from './organization-scope.js'; + +/** + * One environment-wide page of `object`, read as the system. A read that + * throws answers `[]`: this module is a diagnostic READING run at boot, and a + * reading that could fail the boot would be worse than the fork it reports, so + * a refused read is a reading of nothing. ⛔ Never use this where `[]` is read + * as "gone" — a write door that must tell an outage from an absence asks the + * error's type instead. + */ +async function tryFind(ql: any, object: string, where: any, limit: number): Promise { + try { + const rows = await ql.find(object, { where, limit }, { context: seedCtx() }); + return Array.isArray(rows) ? rows : []; + } catch { return []; } +} /** * Page cap for the `sys_metadata` overlay sweep — the same value @@ -96,7 +111,8 @@ export interface PackagedPermissionSetOverlayReading { } export interface OverlayDetectionOptions { - logger?: ProjectionLogger; + /** Where {@link reportPackagedPermissionSetOverlays} reports a non-empty reading. */ + logger?: { warn: (m: string, meta?: Record) => void }; } /** diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts deleted file mode 100644 index 84c9aef4175..00000000000 --- a/packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts +++ /dev/null @@ -1,470 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * THE RESTORE LEG — the fourth write point of `createPermissionSetWriteThrough` - * under the 2026-08-24 "lock the base, clone to customize" ruling: guarded - * since commit 9cfc1f7e9, with the refusal on the DURABILITY channel, plus the - * reachability fence. - * - * The lock (`packaged-permission-set-lock.ts`) refuses a save that targets a - * package-declared permission set. The `insert` and `update` legs of - * `permission-set-projection.ts` THROW it; this leg cannot — it runs after - * the engine has already un-trashed the row, so a throw would misreport a - * completed engine operation and half-apply a multi-row restore. It consults - * the SAME lock before its one guarded write (the `saveMetaItem` mint), - * refuses the mint, and reports on the durability channel (#4632) through the - * #9754 error→warn chain. The un-trash stands: with no overlay minted, boot - * reconciliation re-projects the DECLARED body onto the record, so the - * environment converges to the package truth rather than to a fork. The full - * argument — why not refuse BEFORE the un-trash (no trashed-row read exists; - * a pre-pass would go green in doubles and resolve nothing on a real engine), - * why not compensate (this middleware's own delete leg guarantees the record - * survives, so a re-trash through the data door cannot happen, and a failed - * compensation makes the refusal a lie) — lives at the leg itself. - * - * ## What this file pins, and what it deliberately does not - * - * It pins six facts, in the order they answer the question: - * - * 1. ⭐ CONTROL — the harness reaches the LOCK, and the lock is what answers. - * An `update` on an artifact-backed name is refused with `NOT_OVERRIDABLE` - * / 403 AND the lock's own message (it names the clone path). The code - * alone would not identify the gate: ADR-0005's tier gate inside - * `saveMetaItem` answers with the same code for the same row, and its - * message instead says the item "is provided by a managed package and is - * sealed" (the double below throws the producer's sentence, ADR-0131 D6's - * managed-content seal). Without this leg, case 2 below could pass for the wrong reason - * — a probe answered by a gate that is not the one under test. - * - * 2. THE LOCK AT THE RESTORE LEG — the same fixture, the same set, through - * `restore`: the engine un-trash runs (the leg stays a post-pass), the - * mint is refused BEFORE the metadata write, no overlay lands, and the - * refusal is REPORTED on the durability channel with the lock's own error - * — never thrown. - * - * This case is the INVERSION commit 1e79aa4f8's MEASURED RESIDUAL demanded of the - * follow-up (commit 9cfc1f7e9): it used to pin the re-authoring of a packaged body - * through this leg with no lock consulted. The deliberate red for the - * inversion was measured for commit 9cfc1f7e9: with the leg's lock consultation - * removed, this case fails on `saves.length` — so it does not pass with - * the lock absent. - * - * 2b. NON-PACKAGED CONTROL — an org-owned set through the same leg is - * re-authored exactly as before the lock arrived. The ruling's scope is - * "packaged sets cannot be re-authored via restore" and nothing else; - * this case is the evidence the extension did not grow past it. - * - * 2c. FAIL-CLOSED — provenance that cannot be answered refuses the mint - * too, reported with the lock's `unknown` error. Accepting on a failed - * read would reopen the hatch-open gap on every transient — the lock's - * three-verdict contract (see its header) applies at every door it - * guards, this one included. - * - * 3. THE FENCE — why the trashed-packaged-row scenario stays remote: the write-through's - * `delete` leg cannot put a package-declared set into a restorable state. - * An artifact-backed definition tombstones its overlay (an ADR-0005 RESET) - * and the record re-projects to the shipped body; the driver delete never - * runs, so there is no trashed row for a `restore` to act on. - * - * 4. THE BLIND SPOT in the gate that WOULD pre-empt it. `security-plugin.ts`'s - * `assertPackageManagedWriteGate` runs OUTSIDE this middleware and admits - * `restore` into its package-row refusal — but it keys on the `managed_by` - * COLUMN, and the lock's own header records that column as measurably the - * wrong fact. On the `provenance_skip` shape (`permission-set-drift.ts`) a - * genuinely package-declared set carries `managed_by` other than - * `'package'`, so the outer gate does not fire and the unlocked leg is - * what the row reaches. Pinned through the exported classifier, which is - * the fact the lock reads. - * - * ⛔ NOT re-pinned here, on purpose (Prime Directive #8 — one spelling): - * - that `restore` is not an engine middleware dispatch verb. That is - * `packages/objectql/src/engine-middleware-operation-vocabulary.test.ts` - * (#7809), which welds the 7-member union to `engine.ts` source AND drives - * a real engine through every public method. A second copy here would be a - * claim about this file rather than about the engine. - * - that `restore` never derives as an API operation. That is - * `packages/spec/src/data/api-derivation.test.ts` against - * `API_METHOD_DERIVATION.restore.flag`, permanently `false` since - * `enable.trash` was retired (#2377 / ADR-0049); a real recycle bin is - * parked at #3146. - * - * Those two are what make this leg unreachable TODAY. The day either goes - * red — #3146 landing a real recycle bin is the expected way — the lock - * pinned by case 2 is what stands between a trashed packaged row and a - * silent fork, and this file is the proof it was already in place. - */ - -import { describe, it, expect } from 'vitest'; -import { assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; -import { PermissionSetSchema } from '@objectstack/spec/security'; -import { createPermissionSetWriteThrough } from './permission-set-projection.js'; -import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; - -/** The package-declared body every case in this file turns on. */ -const declaredBody = () => ({ - name: 'crm_rep', - label: 'CRM Representative', - objects: { crm_lead: { allowRead: true } }, - fields: {}, - systemPermissions: ['pkg.baseline'], - rowLevelSecurity: [], - tabPermissions: {}, - _packageId: 'com.example.crm', -}); - -/** - * A `sys_permission_set` row projected from that declaration. - * - * `managed_by` is a parameter because the two gates in play read DIFFERENT - * facts about the same row: the outer two-doors gate reads this column, the - * lock reads the artifact registry. Case 4 is about exactly that difference. - */ -const packagedRow = (managedBy: string) => ({ - id: 'ps_pkg', - name: 'crm_rep', - label: 'CRM Representative', - managed_by: managedBy, - package_id: 'com.example.crm', - object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }), - field_permissions: JSON.stringify({}), - system_permissions: JSON.stringify(['pkg.baseline']), - row_level_security: JSON.stringify([]), - tab_permissions: JSON.stringify({}), -}); - -/** - * Minimal engine double over `sys_permission_set` + the env overlay store. - * - * `registry.listItems('permission')` is the source the lock reads — an - * in-memory array, which is the whole reason the lock uses it (no page, no - * cap). Seeding it IS seeding "this name is package-declared". - */ -function makeQl(rows: any[], declared: any[]) { - const permRows = [...rows]; - const overlays: any[] = []; - return { - permRows, - overlays, - /** True once the DRIVER delete actually ran (i.e. the row was removed). */ - driverDeleted: false, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - // REFUSES what it does not implement, rather than quietly matching - // nothing: a double that answered `[]` for an operator it never learned - // would report "no such row", and every case here would still pass while - // measuring the double instead of the middleware. - return permRows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`); - if (v && typeof v === 'object') throw new Error(`fake engine: unsupported operand for ${k}`); - return r[k] === v; - }), - ); - }, - async findOne(object: string, q: any) { - assertEngineFindOnePredicate(object, q); - return (await this.find(object, q))[0] ?? null; - }, - // Opens with the PRODUCER's own dispatch predicate, never a hand-mirrored - // guard: a fixture that drifts to a call shape `ObjectQL` would refuse must - // fail loudly here rather than collect a green the real engine would not - // have given. `tryUpdate` in the subject SWALLOWS a throw, so a double - // looser than the engine would be invisible twice over. - async update(object: string, data: any, options?: any) { - const dispatch = assertEngineUpdateDispatch(data, options); - const targets = dispatch.kind === 'by-id' - ? permRows.filter((r) => r.id === dispatch.id) - : await this.find(object, options); - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - async insert(_object: string, data: any) { - permRows.push({ ...data }); - return { id: data.id }; - }, - }; -} - -/** - * Metadata protocol double. - * - * `tierGate: 'open'` models the documented `OS_METADATA_WRITABLE=permission` - * operator hatch — the deployment shape this whole family of cards came from, - * where the producer's own ADR-0005 refusal is switched off and the write-door - * lock is the ONLY thing left. `tierGate: 'closed'` is the default posture. - * - * The real `PermissionSetSchema` runs on every accepted save, exactly as - * `saveMetaItem` runs it, so a body this double accepts is a body production - * would accept too. - */ -function makeProtocol(ql: any, declaredNames: string[], tierGate: 'open' | 'closed') { - const codeFor = (name: string) => - declaredNames.includes(name) ? declaredBody() : null; - return { - saves: [] as any[], - deletes: [] as any[], - async saveMetaItem(req: { type: string; name: string; item: any }) { - if (tierGate === 'closed' && declaredNames.includes(req.name)) { - const err: any = new Error( - `Metadata item 'permission/${req.name}' is provided by a managed package and is sealed ` - + 'against in-place edits. Clone it under a new name to customize it (the "Clone" action on ' - + 'the permission set, or POST /api/v1/data/sys_permission_set with a new name). ' - + 'See docs/adr/0126-packaged-metadata-customization-model.md.', - ); - err.code = 'NOT_OVERRIDABLE'; - err.status = 403; - throw err; - } - const parsed = PermissionSetSchema.safeParse(req.item); - if (!parsed.success) { - const err: any = new Error( - `[invalid_metadata] permission/${req.name} failed spec validation: ` - + parsed.error.issues.map((i: any) => `${i.path.join('.') || ''}: ${i.message}`).join('; '), - ); - err.code = 'INVALID_METADATA'; - err.status = 422; - throw err; - } - ql.overlays.push({ name: req.name, item: req.item }); - this.saves.push({ ...req }); - return { success: true }; - }, - async deleteMetaItem(req: { type: string; name: string }) { - const i = ql.overlays.findIndex((o: any) => o.name === req.name); - if (i >= 0) ql.overlays.splice(i, 1); - this.deletes.push({ ...req }); - // Artifact-backed: the overlay is tombstoned and the definition RESETS - // to the shipped body — the record is never removed. `reset: true` is - // the real protocol's own answer for this case. - return { success: true, reset: declaredNames.includes(req.name) }; - }, - async getMetaItemLayered(req: { type: string; name: string }) { - const code = codeFor(req.name); - const overlay = ql.overlays.find((o: any) => o.name === req.name)?.item ?? null; - return { - type: 'permission', - name: req.name, - code, - overlay, - overlayScope: overlay ? 'env' : null, - effective: overlay ?? code, - }; - }, - }; -} - -/** Run the middleware, reporting whether the engine's own write (`next`) ran. */ -async function run(mw: any, opCtx: any): Promise { - let nextCalled = false; - await mw(opCtx, async () => { nextCalled = true; }); - return nextCalled; -} - -const userCtx = { userId: 'usr_admin' }; - -describe('[#11725] the restore leg of the permission-set write-through', () => { - it('CONTROL: an UPDATE on the same set is refused by the #11702 LOCK — named by its message, not just its code', async () => { - // The gate identification this whole file rests on. `NOT_OVERRIDABLE`/403 - // alone does NOT name a gate: ADR-0005's tier gate answers with the same - // envelope for the same row. The lock is distinguished by its MESSAGE, - // which teaches the sanctioned path — and by `saves.length === 0`, which - // says the refusal happened BEFORE the metadata write rather than inside - // it. Both halves are asserted, because either alone is ambiguous. - const ql = makeQl([packagedRow('package')], [declaredBody()]); - // Hatch OPEN: the producer's tier gate is switched off, so anything that - // refuses here can only be the write-door lock. - const protocol = makeProtocol(ql, ['crm_rep'], 'open'); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - let caught: any; - try { - await run(mw, { - object: 'sys_permission_set', - operation: 'update', - data: { id: 'ps_pkg', label: 'hijacked' }, - context: userCtx, - }); - } catch (e) { caught = e; } - - expect(caught, 'the update was refused').toBeDefined(); - expect(caught).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(caught.name).toBe('PackagedPermissionSetLockedError'); - // The wording IS the contract here (it is what separates this refusal from - // the tier gate's), so it is asserted on top of the envelope, never instead. - expect(caught.message).toContain("is declared by package 'com.example.crm'"); - expect(caught.message).toContain('clone'); - expect(protocol.saves.length, 'refused BEFORE the metadata write').toBe(0); - expect(ql.overlays.length, 'no overlay of a packaged set was minted').toBe(0); - }); - - it('LOCK AT THE RESTORE LEG: the same set through RESTORE keeps the engine un-trash but the re-author is REFUSED — reported on the durability channel, never thrown', async () => { - // ⭐ The INVERSION of commit 1e79aa4f8's MEASURED RESIDUAL, demanded by that case's - // own comment and delivered by commit 9cfc1f7e9. It used to assert: no refusal, one - // save, the packaged body in the overlay store. Now the lock is consulted - // before the leg's one guarded write, and every half inverts. - // - // Identical fixture to the CONTROL above — same ql, same registry, same - // protocol posture, same row. The ONLY difference is the operation, so the - // difference in outcome is attributable to the leg and nothing else. The - // hatch is OPEN, so anything that refuses can only be the write-door lock. - const ql = makeQl([packagedRow('package')], [declaredBody()]); - const protocol = makeProtocol(ql, ['crm_rep'], 'open'); - const errors: any[] = []; - const mw = createPermissionSetWriteThrough({ - ql, - getProtocol: () => protocol, - logger: { error: (m: string, e?: Error) => errors.push({ m, e }), info: () => {}, warn: () => {} }, - }); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', - operation: 'restore', - options: { where: { id: 'ps_pkg' } }, - context: userCtx, - }); - - // The engine operation is NOT misreported: the leg stays a post-pass and - // the caller of a completed restore hears success, not a throw. - expect(nextCalled, 'the engine un-trash runs first — the leg is a post-pass').toBe(true); - - // The refusal is LOUD, and it is the LOCK's — same gate identification as - // the CONTROL: the error names the package and teaches the clone path, - // which ADR-0005's tier gate (the only other 403 on this row) does not. - expect(errors.length, 'the refusal reached the durability channel').toBe(1); - expect(errors[0].e).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(errors[0].e.name).toBe('PackagedPermissionSetLockedError'); - expect(errors[0].e.message).toContain("is declared by package 'com.example.crm'"); - expect(errors[0].e.message).toContain('clone'); - // The leg's own report is the REFUSAL wording, not the failed-write one - // (#5240 — one condition, one wording): it says the skip was deliberate. - expect(errors[0].m).toContain('deliberately NOT re-authored'); - expect(errors[0].m).toContain('clone'); - - // And the mint never happened: refused BEFORE the metadata write, so no - // overlay of a package-declared set exists to win over upgrades. - expect(protocol.saves.length, 'refused BEFORE the metadata write').toBe(0); - expect(ql.overlays, 'no overlay of a packaged set was minted').toEqual([]); - }); - - it('NON-PACKAGED CONTROL: an org-owned set through RESTORE is re-authored exactly as before — the lock changes nothing for it', async () => { - // The ruled scope is "packaged sets cannot be re-authored via restore" - // and NOTHING else — this case is the evidence the extension did not grow - // past it. Same registry as above (crm_rep IS declared), so the classifier - // is answering `org` for THIS name, not `org` for everything. - const orgRow = { - id: 'ps_org', - name: 'my_own_set', - label: 'My Own Set', - managed_by: 'user', - object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }), - field_permissions: JSON.stringify({}), - system_permissions: JSON.stringify([]), - row_level_security: JSON.stringify([]), - tab_permissions: JSON.stringify({}), - }; - const ql = makeQl([orgRow], [declaredBody()]); - const protocol = makeProtocol(ql, ['crm_rep'], 'open'); - const errors: any[] = []; - const mw = createPermissionSetWriteThrough({ - ql, - getProtocol: () => protocol, - logger: { error: (m: string, e?: Error) => errors.push({ m, e }), info: () => {}, warn: () => {} }, - }); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', - operation: 'restore', - options: { where: { id: 'ps_org' } }, - context: userCtx, - }); - - expect(nextCalled, 'the engine un-trash runs').toBe(true); - expect(errors, 'nothing was refused, nothing was reported').toEqual([]); - expect(protocol.saves.length, 'the org-owned body IS re-authored, as always').toBe(1); - expect(protocol.saves[0].name).toBe('my_own_set'); - expect(ql.overlays.map((o: any) => o.name)).toEqual(['my_own_set']); - }); - - it('FAIL-CLOSED: provenance that cannot be answered refuses the re-author too — reported with the lock\'s unknown error, never guessed', async () => { - // Both artifact sources are broken: the registry read throws and the - // layered probe fails. The lock's three-verdict contract (its header says - // why accepting on `unknown` is the one guess a write door must not make) - // applies at this door exactly as at insert/update — except the refusal - // is reported, not thrown, like every refusal on this leg. - const ql = makeQl([packagedRow('package')], [declaredBody()]); - (ql as any).registry = { listItems: () => { throw new Error('registry offline'); } }; - const protocol = makeProtocol(ql, ['crm_rep'], 'open'); - (protocol as any).getMetaItemLayered = async () => { throw new Error('metadata layer unreadable'); }; - const errors: any[] = []; - const mw = createPermissionSetWriteThrough({ - ql, - getProtocol: () => protocol, - logger: { error: (m: string, e?: Error) => errors.push({ m, e }), info: () => {}, warn: () => {} }, - }); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', - operation: 'restore', - options: { where: { id: 'ps_pkg' } }, - context: userCtx, - }); - - expect(nextCalled, 'the engine un-trash runs').toBe(true); - expect(errors.length, 'the fail-closed refusal reached the durability channel').toBe(1); - expect(errors[0].e).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(errors[0].e.name).toBe('PackagedPermissionSetProvenanceUnknownError'); - expect(protocol.saves.length, 'no mint on a guess').toBe(0); - expect(ql.overlays).toEqual([]); - }); - - it('FENCE: DELETE cannot produce a restorable row — the packaged definition RESETS and the driver delete never runs', async () => { - // Why a trashed packaged row cannot arise through this door: there is - // nothing to restore. The write-through owns the delete, tombstones the - // overlay, and returns WITHOUT calling `next()` — so the engine never - // removes (or trashes) the record. - const ql = makeQl([packagedRow('package')], [declaredBody()]); - const protocol = makeProtocol(ql, ['crm_rep'], 'open'); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', - operation: 'delete', - options: { where: { id: 'ps_pkg' } }, - context: userCtx, - }); - - expect(nextCalled, 'the driver delete is never reached').toBe(false); - expect(protocol.deletes.length, 'the overlay tombstone IS the delete').toBe(1); - expect(protocol.deletes[0]).toMatchObject({ type: 'permission', name: 'crm_rep' }); - expect(ql.permRows.length, 'the record survives — a packaged set cannot be removed').toBe(1); - expect(ql.permRows[0].name).toBe('crm_rep'); - }); - - it('BLIND SPOT: the outer two-doors gate keys on `managed_by`, which the lock records as the WRONG fact', async () => { - // `assertPackageManagedWriteGate` (security-plugin.ts) admits `restore` - // into its package-row refusal and would therefore pre-empt this leg — but - // only for rows whose `managed_by` column reads `'package'`. The - // `provenance_skip` shape is a genuinely package-declared set whose column - // does not, and on that row the outer gate returns without refusing while - // the lock's own fact says `packaged`. That disagreement is the reason - // "the outer gate already covers it" is not an answer. - const declared = [declaredBody()]; - const provenanceSkipRow = packagedRow('user'); // the drift-report shape - const ql = makeQl([provenanceSkipRow], declared); - - // The lock's fact, read from the artifact registry the lock actually uses. - expect(classifyPackagedPermissionSet('crm_rep', ql)).toEqual({ - status: 'packaged', - packageId: 'com.example.crm', - }); - // The outer gate's fact, on the very same row. - expect(provenanceSkipRow.managed_by, 'the column the outer gate reads').not.toBe('package'); - - // Control: the classifier is not simply answering `packaged` for - // everything — a name the registry does not declare comes back `org`, - // which is what makes the assertion above evidence of anything. - expect(classifyPackagedPermissionSet('my_own_set', ql)).toEqual({ status: 'org' }); - }); -}); diff --git a/packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts b/packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts deleted file mode 100644 index 22a456a8021..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit 8f6d83147] THE DUPLICATE-NAME REFUSAL on `sys_permission_set` — its ADR-0112 - * envelope, and the ORDER it stands in relative to the packaged-set lock. - * - * Two halves of one defect, both measured live on `examples/app-showcase` - * before the fix (seeded admin, cookie session): - * - * 1. the refusal was a bare `Error` carrying `.status = 409` and NO `.code`, - * so the flat `{ error, code }` responder — which invents nothing for a - * producer that declared nothing — put prose on the wire: - * `409 {"error":"[Security] permission set 'showcase_manager' already - * exists","object":"sys_permission_set"}`. ADR-0112's 2026-08-17 amendment - * (#9232) closed `error.code` at the flat door too, so a 409 with no code - * is that contract unhonoured, and a dialog that must branch on the - * refusal is pushed to string-matching; - * - * 2. ⭐ it ran BEFORE `assertPermissionSetNotPackageDeclared`. A - * package-declared set HAS a projected row, so its name is duplicate and - * locked at once — and the admin who opens the Clone dialog on a packaged - * set and types the base set's own name (the single most likely thing to - * type) got the duplicate refusal and never reached `NOT_OVERRIDABLE`, - * the refusal that explains the actual situation and names the remedy. - * - * ## What each case is for - * - * 1. CONTROL — the ordinary (non-packaged) duplicate still refuses, and the - * refusal now carries the closed member. Asserting the ENVELOPE (`code` + - * `status`), never a bare "it threw": the unfixed producer threw too. - * 2. ⭐ THE ORDERING — same middleware, same duplicate row, but the name is - * package-declared: the answer is the lock's `NOT_OVERRIDABLE` / 403 - * carrying the lock's OWN message. The code alone would not identify the - * gate — ADR-0005's tier gate inside `saveMetaItem` answers 403 - * `NOT_OVERRIDABLE` for the same row — so the message and `saves.length` - * are what prove the LOCK answered, ahead of any metadata write. - * 3. NEGATIVE CONTROL — the reorder did not make every duplicate answer - * `NOT_OVERRIDABLE`: case 1's refusal is asserted to be NOT that code. - * (Stated as its own case because that is the regression the ordering - * change could plausibly introduce.) - * 4. HAPPY PATH — a free name on an org-verdict kernel still lands. Running - * the lock first must not cost a create. - * 5. FAIL-CLOSED, DECLARED — an ordinary duplicate attempted while NO - * artifact source can answer takes the lock's `unknown` refusal (403) - * instead of the 409. That case MOVED with the reorder; it is pinned so - * the behaviour is declared rather than incidental. Both answers are - * refusals and neither writes. - */ - -import { describe, it, expect } from 'vitest'; -import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { PermissionSetSchema } from '@objectstack/spec/security'; -import { createPermissionSetWriteThrough } from './permission-set-projection.js'; -import { - PERMISSION_SET_NAME_CONFLICT_CODE, - PERMISSION_SET_NAME_CONFLICT_STATUS, -} from './errors.js'; - -/** The package-declared body case 2 turns on. */ -const declaredBody = () => ({ - name: 'showcase_manager', - label: 'Showcase Manager', - objects: { showcase_task: { allowRead: true } }, - fields: {}, - systemPermissions: ['showcase.manage'], - rowLevelSecurity: [], - tabPermissions: {}, - _packageId: 'com.example.showcase', -}); - -/** A `sys_permission_set` row that already holds `name`. */ -const rowFor = (name: string, over: Record = {}) => ({ - id: `ps_${name}`, - name, - label: name, - managed_by: 'admin', - object_permissions: JSON.stringify({}), - field_permissions: JSON.stringify({}), - system_permissions: JSON.stringify([]), - row_level_security: JSON.stringify([]), - tab_permissions: JSON.stringify({}), - ...over, -}); - -/** - * Minimal engine double. - * - * `registry.listItems('permission')` is the artifact source the lock reads, so - * seeding it IS seeding "this name is package-declared". It REFUSES query - * shapes it does not implement rather than answering `[]`, which would report - * "no such row" and let every case pass while measuring the double — and it - * holds the caller's `limit` and opens `update` with the producer's own - * dispatch predicate, so a fake looser than `ObjectQL` cannot collect a green - * the real engine would not have given. - */ -function makeQl(rows: any[], declared: any[]) { - const permRows = [...rows]; - return { - permRows, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - const matched = permRows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`); - if (v && typeof v === 'object') throw new Error(`fake engine: unsupported operand for ${k}`); - return r[k] === v; - }), - ); - // The caller's bound, applied AFTER the filter and by PRESENCE — the - // duplicate pre-check asks for `limit: 1`, and a limit-blind double - // would be answering a different question than the engine does. - return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; - }, - async findOne(object: string, q: any) { - assertEngineFindOnePredicate(object, q); - return (await this.find(object, q))[0] ?? null; - }, - async insert(_object: string, data: any) { - permRows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any, options?: any) { - const dispatch = assertEngineUpdateDispatch(data, options); - const targets = dispatch.kind === 'by-id' - ? permRows.filter((r) => r.id === dispatch.id) - : await this.find(object, options); - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - }; -} - -/** - * Metadata protocol double. The real `PermissionSetSchema` runs on every - * accepted save, exactly as `saveMetaItem` does. - * - * `layeredThrows` models a metadata layer that cannot answer — case 5's - * `unknown` verdict, which is a READ failure and not an empty answer. - */ -function makeProtocol(ql: any, declaredNames: string[], opts: { layeredThrows?: boolean } = {}) { - return { - saves: [] as any[], - projected: [] as string[], - registerMutationProjector(_type: string, _fn: any) { /* not exercised here */ }, - async saveMetaItem(req: { type: string; name: string; item: any }) { - const parsed = PermissionSetSchema.safeParse(req.item); - if (!parsed.success) { - const err: any = new Error( - `[invalid_metadata] permission/${req.name} failed spec validation: ` - + parsed.error.issues.map((i: any) => `${i.path.join('.') || ''}: ${i.message}`).join('; '), - ); - err.code = 'INVALID_METADATA'; - err.status = 422; - throw err; - } - this.saves.push({ ...req }); - ql.permRows.push(rowFor(req.name)); - return { success: true }; - }, - async deleteMetaItem() { return { success: true }; }, - async getMetaItemLayered(req: { type: string; name: string }) { - if (opts.layeredThrows) throw new Error('metadata store unreachable'); - const code = declaredNames.includes(req.name) ? declaredBody() : null; - return { type: 'permission', name: req.name, code, overlay: null, overlayScope: null, effective: code }; - }, - }; -} - -/** Run the middleware, reporting whether the engine's own write (`next`) ran. */ -async function run(mw: any, opCtx: any): Promise { - let nextCalled = false; - await mw(opCtx, async () => { nextCalled = true; }); - return nextCalled; -} - -const insertCtx = (name: string) => ({ - object: 'sys_permission_set', - operation: 'insert', - context: { userId: 'usr_admin' }, - data: { name, label: 'Clone of the base' }, -}); - -/** The thrown value, or a loud failure — `rejects` alone reads a bare throw as a pass. */ -async function refusalOf(fn: () => Promise): Promise { - try { - await fn(); - } catch (e) { - return e; - } - throw new Error('expected the insert to be REFUSED; it was accepted'); -} - -describe('[#19307] duplicate-name refusal on sys_permission_set', () => { - it('1. CONTROL — an ORDINARY duplicate is refused with the closed member on the envelope', async () => { - const ql = makeQl([rowFor('org_owned_set')], []); - const protocol = makeProtocol(ql, []); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const err = await refusalOf(() => run(mw, insertCtx('org_owned_set'))); - - // The envelope, not the throw: `status` AND the machine-readable `code`, - // which is what the flat door puts on the wire (#9232). - expect(err.code).toBe(PERMISSION_SET_NAME_CONFLICT_CODE); - expect(err.code).toBe('UNIQUE_VIOLATION'); - expect(err.status).toBe(PERMISSION_SET_NAME_CONFLICT_STATUS); - expect(err.status).toBe(409); - // Both spellings: the two transports read different property names. - expect(err.statusCode).toBe(409); - expect(err.name).toBe('PermissionSetNameConflictError'); - expect(err.message).toContain("permission set 'org_owned_set' already exists"); - // Refused BEFORE the write, on both stores. - expect(protocol.saves.length).toBe(0); - expect(ql.permRows.length).toBe(1); - }); - - it('2. ⭐ ORDERING — a duplicate that is ALSO package-declared answers the LOCK, not the conflict', async () => { - // The Clone-dialog case: the base set is declared by a package AND has its - // projected row, so both refusals are true at once. - const ql = makeQl([rowFor('showcase_manager', { managed_by: 'package', package_id: 'com.example.showcase' })], - [declaredBody()]); - const protocol = makeProtocol(ql, ['showcase_manager']); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const err = await refusalOf(() => run(mw, insertCtx('showcase_manager'))); - - expect(err.code).toBe('NOT_OVERRIDABLE'); - expect(err.status).toBe(403); - expect(err.statusCode).toBe(403); - // ⭐ The LOCK answered, not ADR-0005's tier gate inside `saveMetaItem` - // (same code, different message) and not the duplicate check: the lock's - // message is the only one that names the clone path. - expect(err.name).toBe('PackagedPermissionSetLockedError'); - expect(err.message).toContain("declared by package 'com.example.showcase'"); - expect(err.message).toContain('clone'); - expect(err.message).not.toContain('already exists'); - // Nothing was written, and the metadata door was never reached. - expect(protocol.saves.length).toBe(0); - }); - - it('3. NEGATIVE CONTROL — the reorder did NOT turn ordinary duplicates into NOT_OVERRIDABLE', async () => { - const ql = makeQl([rowFor('org_owned_set')], [declaredBody()]); // a packaged set exists, under ANOTHER name - const protocol = makeProtocol(ql, ['showcase_manager']); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const err = await refusalOf(() => run(mw, insertCtx('org_owned_set'))); - - expect(err.code).not.toBe('NOT_OVERRIDABLE'); - expect(err.code).toBe('UNIQUE_VIOLATION'); - expect(err.status).toBe(409); - }); - - it('4. HAPPY PATH — a free name still lands with the lock consulted first', async () => { - const ql = makeQl([rowFor('org_owned_set')], [declaredBody()]); - const protocol = makeProtocol(ql, ['showcase_manager']); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const nextCalled = await run(mw, insertCtx('my_new_set')); - - expect(nextCalled).toBe(false); // projector-owned record; no driver write - expect(protocol.saves.map((s) => s.name)).toEqual(['my_new_set']); - }); - - it('5. FAIL-CLOSED, DECLARED — a duplicate whose provenance cannot be resolved takes the lock refusal', async () => { - // No registry AND a layered read that throws ⇒ no source answered. - const ql: any = makeQl([rowFor('org_owned_set')], []); - delete ql.registry; - const protocol = makeProtocol(ql, [], { layeredThrows: true }); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const err = await refusalOf(() => run(mw, insertCtx('org_owned_set'))); - - expect(err.code).toBe('NOT_OVERRIDABLE'); - expect(err.status).toBe(403); - expect(err.name).toBe('PackagedPermissionSetProvenanceUnknownError'); - expect(protocol.saves.length).toBe(0); - }); -}); diff --git a/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts b/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts deleted file mode 100644 index 79503892e83..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#22169] The boot heal converges on a table holding DUPLICATE rows of a name. - * - * `reconcilePermissionSetProjection` pages `sys_permission_set` and heals every - * overlay-less, env-authored row that drifted from its declared body. It used - * to hand each drifted row to a by-NAME upsert, which re-read "the" row as the - * first one of that name (lowest id) — so with two or more rows per name only - * that one was ever written, and every later row was warned about as - * "re-projected" and left drifted, on every boot. Measured on a real engine - * (8 names × 3 rows): 16 of 24 rows still drifted and 16 drift warns on boot 2, - * 3, … . The same by-name read had an INSERT branch that a refused read reached - * (`tryFind` answers `[]` for a throw), minting a duplicate of a row the loop - * was holding. And a layered metadata read (four serial statements on the real - * protocol) was awaited for every candidate row although a readable - * SchemaRegistry makes the trust rule ignore it. - * - * ## The harness, and why each piece is real - * - * A REAL `ObjectQL` over a REAL `SqlDriver` (better-sqlite3 `:memory:`), both - * aliased to source by this package's `vitest.config.ts`. The by-name read the - * defect lives in is the engine's own `ORDER BY id LIMIT 1`, and the heal's - * write is the engine's own update by id — a fake table would answer for its - * author instead. The per-organization unique index is DROPPED after schema - * sync: that is the shape of a remote table built before the index retrofit, - * the only shape that can hold duplicates, and a table that already holds them - * can never build the index again. - * - * The engine is SPIED, never replaced: each spied verb counts and then runs - * the real method, so no double stands between the heal and the engine's own - * dispatch contract. The protocol is a counting stub — what is pinned about it - * is whether it is CALLED, which a stub answers exactly. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; - -import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; -import { - permissionSetRowFields, - reconcilePermissionSetProjection, - recordDiffersFromBody, -} from './permission-set-projection.js'; - -const SYS = { context: { isSystem: true } } as any; - -/** Just enough `sys_metadata` for the overlay pass to read an empty table. */ -const SYS_METADATA_OBJECT: any = { - name: 'sys_metadata', - label: 'System Metadata', - fields: { - id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, - type: { name: 'type', label: 'Type', type: 'text', required: true }, - name: { name: 'name', label: 'Name', type: 'text', required: true }, - organization_id: { name: 'organization_id', label: 'Org', type: 'text' }, - package_id: { name: 'package_id', label: 'Package', type: 'text' }, - metadata: { name: 'metadata', label: 'Body', type: 'textarea' }, - state: { name: 'state', label: 'State', type: 'text' }, - }, -}; - -const declaredBody = (name: string) => ({ - name, - label: `Declared ${name}`, - objects: { crm_lead: { allowRead: true } }, - systemPermissions: ['declared.baseline'], -}); - -/** A row of `name` whose facets drifted from the declaration. */ -const driftedRow = (id: string, name: string) => ({ - id, - name, - managed_by: 'admin', - active: true, - ...permissionSetRowFields(declaredBody(name)), - system_permissions: '["drifted.edit"]', -}); - -const engines: ObjectQL[] = []; -afterEach(async () => { - vi.restoreAllMocks(); - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -async function boot(declaredNames: string[], rows: Array>): Promise { - const engine = new ObjectQL(); - const driver = new SqlDriver({ - client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, - }); - engine.registerDriver(driver, true); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.heal-by-id-22169', - name: 'Heal by id', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [SysPermissionSet, SYS_METADATA_OBJECT], - } as any); - await engine.syncSchemas(); - engines.push(engine); - await (driver as any).getKnex().raw('DROP INDEX IF EXISTS uniq_sys_permission_set_organization_id_name'); - for (const name of declaredNames) { - engine.registry.registerItem('permission', declaredBody(name), 'name', 'com.example.pkg'); - } - for (const row of rows) await (engine as any).insert('sys_permission_set', row, SYS); - return engine; -} - -/** - * The real engine with its verbs SPIED — every call still runs the real method, - * so the engine's own dispatch contract answers it; the spies only count and, - * when `refuseNameReads` is set, make a by-name read of the table throw. - * - * `withRegistry: false` hands the heal the same engine with `registry` hidden, - * the shape of a kernel whose SchemaRegistry is not readable — the one shape - * whose trust rule still reads the layered item. - */ -function observe(engine: any, opts: { withRegistry?: boolean } = {}) { - const log = { updatedIds: [] as string[], inserts: 0 }; - const state = { refuseNameReads: false }; - const realFind = engine.find.bind(engine); - const realInsert = engine.insert.bind(engine); - const realUpdate = engine.update.bind(engine); - vi.spyOn(engine, 'find').mockImplementation(async (o: any, q?: any, opt?: any) => { - const byName = o === 'sys_permission_set' && q?.where && typeof q.where === 'object' && 'name' in q.where; - if (byName && state.refuseNameReads) throw new Error('fake outage: the by-name read was refused'); - return realFind(o, q, opt); - }); - vi.spyOn(engine, 'insert').mockImplementation(async (o: any, d: any, opt?: any) => { - if (o === 'sys_permission_set') log.inserts += 1; - return realInsert(o, d, opt); - }); - vi.spyOn(engine, 'update').mockImplementation(async (o: any, d: any, opt?: any) => { - if (o === 'sys_permission_set') log.updatedIds.push(String(d?.id)); - return realUpdate(o, d, opt); - }); - const ql = opts.withRegistry === false - ? new Proxy(engine, { - get(target, prop) { - if (prop === 'registry') return undefined; - const value = Reflect.get(target, prop, target); - return typeof value === 'function' ? value.bind(target) : value; - }, - }) - : engine; - return { ql, log, state }; -} - -/** A protocol that answers the layered read from the declarations and counts every call. */ -function countingProtocol(declaredNames: string[]) { - const layeredReads: string[] = []; - return { - layeredReads, - async getMetaItemLayered(req: { type: string; name: string }) { - layeredReads.push(req.name); - const code = declaredNames.includes(req.name) ? declaredBody(req.name) : null; - return { type: 'permission', name: req.name, code, overlay: null, effective: code }; - }, - }; -} - -function captureWarns() { - const warns: Array<{ msg: string; meta?: any }> = []; - return { - warns, - driftWarns: () => warns.filter((w) => w.msg.includes('drifted from its metadata definition')), - logger: { info: () => {}, warn: (msg: string, meta?: any) => warns.push({ msg, meta }) }, - }; -} - -async function allRows(engine: ObjectQL): Promise { - return (await (engine as any).find('sys_permission_set', { where: {}, limit: 100 }, SYS)) as any[]; -} - -describe('#22169 — the boot heal writes the row it read, by id', () => { - it('a duplicated name: each drifted row is written once, by its own id, and the next boot logs no drift warn', async () => { - const engine = await boot(['member_default'], [ - driftedRow('ps_a', 'member_default'), - driftedRow('ps_b', 'member_default'), - ]); - const { ql, log } = observe(engine); - const protocol = countingProtocol(['member_default']); - - const first = captureWarns(); - const out1 = await reconcilePermissionSetProjection(protocol, { ql, logger: first.logger }); - - expect([...log.updatedIds].sort(), 'each drifted row written once, by ITS id').toEqual(['ps_a', 'ps_b']); - expect(log.inserts).toBe(0); - expect(out1.driftHealed).toBe(2); - // The warn is said per row healed, AFTER its write, and names the row. - expect(first.driftWarns().map((w) => w.meta?.id).sort()).toEqual(['ps_a', 'ps_b']); - for (const row of await allRows(engine)) { - expect(recordDiffersFromBody(row, declaredBody('member_default')), `${row.id} converged`).toBe(false); - } - - // Boot 2: nothing drifted, so nothing is written and nothing is warned. - log.updatedIds.length = 0; - const second = captureWarns(); - const out2 = await reconcilePermissionSetProjection(protocol, { ql, logger: second.logger }); - expect(second.driftWarns(), 'the next boot logs no drift warn').toEqual([]); - expect(log.updatedIds).toEqual([]); - expect(out2.driftHealed).toBe(0); - expect(await allRows(engine)).toHaveLength(2); // ⛔ duplicates are healed, never deleted here - }); - - it('control: a single drifted row heals as it always did — one write, one warn, then silence', async () => { - const engine = await boot(['viewer_readonly'], [driftedRow('ps_v', 'viewer_readonly')]); - const { ql, log } = observe(engine); - const protocol = countingProtocol(['viewer_readonly']); - - const first = captureWarns(); - const out1 = await reconcilePermissionSetProjection(protocol, { ql, logger: first.logger }); - expect(log.updatedIds).toEqual(['ps_v']); - expect(out1.driftHealed).toBe(1); - expect(first.driftWarns()).toHaveLength(1); - const [row] = await allRows(engine); - expect(JSON.parse(row.system_permissions)).toEqual(['declared.baseline']); - expect(row.managed_by, 'facets only — provenance untouched').toBe('admin'); - - const second = captureWarns(); - await reconcilePermissionSetProjection(protocol, { ql, logger: second.logger }); - expect(second.driftWarns()).toEqual([]); - }); - - it('issues NO layered metadata read while a SchemaRegistry answers — on the healing boot or after it', async () => { - const engine = await boot(['member_default', 'viewer_readonly'], [ - driftedRow('ps_a', 'member_default'), - driftedRow('ps_b', 'member_default'), - driftedRow('ps_v', 'viewer_readonly'), - ]); - const { ql } = observe(engine); - const protocol = countingProtocol(['member_default', 'viewer_readonly']); - - await reconcilePermissionSetProjection(protocol, { ql, logger: captureWarns().logger }); - await reconcilePermissionSetProjection(protocol, { ql, logger: captureWarns().logger }); - expect(protocol.layeredReads).toEqual([]); - }); - - it('without a SchemaRegistry the layered read is still the source — asked once per NAME, not per row', async () => { - const engine = await boot([], [ - driftedRow('ps_a', 'member_default'), - driftedRow('ps_b', 'member_default'), - ]); - const { ql, log } = observe(engine, { withRegistry: false }); - const protocol = countingProtocol(['member_default']); - - const out = await reconcilePermissionSetProjection(protocol, { ql, logger: captureWarns().logger }); - expect(protocol.layeredReads).toEqual(['member_default']); - expect([...log.updatedIds].sort()).toEqual(['ps_a', 'ps_b']); - expect(out.driftHealed).toBe(2); - }); - - it('a refused by-name existence read inserts nothing — the heal holds the row and writes it by id', async () => { - const engine = await boot(['member_default'], [ - driftedRow('ps_a', 'member_default'), - driftedRow('ps_b', 'member_default'), - ]); - const { ql, log, state } = observe(engine); - state.refuseNameReads = true; - const protocol = countingProtocol(['member_default']); - - await reconcilePermissionSetProjection(protocol, { ql, logger: captureWarns().logger }); - expect(log.inserts, '⛔ no row minted on a read that did not answer').toBe(0); - expect(await allRows(engine)).toHaveLength(2); - expect([...log.updatedIds].sort()).toEqual(['ps_a', 'ps_b']); - }); -}); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.test.ts b/packages/plugins/plugin-security/src/permission-set-projection.test.ts deleted file mode 100644 index ddab6b9aa30..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ /dev/null @@ -1,1886 +0,0 @@ -// 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. - * - * [#6858 / ADR-0094 D5-R] `makeProtocol` models ADR-0005's TIER GATE, which - * this suite used to be blind to: `permission` is `allowEnvironmentOverlay: false` - * since commit ee58392e1, so a metadata write whose (type, name) is backed by - * a code ARTIFACT is refused 403 `NOT_OVERRIDABLE`, while an artifact-free - * name rides `allowRuntimeCreate: true` and still lands. Commit ee58392e1 records the - * blind spot in its own message: this suite stubs `saveMetaItem`, so the real - * gate is pinned elsewhere (dogfood cases and a dedicated 403 suite) — - * which is exactly why four cases here kept asserting the RETIRED overlay - * direction and stayed green: the stub could not refuse. They are triaged - * below, each one individually. - */ - -import { describe, it, expect } from 'vitest'; -import { PermissionSetSchema } from '@objectstack/spec/security'; -import { - permissionSetRowFields, - permissionSetBodyFromRow, - permissionSpecBodyKeys, - pickRowStateColumns, - mergeRowPatchIntoBody, - recordDiffersFromBody, - upsertEnvPermissionSet, - projectPermissionMutation, - registerPermissionSetProjection, - createPermissionSetWriteThrough, - reconcilePermissionSetProjection, - type ProjectionLogger, -} from './permission-set-projection.js'; -import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; - -/** - * In-memory ql over sys_permission_set + sys_metadata. - * - * [#11097] Supports the `$in` membership operator, because the real engine does - * — `bootstrapDeclaredPermissions` has read `sys_permission_set` with - * `{ name: { $in: names } }` since #10946, on this very table. A double that - * refused it (or, worse, quietly matched nothing and returned `[]`) would pin - * the double's limits rather than the projector's behaviour: `[]` is the answer - * "none of these names exist", so a non-matching double would make the - * reconciler re-create every overlay record on every boot while this suite - * stayed green. - * - * `calls` counts every round trip — the defect #11096/#11097 fix is a COUNT. - */ -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]) => { - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - if (v && typeof v === 'object' && !Array.isArray(v)) { - const inList = (v as any).$in; - if (Array.isArray(inList)) return inList.includes(r[k]); - throw new Error(`fake driver: unsupported operator ${Object.keys(v).join(',')}`); - } - return v === null ? (r[k] ?? null) === null : r[k] === v; - }); - return { - permRows, - metaRows, - /** Round trips by table, since issue order matters to the count tests. */ - calls: { find: 0, insert: 0, update: 0 }, - resetCalls() { (this as any).calls = { find: 0, insert: 0, update: 0 }; }, - async find(object: string, q: any) { - const rows = tableFor(object); - if (object === 'sys_permission_set') (this as any).calls.find += 1; - return rows ? rows.filter((r) => matches(r, q?.where)) : []; - }, - async findOne(object: string, q: any) { - assertEngineFindOnePredicate(object, q); - 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; - if (object === 'sys_permission_set') (this as any).calls.insert += 1; - rows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any) { - const rows = tableFor(object); - if (object === 'sys_permission_set') (this as any).calls.update += 1; - 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. - * - * [#21861] The package dimension of a row is modelled, because the defect it - * hid lives there. A save targets the ONE row keyed by its `packageId` (absent - * = the package-less row), as `SysMetadataRepository.put` does, so a save that - * names no package beside a package-bound row mints a second row instead of - * quietly overwriting the only one. The reads serve the first env-wide row of - * the name in any package (`findServedOverlayRow` with no package), and the - * single-item read states that row's `package_id` on the item as `_packageId`, - * as `getMetaItem` does. - */ -function makeProtocol(ql: any, declared: Record = {}) { - let projector: ((evt: any) => Promise) | null = null; - const isActiveEnvRow = (r: any, name: string) => - r.type === 'permission' && r.name === name && r.state === 'active' && (r.organization_id ?? null) === null; - const overlayFor = (name: string) => ql.metaRows.find((r: any) => isActiveEnvRow(r, name)); - const rowAt = (name: string, packageId: string | null) => - ql.metaRows.find((r: any) => isActiveEnvRow(r, name) && (r.package_id ?? null) === packageId); - 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; packageId?: string | null }) { - // [#6858 / ADR-0094 D5-R] ADR-0005's tier gate, first — ahead of schema - // validation, exactly as `protocol.ts` orders it. `declared` IS this - // stub's artifact registry, so `declared[name] !== undefined` is the - // `isArtifactBacked` fact the production gate reads (never the record's - // `managed_by` column). The refusal is topology-independent: the - // protocol gate fires when `environmentId` is set, and - // `SysMetadataRepository.assertAllowed` refuses the `override-artifact` - // intent on every other kernel — pinned in - // metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts. - // - // `deleteMetaItem` below deliberately does NOT model a gate: its - // artifact-backed refusal sits inside `environmentId !== undefined` - // with no repository-level twin, so the answer is topology-dependent - // and a single modelled verdict here would be a fabrication. It stays - // pinned where the real gate can be reached (the protocol suite above, - // and packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts). - if (declared[req.name] !== undefined) { - const err: any = new Error( - `Metadata item 'permission/${req.name}' is provided by a managed package and is sealed ` - + 'against in-place edits. Clone it under a new name to customize it (the "Clone" action on ' - + 'the permission set, or POST /api/v1/data/sys_permission_set with a new name). ' - + 'See docs/adr/0126-packaged-metadata-customization-model.md.', - ); - err.code = 'NOT_OVERRIDABLE'; - err.status = 403; - throw err; - } - // [#4669] The REAL `PermissionSetSchema`, exactly as `saveMetaItem` runs - // it (metadata-protocol/src/protocol.ts → `resolveOverlaySchema`), same - // `[invalid_metadata]` 422 envelope. Without this the mock accepts any - // object and the suite stays green while every real backfill fails — - // which is how a 100%-failing projection shipped. - const parsed = PermissionSetSchema.safeParse(req.item); - if (!parsed.success) { - const summary = parsed.error.issues - .map((i: any) => `${i.path.join('.') || ''}: ${i.message}`) - .join('; '); - const err: any = new Error(`[invalid_metadata] permission/${req.name} failed spec validation: ${summary}`); - err.code = 'INVALID_METADATA'; - err.status = 422; - throw err; - } - const packageId = req.packageId ?? null; - const existing = rowAt(req.name, packageId); - if (existing) existing.metadata = JSON.stringify(req.item); - else { - ql.metaRows.push({ - id: packageId === null ? `meta_${req.name}` : `meta_${req.name}@${packageId}`, - type: 'permission', name: req.name, state: 'active', - organization_id: null, ...(packageId === null ? {} : { package_id: packageId }), - 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, - }; - }, - getMetaItemCalls: 0, - async getMetaItem(req: { type: string; name: string }) { - protocol.getMetaItemCalls += 1; - const o = overlayFor(req.name); - const item = o - ? { ...JSON.parse(o.metadata), ...(o.package_id ? { _packageId: o.package_id } : {}) } - : declared[req.name]; - if (item === undefined) { - const err: any = new Error(`permission/${req.name} not found`); - err.status = 404; - throw err; - } - return { type: 'permission', name: req.name, item }; - }, - }; - 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'); - // and projecting the rebuilt body changes nothing - expect(recordDiffersFromBody(row, body)).toBe(false); - }); -}); - -// ── The definition ⊆ spec contract (#4669) ───────────────────────────────── -// -// `sys_permission_set` carries columns the DEFINITION does not (`active`, the -// timestamps, the provenance trio). Feeding them to `saveMetaItem` is what -// #4001's `.strict()` schema rejects, and what took the ADR-0094 D4 backfill -// to a 100% failure rate. - -describe('row→body projection keeps ONLY spec-declared keys (#4669)', () => { - const legacyRow = () => ({ - id: 'ps_1', - name: 'organization_admin', - ...permissionSetRowFields(envBody()), - // every storage column a real row carries… - active: true, - managed_by: 'admin', - package_id: null, - customized: false, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-02-02T00:00:00Z', - // …plus a column this code has never heard of - some_future_column: 'whatever', - }); - - it('the whitelist is DERIVED from the spec schema, not transcribed', () => { - const keys = permissionSpecBodyKeys(); - // identical to PermissionSetSchema's own shape — the single source - expect([...keys].sort()).toEqual(Object.keys((PermissionSetSchema as any).shape).sort()); - expect(keys.has('objects')).toBe(true); - expect(keys.has('systemPermissions')).toBe(true); - expect(keys.has('adminScope')).toBe(true); - // `active` is a TABLE column, never a spec key — that is the whole bug - expect(keys.has('active')).toBe(false); - }); - - it('drops `active` and every other storage column from the projected body', () => { - const body = permissionSetBodyFromRow(legacyRow()); - for (const col of ['active', 'managed_by', 'package_id', 'customized', 'created_at', 'updated_at', 'id', 'some_future_column']) { - expect(body, `storage column '${col}' must not enter the metadata body`).not.toHaveProperty(col); - } - // the definition itself survives intact - expect(body.objects).toEqual(envBody().objects); - expect(body.systemPermissions).toEqual(envBody().systemPermissions); - }); - - it('every key the projection emits is one the spec ACCEPTS (parsed by the real schema)', () => { - const parsed = PermissionSetSchema.safeParse(permissionSetBodyFromRow(legacyRow())); - expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); - // …and the reverse guard: a spec RENAME must fail here rather than silently - // dropping the value at runtime. - const keys = permissionSpecBodyKeys(); - for (const key of Object.keys(permissionSetBodyFromRow(legacyRow()))) { - expect(keys.has(key), `body key '${key}' is not declared by PermissionSetSchema`).toBe(true); - } - }); - - it('filters a body STORED before #4001 (data at rest can still carry `active`)', () => { - // a legacy sys_metadata overlay written while the schema still stripped it - const legacyStored = { ...envBody(), active: false, _packageId: 'com.x' }; - const merged = mergeRowPatchIntoBody(legacyStored, { label: 'Renamed' }); - expect(merged).not.toHaveProperty('active'); - expect(merged).not.toHaveProperty('_packageId'); - expect(PermissionSetSchema.safeParse(merged).success).toBe(true); - }); - - it('pickRowStateColumns isolates the record-state columns (normalized)', () => { - expect(pickRowStateColumns({ active: 'false', label: 'x' })).toEqual({ active: false }); - expect(pickRowStateColumns({ active: true })).toEqual({ active: true }); - expect(pickRowStateColumns({ label: 'x' })).toBeNull(); - expect(pickRowStateColumns(null)).toBeNull(); - }); -}); - -describe('upsertEnvPermissionSet (ADR-0094 — record is a pure projection)', () => { - it('CREATES a missing record (managed_by admin) — 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'); - // [A4 #2920] env/Studio-authored sets are ADMIN-owned (formerly 'user'). - expect(row.managed_by).toBe('admin'); - expect(row.active).toBe(true); - expect(JSON.parse(row.object_permissions)).toEqual(envBody().objects); - }); - - it('projects all facets 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()); - 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'); - }); - - it('[#4669] NEVER re-flips `active` from a body — it is row state, not definition', async () => { - // A body carrying `active` can only be legacy data at rest (pre-#4001) or a - // caller mistake. Projecting it would silently undo an admin's - // deactivate — the record's switch is the record's own. - const ql = makeQl(); - ql.permRows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]', active: false }); - await upsertEnvPermissionSet(ql, { ...envBody(), active: true } as any); - expect(ql.permRows[0].active, 'a stale body must not re-activate a deactivated set').toBe(false); - // …and a record the projector CREATES starts active (column default). - await upsertEnvPermissionSet(ql, envBody({ name: 'fresh_set' })); - expect(ql.permRows.find((r: any) => r.name === 'fresh_set')?.active).toBe(true); - }); - - 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('projects onto a PACKAGE-OWNED row (overlay customization) while preserving its provenance', async () => { - // Unit-level shape, unchanged by ADR-0094 D5-R: handed a body for a - // package-owned row, this function projects the facets and preserves the - // provenance. What D5-R retired is the CLAIM about where that body comes - // from — an env overlay of a packaged set is no longer a supported - // customization channel (commit ee58392e1); see the D5-R lifecycle block - // below for the refusal this projector now sits behind. - const ql = makeQl(); - ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg"]' }); - const r = await upsertEnvPermissionSet(ql, envBody()); - expect(r.updated).toBe(1); - const row = ql.permRows[0]; - expect(JSON.parse(row.system_permissions)).toEqual(['setup.access', 'manage_org_users']); - expect(row.managed_by).toBe('package'); // provenance preserved - expect(row.package_id).toBe('com.example.crm'); - expect(row.id).toBe('ps_pkg'); // id stable - }); -}); - -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('admin'); - // 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(); - }); - - // [#21789] The echo is served by the protocol's layered read as the item's - // `code` layer, and the envelope's `provenance` is read off it — so the echo - // must say what the lock says, or the read reports a lock the doors do not - // enforce (an org's own set rendered "locked by a code package"). - it('[#21789] the echo of a set no code package ships says so: `_provenance: \'org\'`', async () => { - const ql = makeQl(); - // The registry holds the stored row the way the hydrator registers it — - // a runtime package id stamped on, tenant provenance written over it. - (ql as any).registry = { - listItems: (t: string) => (t === 'permission' - ? [{ ...envBody(), _packageId: 'app.crm_workspace', _provenance: 'org' }] - : []), - }; - 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(); - await projectPermissionMutation(protocol, { ql, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' }); - const entry = metadata.registry.get('permission/organization_admin'); - expect({ marker: entry?._envProjection, provenance: entry?._provenance }).toEqual({ marker: true, provenance: 'org' }); - }); - - it('[#21789] control: the echo of a legacy overlay of a code-shipped set does NOT claim tenant provenance', async () => { - const ql = makeQl(); - const shipped = { ...envBody({ systemPermissions: ['declared.only'] }), _packageId: 'com.example.crm', _provenance: 'package' }; - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [shipped] : []) }; - const protocol = makeProtocol(ql, { organization_admin: shipped }); - ql.metaRows.push({ id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', organization_id: null, metadata: JSON.stringify(envBody({ systemPermissions: ['overlaid'] })) }); - const metadata = makeMetadataFacade(); - await projectPermissionMutation(protocol, { ql, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' }); - const entry = metadata.registry.get('permission/organization_admin'); - expect(entry?.systemPermissions, 'precondition: the overlay was projected').toEqual(['overlaid']); - expect({ marker: entry?._envProjection, provenance: entry?._provenance ?? null }).toEqual({ marker: true, provenance: null }); - }); - - 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); - }); -}); - -// ── Package-set customization (ADR-0094 D5-R — the 2026-07-14 direction is -// RETIRED; what survives is the `allowRuntimeCreate` tier) ─────────────── - -/** Seed an env-scope overlay row directly — a LEGACY overlay, authored before - * commit ee58392e1's rollback closed the write door. `saveMetaItem` can no longer mint - * one for an artifact-backed name, but `supportsOverlay: true` is unchanged, - * so rows that already exist still merge overlay-wins at read time. */ -const seedLegacyOverlay = (ql: any, name: string, body: any) => { - ql.metaRows.push({ - id: `meta_${name}`, type: 'permission', name, state: 'active', - organization_id: null, metadata: JSON.stringify(body), - }); -}; - -describe('package-owned set customization lifecycle (ADR-0094 D5-R)', () => { - it('an env-scope save on an ARTIFACT-BACKED package name is REFUSED (403 NOT_OVERRIDABLE) — no overlay, record untouched', async () => { - // Was: "a Studio env-scope save on a PACKAGE name customizes the record - // and keeps provenance" — the 2026-07-14 direction. Commit ee58392e1 rolled - // `permission` back to `allowEnvironmentOverlay: false` and ADR-0094 D5-R's ruling A - // accepted the tightening, so the write this case used to assert is the - // write production now refuses. Rejection-class: the ENVELOPE is the - // claim (`code` AND `status`), because a bare "it threw" would stay green - // on a stub that threw for any other reason. - const ql = makeQl(); - const declaredBody = envBody({ systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { organization_admin: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); - - await expect( - protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['customized'] }) }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - - expect(ql.metaRows.length, 'refused, not "refused after writing" — no phantom overlay row').toBe(0); - const row = ql.permRows[0]; - expect(JSON.parse(row.system_permissions), 'the record still projects the shipped declaration').toEqual(['pkg.baseline']); - expect(row.customized, 'nothing was customized').toBeFalsy(); - }); - - it('a package row MATERIALIZED through the metadata door is still customizable — the surviving allowRuntimeCreate tier', async () => { - // The boundary commit ee58392e1 measured UNAFFECTED, and the reason D5-R names a - // surviving NEIGHBOUR rather than a re-route: this row is - // `managed_by:'package'` like the one above, but its DEFINITION lives in - // `sys_metadata` (authored + published through the metadata door, - // ADR-0070), so no artifact backs it and the write rides - // `allowRuntimeCreate`. It is a direct edit of the one stored - // definition — there is no code layer for it to be an overlay OF. - const ql = makeQl(); - const protocol = makeProtocol(ql, {}); // no artifact registry entry - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_mat', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["materialized.baseline"]' }); - - await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['customized'] }) }); - - const row = ql.permRows[0]; - expect(JSON.parse(row.system_permissions)).toEqual(['customized']); - expect(row.managed_by, 'the package still owns the row').toBe('package'); - expect(row.package_id).toBe('com.example.crm'); - }); - - it('a LEGACY overlay (authored before the rollback) still projects, and deleting it still RESETS to the declaration', async () => { - // `supportsOverlay: true` was not touched by commit ee58392e1 — only the WRITE flag - // was. A row that already exists keeps merging overlay-wins, so the - // reset invariant still has to hold for it. Seeded directly because the - // write door that used to mint it is closed. - const ql = makeQl(); - const declaredBody = envBody({ systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { organization_admin: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); - seedLegacyOverlay(ql, 'organization_admin', envBody({ systemPermissions: ['legacy.overlay'] })); - - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active' }); - expect(JSON.parse(ql.permRows[0].system_permissions), 'the legacy overlay still wins at read time').toEqual(['legacy.overlay']); - - await protocol.deleteMetaItem({ type: 'permission', name: 'organization_admin' }); - const row = ql.permRows[0]; - expect(row, 'a packaged definition is never removed by an overlay reset').toBeTruthy(); - expect(JSON.parse(row.system_permissions)).toEqual(['pkg.baseline']); - expect(row.managed_by).toBe('package'); - expect(row.customized, 'reset clears the customized flag').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('admin'); - 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.objects).toEqual(envBody().objects); // unmentioned facets preserved - // [#4669] …but `active` rode along as a COLUMN, never as a body key: the - // definition stays spec-clean while the record's switch still flips. - expect(overlay).not.toHaveProperty('active'); - // …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('[#4669] the activate/deactivate ACTIONS write the column and nothing else', async () => { - // `sys-permission-set.object.ts` ships two `type:'api'` actions that PATCH - // /data/sys_permission_set/{id} with `bodyExtra: { active: true|false }`. - // A row-state-only patch is not a definition write: it passes through to - // the driver, mints no overlay, and touches the metadata store not at all. - 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 savesBefore = protocol.saves.length; - const metaRowsBefore = JSON.stringify(ql.metaRows); - const mw = makeMiddleware(ql, protocol); - - for (const active of [false, true]) { - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: rowId, active }, - }); - expect(nextCalled, 'the driver performs the column write, with its ordinary semantics').toBe(true); - } - expect(protocol.saves.length, 'no metadata write for a pure row-state patch').toBe(savesBefore); - expect(JSON.stringify(ql.metaRows)).toBe(metaRowsBefore); - }); - - it('[#4669] deactivating a PACKAGE-owned set mints no customization overlay', async () => { - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]', active: true }); - const mw = makeMiddleware(ql, protocol); - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, data: { id: 'ps_pkg', active: false }, - }); - expect(nextCalled).toBe(true); - expect(ql.metaRows.length, 'switching a packaged set off is not a customization of it').toBe(0); - expect(ql.permRows[0].customized).toBeUndefined(); - }); - - it('[#4669] INSERT honours an explicit `active` on the record (Clone action sends one)', 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', active: false, - object_permissions: JSON.stringify({ ticket: { allowRead: true } }), - }, - }; - await run(mw, opCtx); - expect(protocol.saves[0].item, 'the definition never carries row state').not.toHaveProperty('active'); - expect(ql.permRows[0].active).toBe(false); - expect(opCtx.result?.active).toBe(false); - }); - - 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 }); - // [#6858] A LEGACY env overlay shadows the declaration — seeded directly - // because `saveMetaItem` can no longer mint one for an artifact-backed - // name (ADR-0094 D5-R). The invariant under test is unchanged: the record - // is projected from the overlay, and the data-door delete resets it. - seedLegacyOverlay(ql, 'organization_admin', envBody({ systemPermissions: ['overlaid'] })); - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active' }); - 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('UPDATE of an ARTIFACT-BACKED set surfaces the producer\'s 403 to the caller (write point :794 — left to 403 loudly)', async () => { - // Was: "UPDATE of a PACKAGE-OWNED set becomes an env overlay" — the - // retired D5 direction. This is the ONE of the four production write - // points that commit ee58392e1's rollback actually closes, and the card's design - // question was what to do with it. Decision (ADR-0094 D5-R): leave it to - // the producer. The middleware still TRANSLATES the write; the protocol's - // ADR-0005 tier gate refuses it; the middleware neither pre-empts the - // refusal (it would need a second copy of `isArtifactBacked` — Prime - // Directive #8) nor swallows it. The caller hears the envelope. - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ - id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', - system_permissions: '["pkg.baseline"]', - }); - const mw = makeMiddleware(ql, protocol); - await expect( - run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', system_permissions: '["customized"]' }, - }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - - expect(ql.metaRows.length, 'no overlay was minted by the refused edit').toBe(0); - const row = ql.permRows[0]; - expect(JSON.parse(row.system_permissions), 'the record still shows the shipped declaration').toEqual(['pkg.baseline']); - expect(row.managed_by).toBe('package'); - }); - - it('UPDATE of a package row MATERIALIZED through the metadata door still lands (the surviving tier)', async () => { - // The other half of the boundary — same `managed_by:'package'` row shape, - // no artifact behind the name, so the write rides `allowRuntimeCreate` - // and the data door keeps working. Without this the case above would also - // pass on a harness that could not write ANYTHING through the middleware. - const ql = makeQl(); - const protocol = makeProtocol(ql, {}); // no artifact registry entry - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ - id: 'ps_mat', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', - system_permissions: '["materialized.baseline"]', - }); - const mw = makeMiddleware(ql, protocol); - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_mat', system_permissions: '["customized"]' }, - }; - const nextCalled = await run(mw, opCtx); - expect(nextCalled, 'the driver write is still skipped — the record is projector-owned').toBe(false); - expect(JSON.parse(ql.metaRows[0].metadata).systemPermissions).toEqual(['customized']); - const row = ql.permRows[0]; - expect(JSON.parse(row.system_permissions)).toEqual(['customized']); - expect(row.managed_by, 'the package still owns the row').toBe('package'); - expect(row.package_id).toBe('com.example.crm'); - }); - - it('INSERT of a name that shadows a code-declared set surfaces the 403 too (write point :752)', async () => { - // Reachable when a declaration ships but its record was never - // materialized: the duplicate-name probe finds no row, so the insert - // proceeds to `saveMetaItem` on an artifact-backed name. Fail-closed and - // unswallowed, same disposition as the UPDATE above. - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep' }); - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - const mw = makeMiddleware(ql, protocol); - await expect( - run(mw, { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { name: 'crm_rep', label: 'Shadow', object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }) }, - }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(ql.permRows.length, 'no record was created by the refused insert').toBe(0); - expect(ql.metaRows.length).toBe(0); - }); - - it('RESTORE reports a refused re-author on the durability channel instead of throwing (write point :713)', async () => { - // The deliberate asymmetry. `restore` runs AFTER the engine has already - // un-trashed the row, so throwing would leave the caller with a restored - // record and a failed request; the write point catches and reports on the - // durability channel (#4632) instead. Pinned with an artifact-backed name - // so the refusal is the ADR-0005 one — the scenario is narrow (a packaged - // definition cannot be trashed through the data door at all), which is - // why the DISPOSITION, not the frequency, is what this case fixes. - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep' }); - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - const errors: any[] = []; - const mw = createPermissionSetWriteThrough({ - ql, getProtocol: () => protocol, - logger: { error: (m: string, e?: Error) => errors.push({ m, e }), info: () => {}, warn: () => {} }, - }); - ql.permRows.push({ id: 'ps_r', name: 'crm_rep', managed_by: 'user' }); - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'restore', options: { where: { id: 'ps_r' } }, context: userCtx, - }); - expect(nextCalled, 'the engine un-trash still runs').toBe(true); - expect(errors.length, 'the failure is reported, not swallowed').toBe(1); - expect(errors[0].e).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(errors[0].m).toContain('NOT re-authored into metadata'); - }); - - it('DELETE of a customized PACKAGE set removes the overlay and resets to the declared baseline', async () => { - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); - const mw = makeMiddleware(ql, protocol); - // [#6858] The overlay is LEGACY (pre-rollback) and seeded directly — the - // data-door edit that used to mint it now answers 403 (ADR-0094 D5-R). - // The invariant under test is untouched: "delete" lifts the overlay and - // the record resets to the shipped declaration. - seedLegacyOverlay(ql, 'crm_rep', envBody({ name: 'crm_rep', systemPermissions: ['customized'] })); - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'crm_rep', state: 'active' }); - expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['customized']); - // "delete" = reset - const nextCalled = await run(mw, { object: 'sys_permission_set', operation: 'delete', options: { where: { id: 'ps_pkg' } }, context: userCtx }); - expect(nextCalled).toBe(false); - expect(ql.metaRows.length).toBe(0); // overlay gone - expect(ql.permRows.length).toBe(1); // record survives - expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['pkg.baseline']); - expect(ql.permRows[0].managed_by).toBe('package'); - }); - - it('the delete OUTCOME tells a real deletion apart from a packaged RESET', async () => { - // Both endings above are correct — a runtime-only set goes, a packaged one - // re-projects to its declaration. What the CALLER could not do was tell - // them apart: the outcome this middleware wrote was `true` on every path - // (it started there and the loop could only ever assign `true` again), so - // the data door answered `200 {"success":true}` for both. A UI fired a - // success toast on a permission set it had not deleted and showed the row - // again on refresh — the two legs' own assertions above stayed green - // throughout, because they read the STORE and never the answer. - // - // ⛔ The reset's outcome must not be `false` either: that value is the - // driver contract's "no row matched", which `deleteData` turns into a - // `404 RECORD_NOT_FOUND` — about a record this same caller can still GET. - // Zero rows removed is the other declared arm and the only one that says - // "it is still there"; `metadata-protocol`'s own - // `protocol.record-not-found.test.ts` pins the mapping onto `success`. - - // Leg 1 — runtime-only definition: the record really goes. - const envQl = makeQl(); - const envProtocol = makeProtocol(envQl); - registerPermissionSetProjection(envProtocol, { ql: envQl }); - await envProtocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody() }); - const envCtx: any = { - object: 'sys_permission_set', - operation: 'delete', - options: { where: { id: envQl.permRows[0].id } }, - context: userCtx, - }; - await run(makeMiddleware(envQl, envProtocol), envCtx); - expect(envQl.permRows.length, 'control: the env-authored record really went').toBe(0); - expect(envCtx.result, 'a completed delete keeps answering the boolean arm').toBe(true); - - // Leg 2 — artifact-backed definition: the record survives the RESET. - const pkgQl = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (pkgQl as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const pkgProtocol = makeProtocol(pkgQl, { crm_rep: declaredBody }); - registerPermissionSetProjection(pkgProtocol, { ql: pkgQl }); - pkgQl.permRows.push({ - id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', - package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]', - }); - seedLegacyOverlay(pkgQl, 'crm_rep', envBody({ name: 'crm_rep', systemPermissions: ['customized'] })); - await projectPermissionMutation(pkgProtocol, { ql: pkgQl }, { type: 'permission', name: 'crm_rep', state: 'active' }); - const pkgCtx: any = { - object: 'sys_permission_set', - operation: 'delete', - options: { where: { id: 'ps_pkg' } }, - context: userCtx, - }; - await run(makeMiddleware(pkgQl, pkgProtocol), pkgCtx); - expect(pkgQl.permRows.length, 'the record survives — the designed ADR-0094 reset').toBe(1); - expect( - typeof pkgCtx.result, - 'the COUNT arm — `false` would be read as not-found and 404 a record that is still there', - ).toBe('number'); - expect(pkgCtx.result, 'zero records removed').toBe(0); - - // The card's own claim, stated as an assertion: the two endings no longer - // hand the caller the same answer. - expect(pkgCtx.result).not.toEqual(envCtx.result); - }); - - it('a read-back that cannot ANSWER reports the record as NOT deleted (fail-closed)', async () => { - // The outcome above is read off the record, which puts the whole answer at - // the mercy of ONE read — and the obvious helper for it, `tryFind`, ends in - // `catch { return []; }`. Probing through that helper makes a FAILED read - // byte-identical to "the row is gone", so the door answers - // `200 {"success":true}` with the record sitting exactly where it was: - // this card's own lie, re-triggered by an outage instead of by a packaged - // set. Nothing else in this file would have said a word about it. - // - // `unknown` is therefore counted as a survivor. A caller told "not - // deleted" re-reads the record and learns the truth; a caller told - // "deleted" has no reason to look again. - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ - id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', - package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]', - }); - - // Fail ONLY the read-back. Target resolution and the probe both read - // `sys_permission_set` by id, so they are told apart by ORDER: the first - // is the middleware resolving what the caller addressed, the second is the - // read-back under test. `byIdReads` is asserted below so that a change in - // that shape turns this test RED rather than leaving it vacuously green - // with an injection that never fired. - const realFind = ql.find.bind(ql); - let byIdReads = 0; - (ql as any).find = async (object: string, q: any) => { - if (object === 'sys_permission_set' && q?.where?.id !== undefined) { - byIdReads += 1; - if (byIdReads >= 2) throw new Error('db unavailable'); - } - return realFind(object, q); - }; - - const warnings: { m: string; meta?: any }[] = []; - const mw = createPermissionSetWriteThrough({ - ql, getProtocol: () => protocol, - logger: { info: () => {}, warn: (m: string, meta?: any) => warnings.push({ m, meta }) }, - }); - const opCtx: any = { - object: 'sys_permission_set', - operation: 'delete', - options: { where: { id: 'ps_pkg' } }, - context: userCtx, - }; - await run(mw, opCtx); - - expect(byIdReads, 'the read-back really ran — otherwise this test proves nothing').toBeGreaterThanOrEqual(2); - expect(ql.permRows.length, 'the record is still right there').toBe(1); - expect( - opCtx.result, - '⛔ never `true`: that is the deletion claim this card exists to remove, and a failed read must not manufacture it', - ).not.toBe(true); - expect(opCtx.result, 'counted as a survivor').toBe(0); - expect( - warnings.some((w) => String(w.m).includes('could not read the sys_permission_set record back')), - 'the degradation is reported, not swallowed', - ).toBe(true); - }); - - it('SINGLE-STORE kernel (no protocol): package rows keep the legacy two-doors refusal', async () => { - const ql = makeQl(); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' }); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => null }); - await expect( - run(mw, { object: 'sys_permission_set', operation: 'update', data: { id: 'ps_pkg', label: 'hijack' }, context: userCtx }), - ).rejects.toMatchObject({ status: 403 }); - await expect( - run(mw, { object: 'sys_permission_set', operation: 'delete', options: { where: { id: 'ps_pkg' } }, context: userCtx }), - ).rejects.toMatchObject({ status: 403 }); - // env rows still pass through to the driver in single-store kernels - ql.permRows.push({ id: 'ps_env', name: 'my_custom', managed_by: 'user' }); - expect(await run(mw, { object: 'sys_permission_set', operation: 'update', data: { id: 'ps_env', label: 'ok' }, context: userCtx })).toBe(true); - }); - - it('leaves non-sys_permission_set objects and unrelated operations alone', async () => { - const ql = makeQl(); - const mw = makeMiddleware(ql, makeProtocol(ql)); - 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); - }); -}); - -// ── [#21861] The update leg writes back into the row it edits ────────────── - -describe('[#21861] a data-door UPDATE writes back into the stored row it edits — no package-less fork', () => { - const userCtx = { userId: 'usr_admin' }; - const PKG = 'com.acme.runtime'; - - /** Every active stored row of `name`, as its scope, its binding and the description its body carries. */ - const activeRows = (ql: any, name: string) => - ql.metaRows - .filter((r: any) => r.name === name && r.state === 'active') - .map((r: any) => ({ - organization_id: r.organization_id ?? null, - package_id: r.package_id ?? null, - description: JSON.parse(r.metadata).description, - })); - - /** A stored set (one row, optionally package-bound) with its projected record. */ - async function storedSet(packageId?: string) { - const ql = makeQl(); - const protocol = makeProtocol(ql); - registerPermissionSetProjection(protocol, { ql }); - await protocol.saveMetaItem({ - type: 'permission', name: 'support_agent', item: envBody({ name: 'support_agent' }), - ...(packageId ? { packageId } : {}), - }); - protocol.saves.length = 0; - return { ql, protocol, rowId: ql.permRows[0].id as string }; - } - - it('a set stored in a writable runtime package: the edit lands on that row, which stays bound', async () => { - const { ql, protocol, rowId } = await storedSet(PKG); - expect(activeRows(ql, 'support_agent'), 'before').toEqual([{ organization_id: null, package_id: PKG, description: undefined }]); - - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: rowId, description: 'edited at the data door' }, - }; - expect(await run(makeMiddleware(ql, protocol), opCtx)).toBe(false); - - expect(activeRows(ql, 'support_agent'), 'after: still one row, same binding, carrying the edit') - .toEqual([{ organization_id: null, package_id: PKG, description: 'edited at the data door' }]); - expect(protocol.saves.map((s: any) => s.packageId), 'the save names the row\'s own package').toEqual([PKG]); - expect(opCtx.result?.description, 'the projected record follows').toBe('edited at the data door'); - }); - - it('a package-less set: the save still names no package, and its one row carries the edit', async () => { - const { ql, protocol, rowId } = await storedSet(); - expect(activeRows(ql, 'support_agent'), 'before').toEqual([{ organization_id: null, package_id: null, description: undefined }]); - - await run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: rowId, description: 'edited' }, - }); - - expect(protocol.saves).toHaveLength(1); - expect(protocol.saves[0], 'no binding is invented for a set that has none').not.toHaveProperty('packageId'); - expect(activeRows(ql, 'support_agent'), 'after') - .toEqual([{ organization_id: null, package_id: null, description: 'edited' }]); - }); - - it('a filtered edit spanning a package-bound and a package-less set writes each back into its own row', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - registerPermissionSetProjection(protocol, { ql }); - await protocol.saveMetaItem({ type: 'permission', name: 'pkg_set', item: envBody({ name: 'pkg_set' }), packageId: PKG }); - await protocol.saveMetaItem({ type: 'permission', name: 'org_set', item: envBody({ name: 'org_set' }) }); - protocol.saves.length = 0; - const before = { pkg: activeRows(ql, 'pkg_set'), org: activeRows(ql, 'org_set') }; - expect(before).toEqual({ - pkg: [{ organization_id: null, package_id: PKG, description: undefined }], - org: [{ organization_id: null, package_id: null, description: undefined }], - }); - - await run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { description: 'bulk' }, options: { where: { name: { $in: ['pkg_set', 'org_set'] } } }, - }); - - expect({ pkg: activeRows(ql, 'pkg_set'), org: activeRows(ql, 'org_set') }, 'after').toEqual({ - pkg: [{ organization_id: null, package_id: PKG, description: 'bulk' }], - org: [{ organization_id: null, package_id: null, description: 'bulk' }], - }); - expect(protocol.saves.map((s: any) => [s.name, s.packageId ?? null])) - .toEqual([['pkg_set', PKG], ['org_set', null]]); - }); - - it('a set a code package ships is still refused by the lock (403 NOT_OVERRIDABLE) before any binding read or save', async () => { - const ql = makeQl(); - const declaredBody = { ...envBody({ name: 'crm_rep' }), _packageId: 'com.example.crm' }; - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' }); - const before = activeRows(ql, 'crm_rep'); - - await expect(run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', description: 'customized' }, - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - - expect(activeRows(ql, 'crm_rep'), 'the refused edit stored nothing').toEqual(before); - expect(protocol.saves).toHaveLength(0); - expect(protocol.getMetaItemCalls, 'the binding is never read for a refused edit').toBe(0); - }); -}); - -// ── 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('admin'); - }); - - 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(out.backfillFailed).toBe(0); - 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('[#6858] the backfill never targets an ARTIFACT-BACKED name — write point :928 cannot reach the tier gate', async () => { - // The measured half of ADR-0094 D5-R's "3 of the 4 write points were - // already on the surviving tier". The backfill runs only for records - // whose name has NO metadata presence at all; a code-declared name has a - // declared body, so the branch is not entered and no `saveMetaItem` is - // issued. Asserted on the write LEDGER (`protocol.saves`) rather than on - // the absence of a throw: "it did not fail" would also be true if the - // gate had simply accepted the write. - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - ql.permRows.push({ - id: 'ps_pkg_env', name: 'crm_rep', managed_by: 'admin', active: true, - label: 'CRM Rep', system_permissions: '["pkg.baseline"]', - }); - - const out = await reconcilePermissionSetProjection(protocol, { ql }); - - expect(protocol.saves.length, 'no metadata write was attempted for a declared name').toBe(0); - expect(out.backfilledIntoMetadata).toBe(0); - expect(out.backfillFailed, 'and therefore nothing could be refused').toBe(0); - }); - - it('[#4669] a row carrying the `active` STORAGE COLUMN backfills instead of failing spec validation', async () => { - // The reported symptom: every `sys_permission_set` row has an `active` - // column, `permissionSetBodyFromRow` handed it to `saveMetaItem`, and - // #4001's `.strict()` schema rejected all of them — a 100%-failing - // backfill behind one `warn`, with `backfilledIntoMetadata` stuck at 0. - const ql = makeQl(); - const protocol = makeProtocol(ql); // validates with the real PermissionSetSchema - ql.permRows.push({ - id: 'ps_d8', name: 'd8_qc_user', managed_by: 'admin', - active: true, customized: false, package_id: null, - created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-02T00:00:00Z', - label: 'D8 QC User', ...permissionSetRowFields(envBody({ name: 'd8_qc_user' })), - }); - const logs: Array<{ level: string; msg: string }> = []; - const logger = { - info: (m: string) => logs.push({ level: 'info', msg: m }), - warn: (m: string) => logs.push({ level: 'warn', msg: m }), - error: (m: string) => logs.push({ level: 'error', msg: m }), - }; - const out = await reconcilePermissionSetProjection(protocol, { ql, logger }); - expect(out.backfilledIntoMetadata).toBe(1); - expect(out.backfillFailed).toBe(0); - expect(logs.some((l) => /backfill into metadata failed|FAILED/i.test(l.msg))).toBe(false); - const stored = JSON.parse(ql.metaRows[0].metadata); - expect(stored).not.toHaveProperty('active'); - expect(stored.name).toBe('d8_qc_user'); - }); - - it('[#4669/#4632] a REAL backfill failure is loud: error level, counted, consequence + fix', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - // Not a key problem — the stored facet JSON itself is off-contract, so no - // amount of key-filtering saves it. This is the case that MUST shout. - ql.permRows.push({ - id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true, - label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }), - }); - ql.permRows.push({ - id: 'ps_bad2', name: 'broken_set_2', managed_by: 'admin', active: true, - label: 'Broken Set 2', object_permissions: JSON.stringify({ ticket: { nonsense: true } }), - }); - // `error` follows the platform Logger contract: (message, error?, meta?). - const logs: Array<{ level: string; msg: string; meta?: any; cause?: Error }> = []; - const logger = { - info: (m: string, meta?: any) => logs.push({ level: 'info', msg: m, meta }), - warn: (m: string, meta?: any) => logs.push({ level: 'warn', msg: m, meta }), - error: (m: string, cause?: Error, meta?: any) => logs.push({ level: 'error', msg: m, cause, meta }), - }; - const out = await reconcilePermissionSetProjection(protocol, { ql, logger }); - - // counted in the RESULT — not only in a log line nobody reads - expect(out.backfillFailed).toBe(2); - expect(out.backfilledIntoMetadata).toBe(0); - expect(ql.metaRows.length).toBe(0); - - // level: error, never warn/info for a durability degradation - const errors = logs.filter((l) => l.level === 'error'); - expect(errors.length).toBeGreaterThan(0); - expect(logs.some((l) => l.level === 'warn' && /backfill/i.test(l.msg))).toBe(false); - // said ONCE, at the first failure — not once per failed write - const firstFailure = errors[0]!; - expect(errors.filter((l) => /backfill into metadata FAILED/.test(l.msg)).length).toBe(1); - // the consequence… - expect(firstFailure.msg).toMatch(/Nothing will look broken/); - expect(firstFailure.msg).toMatch(/re-provision/); - // …and the fix - expect(firstFailure.msg).toMatch(/Fix:/); - expect(firstFailure.meta?.name).toBe('broken_set'); - - // the summary carries the failure too — an `info` "reconciled" line over a - // failed backfill is the reassuring half-truth the rule exists to remove - const summary = errors.at(-1)!; - expect(summary.msg).toMatch(/2 FAILED backfill/); - expect(summary.meta?.failedNames).toEqual(['broken_set', 'broken_set_2']); - expect(logs.some((l) => l.level === 'info' && /reconciled/.test(l.msg))).toBe(false); - }); - - it('[#9657] a sink with NO `error` still hears the backfill failure — at warn, not in silence', async () => { - // `ProjectionLogger.error` is declared OPTIONAL, and the report used to be - // spelled `logger?.error?.(…)` — an optional call that emits NOTHING when - // the method is absent. A host injecting `{ info, warn }` therefore lost - // the whole durability report, on the one path that must never be quiet. - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ - id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true, - label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }), - }); - const logs: Array<{ level: string; msg: string; meta?: any }> = []; - const logger = { - info: (m: string, meta?: any) => logs.push({ level: 'info', msg: m, meta }), - warn: (m: string, meta?: any) => logs.push({ level: 'warn', msg: m, meta }), - }; - - const out = await reconcilePermissionSetProjection(protocol, { ql, logger }); - - expect(out.backfillFailed).toBe(1); - const firstFailure = logs.find((l) => /backfill into metadata FAILED/.test(l.msg)); - expect(firstFailure).toBeDefined(); - expect(firstFailure!.level).toBe('warn'); - // The consequence and the fix survive the fallback — a downgraded level is - // a degradation of the CHANNEL, never of the message. - expect(firstFailure!.msg).toMatch(/Nothing will look broken/); - expect(firstFailure!.msg).toMatch(/Fix:/); - expect(firstFailure!.meta?.name).toBe('broken_set'); - }); - - it('[#9748] the reconcile SUMMARY also reaches a sink with NO `error` — at warn, not in silence', async () => { - // #9657 repaired the FIRST-FAILURE line above; this summary sits outside - // any `catch`, so the durability gate could not see it and it kept the - // `logger?.error?.(…)` spelling. Against a `{ info, warn }` sink the repair - // therefore made the split WORSE, not better: the first failure survived at - // `warn` while the TOTAL — how many definitions will not survive a - // re-provision — vanished, and the `info` "reconciled" line is skipped too, - // so the sink heard neither. That silence is the reassuring half-truth this - // rule exists to remove, arrived at from the other side. - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ - id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true, - label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }), - }); - ql.permRows.push({ - id: 'ps_bad2', name: 'broken_set_2', managed_by: 'admin', active: true, - label: 'Broken Set 2', object_permissions: JSON.stringify({ ticket: { nonsense: true } }), - }); - const logs: Array<{ level: string; msg: string; meta?: any }> = []; - const logger = { - info: (m: string, meta?: any) => logs.push({ level: 'info', msg: m, meta }), - warn: (m: string, meta?: any) => logs.push({ level: 'warn', msg: m, meta }), - }; - - const out = await reconcilePermissionSetProjection(protocol, { ql, logger }); - - expect(out.backfillFailed).toBe(2); - const summary = logs.filter((l) => /FAILED backfill/.test(l.msg)); - expect(summary).toHaveLength(1); - expect(summary[0]!.level).toBe('warn'); - expect(summary[0]!.msg).toMatch(/2 FAILED backfill/); // the COUNT is the whole point - expect(summary[0]!.msg).toMatch(/will not survive a re-provision/); - expect(summary[0]!.meta?.failedNames).toEqual(['broken_set', 'broken_set_2']); - // and never the reassuring half-truth instead - expect(logs.some((l) => /reconciled \(ADR-0094 D4\)/.test(l.msg))).toBe(false); - }); - - it('[#9748] a sink that HAS `error` still gets the summary at error, not downgraded', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ - id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true, - label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }), - }); - const logs: Array<{ level: string; msg: string; meta?: any; cause?: Error }> = []; - const logger = { - info: (m: string, meta?: any) => logs.push({ level: 'info', msg: m, meta }), - warn: (m: string, meta?: any) => logs.push({ level: 'warn', msg: m, meta }), - error: (m: string, cause?: Error, meta?: any) => logs.push({ level: 'error', msg: m, cause, meta }), - }; - - await reconcilePermissionSetProjection(protocol, { ql, logger }); - - const summary = logs.filter((l) => /FAILED backfill/.test(l.msg)); - expect(summary).toHaveLength(1); - expect(summary[0]!.level).toBe('error'); - expect(summary[0]!.meta?.failedNames).toEqual(['broken_set']); - }); - - it('[#9754] a sink with NO `warn` cannot be spelled at all — the TYPE forbids the silence', async () => { - // THE HARM, reproduced before the fix is believed. Until #9754 every member - // of `ProjectionLogger` was optional, so `{ info }` was a legal sink — and - // against it the reconcile pass reported NOTHING: the first-failure line - // and the summary both reach for `error`, fall back to `warn`, and find - // neither, while the `else` branch carrying the reassuring "reconciled" - // line is skipped because the pass did fail. A permission set that will not - // survive a re-provision, and a boot log that says nothing whatsoever. - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ - id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true, - label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }), - }); - const heard: string[] = []; - const silent = { info: (m: string) => heard.push(m) }; - - const out = await reconcilePermissionSetProjection(protocol, { - ql, - logger: silent as unknown as ProjectionLogger, - }); - - expect(out.backfillFailed).toBe(1); - expect(heard).toEqual([]); // neither the failure, nor the count, nor the "reconciled" line - - // THE CONTRACT is what removes that cast's subject: `warn` is non-optional - // on `ProjectionLogger` (#9754), so no TS caller can build the sink above - // without saying `as unknown as` out loud. - // - // ⚠️ Not pinned here with `@ts-expect-error`, and [commit a68c61267] moved the reason - // out from under that sentence. It used to be that this package's tsconfig - // excluded `**/*.test.ts` (it carried a TEST_DEBT ledger entry in - // scripts/check-type-check-coverage.mjs), so no tsc program compiled this - // file and a directive here would have evaluated NEVER — a phantom check - // that reads like proof, the failure AGENTS.md → "Build & Test" names. The - // sibling `tsconfig.test.json` compiles this file now and that ledger entry - // is gone, so a directive here would be LIVE. The compile-time half of this - // contract is pinned in plugin-email's `outbox-sweep.test.ts` (observed: - // reverting `warn` there turns that directive into `error TS2578: Unused - // '@ts-expect-error' directive`), and the type half of BOTH sinks is held by - // `pnpm check:optional-error-sink` — so the pin is redundant here rather - // than impossible, which is a different sentence and the true one. - }); - - 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); - }); -}); - -// ── #11097 — boot reconciliation is O(1) round trips, and still reconciles ── - -/** - * [#11097] The env door's boot reconciler projected every env-scope `permission` - * overlay in a per-name loop, and each iteration issued its OWN existence - * `SELECT` inside `upsertEnvPermissionSet` plus an `UPDATE` that fired whether - * or not the record already matched. Invisible on a local file database; one - * sequential HTTP request per leg on the remote libsql/Turso database every - * hosted environment runs. - * - * ## What is measured here, and what is NOT - * - * A COUNT — every `find`/`insert`/`update` the reconciler issues against - * `sys_permission_set` is one round trip, counted by `makeQl().calls`. ⚠️ The - * slope of THIS axis has never been measured: the hosted `bootstrap-curve.mjs` - * rig lives in `objectstack-ai/cloud`, and its axes are permission sets / - * positions / objects, not env overlays. What is established is that the code - * shape is the one #10946 measured at 4.0000 round trips per item on the two - * sibling loops. Nothing here measures wall time. - * - * ## Why every counting test below is paired with a reconciliation test - * - * ⚠️ LOAD-BEARING. A reconciler that simply stopped writing would produce a - * perfect count while silently reconciling nothing — the loop keeping its shape - * and losing its purpose. So each count is paired with a drift fixture over the - * same data, and `customized` gets its own pair because it is written by this - * projector and deliberately NOT compared by `recordDiffersFromBody`. - */ -describe('#11097 — env overlay reconciliation: round trips', () => { - const overlay = (n: number) => `env_set_${n}`; - - /** Seed `n` env-authored overlays and settle them into records. */ - const seedOverlays = async (n: number) => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - for (let i = 0; i < n; i += 1) { - ql.metaRows.push({ - id: `m${i}`, type: 'permission', name: overlay(i), state: 'active', - organization_id: null, metadata: JSON.stringify(envBody({ name: overlay(i) })), - }); - } - await reconcilePermissionSetProjection(protocol, { ql }); // first boot: creates - return { ql, protocol }; - }; - - it('does not grow the steady-state round-trip count with the number of overlays', async () => { - const measure = async (n: number) => { - const { ql, protocol } = await seedOverlays(n); - ql.resetCalls(); - const out = await reconcilePermissionSetProjection(protocol, { ql }); // REBUILD - // Nothing changed, so nothing is written — and that is the whole point. - expect(out.projectedFromMetadata).toBe(0); - expect(ql.calls.update).toBe(0); - expect(ql.calls.insert).toBe(0); - return ql.calls.find; - }; - - const [n1, n5, n20, n40] = [await measure(1), await measure(5), await measure(20), await measure(40)]; - // ONE batched `$in` existence read + the pre-existing full-record page read. - // The COUNT is asserted, never the wall time. - expect([n1, n5, n20, n40]).toEqual([2, 2, 2, 2]); - }); - - it('issues ONE batched `$in` existence read for the whole overlay set', async () => { - const { ql, protocol } = await seedOverlays(12); - const seen: any[] = []; - const origFind = ql.find.bind(ql); - (ql as any).find = async (object: string, q: any) => { - if (object === 'sys_permission_set') seen.push(q?.where); - return origFind(object, q); - }; - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(seen).toHaveLength(2); - expect(seen[0]).toEqual({ name: { $in: Array.from({ length: 12 }, (_, i) => overlay(i)) } }); - }); - - it('first boot costs one batched read plus one INSERT per genuinely new overlay', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - for (let i = 0; i < 10; i += 1) { - ql.metaRows.push({ - id: `m${i}`, type: 'permission', name: overlay(i), state: 'active', - organization_id: null, metadata: JSON.stringify(envBody({ name: overlay(i) })), - }); - } - const out = await reconcilePermissionSetProjection(protocol, { ql }); - expect(out.projectedFromMetadata).toBe(10); - expect(ql.calls.insert).toBe(10); - expect(ql.calls.update).toBe(0); - expect(ql.permRows).toHaveLength(10); - }); -}); - -/** - * ⚠️ LOAD-BEARING. Without these, an implementation that skipped every write - * would pass every count above while reconciling nothing at all. - */ -describe('#11097 — drift STILL reconciles', () => { - it('an overlay whose stored facets differ still gets its UPDATE', 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()), - }); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows).toHaveLength(1); - - // Someone wrote straight at the record. - ql.permRows[0].object_permissions = JSON.stringify({ crm_lead: { allowDelete: true } }); - - ql.resetCalls(); - const out = await reconcilePermissionSetProjection(protocol, { ql }); - expect(out.projectedFromMetadata).toBe(1); - expect(ql.calls.update).toBe(1); - expect(JSON.parse(ql.permRows[0].object_permissions)).toEqual({ crm_lead: { allowRead: true, allowEdit: true } }); - }); - - it('an overlay whose label drifted still gets its UPDATE', 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()), - }); - await reconcilePermissionSetProjection(protocol, { ql }); - ql.permRows[0].label = 'Stale Label'; - - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.calls.update).toBe(1); - expect(ql.permRows[0].label).toBe('Organization Administrator'); - }); - - it('only the DRIFTED overlay is written — the other 19 cost nothing', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - for (let i = 0; i < 20; i += 1) { - ql.metaRows.push({ - id: `m${i}`, type: 'permission', name: `env_set_${i}`, state: 'active', - organization_id: null, metadata: JSON.stringify(envBody({ name: `env_set_${i}` })), - }); - } - await reconcilePermissionSetProjection(protocol, { ql }); - const target = ql.permRows.find((r: any) => r.name === 'env_set_7'); - target.label = 'drifted'; - - ql.resetCalls(); - const out = await reconcilePermissionSetProjection(protocol, { ql }); - expect(out.projectedFromMetadata).toBe(1); - expect(ql.calls.update).toBe(1); - expect(target.label).toBe('Organization Administrator'); - }); -}); - -/** - * ⛔ [#11097] The `customized` stamp is written by this projector and is - * deliberately NOT part of `recordDiffersFromBody` — it is provenance, not - * definition, so no metadata body can declare it. That makes it the one column - * a naive "skip when the body matches" would stop maintaining, while the Setup - * list badges on it and the reset action reads it. These pin the flag's own - * comparison term. - */ -describe('#11097 — the `customized` stamp is still maintained', () => { - const pkgRow = (over: Record = {}) => ({ - id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', - package_id: 'com.example.crm', active: true, - ...permissionSetRowFields(envBody()), - ...over, - }); - - it('an overlay appearing over a package row still STAMPS `customized` on a matching row', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - // The record's facets already equal the overlay body — only the flag differs. - ql.permRows.push(pkgRow({ customized: false })); - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody()), - }); - - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows[0].customized).toBe(true); - expect(ql.calls.update).toBe(1); - }); - - it('a row that predates the flag (NULL) is still stamped, not read as already-customized', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push(pkgRow({ customized: null })); - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody()), - }); - - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows[0].customized).toBe(true); - }); - - it('a package row whose flag is ALREADY true costs no write', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push(pkgRow({ customized: true })); - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody()), - }); - - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows[0].customized).toBe(true); - expect(ql.calls.update).toBe(0); - }); - - it('an env-authored row carrying a stale flag is still CLEARED', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - // `managed_by:'admin'` — the flag does not apply; a stale `true` must go. - ql.permRows.push({ - id: 'ps_env', name: 'organization_admin', managed_by: 'admin', active: true, - customized: true, ...permissionSetRowFields(envBody()), - }); - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody()), - }); - - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows[0].customized).toBe(false); - expect(ql.calls.update).toBe(1); - }); -}); - -/** - * ⛔ [#11097] The in-memory evaluator registry sync is NOT a database round trip, - * and gating it on "a write happened" is what compare-before-write would - * otherwise have done to it. The evaluator resolves permission sets - * registry-first, so a steady-state boot that skipped the sync would leave it - * enforcing the stale DECLARED body while the record and Setup showed the - * overlay. - */ -describe('#11097 — the evaluator registry is synced even when nothing was written', () => { - it('an overlay whose record already matches STILL syncs the evaluator registry', async () => { - const ql = makeQl(); - const declared = { organization_admin: envBody({ label: 'Shipped Baseline' }) }; - const protocol = makeProtocol(ql, declared); - const registered: any[] = []; - const metadata = { - registerInMemory: (type: string, name: string, body: any) => { registered.push({ type, name, body }); }, - }; - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody({ label: 'Overlay Wins' })), - }); - - // First boot creates the record AND syncs. - await reconcilePermissionSetProjection(protocol, { ql, metadata }); - expect(registered).toHaveLength(1); - - // Steady state: the record already matches, so NO update is issued — the - // sync must happen anyway. - registered.length = 0; - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql, metadata }); - expect(ql.calls.update).toBe(0); - expect(registered).toHaveLength(1); - expect(registered[0].body.label).toBe('Overlay Wins'); - }); -}); - -/** - * ⛔ #3807's conflation class at the seam the batched read newly exposes: a read - * that CANNOT ANSWER is not the answer "no such record". The per-item shape was - * accidentally immune (a failed read fell through to an insert that failed too, - * for that ONE name); a batched read speaks for the whole set at once. - */ -describe('#11097 — a read that CANNOT ANSWER is not the answer "none exist"', () => { - it('a throwing existence read does NOT re-create records that are already projected', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - for (let i = 0; i < 4; i += 1) { - ql.metaRows.push({ - id: `m${i}`, type: 'permission', name: `env_set_${i}`, state: 'active', - organization_id: null, metadata: JSON.stringify(envBody({ name: `env_set_${i}` })), - }); - } - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows).toHaveLength(4); - - // Every sys_permission_set read now fails — batched and per-item alike. - const origFind = ql.find.bind(ql); - (ql as any).find = async (object: string, q: any) => { - if (object === 'sys_permission_set') throw new Error('fake driver: read unavailable'); - return origFind(object, q); - }; - const warns: string[] = []; - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql, logger: { warn: (m: string) => warns.push(m) } }); - - expect(ql.calls.insert).toBe(0); // ⛔ no blind insert - expect(ql.permRows).toHaveLength(4); // ⛔ nothing re-created - expect(warns.some((w) => w.includes('batched seed existence read failed'))).toBe(true); - }); - - it('an EMPTY result set is still trusted as "none exist" — the first boot depends on it', 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).toHaveLength(1); - }); -}); - -/** - * [#22169] The heal's layered read is cached per NAME for the pass — and a - * backfill is the one write in this pass that changes that name's answer, so it - * drops the cached one. Without the drop, a second row of a name this pass had - * just backfilled would see the pre-write "no definition" answer and backfill - * AGAIN, replacing the definition with whichever duplicate the page served last. - * This suite's double has no SchemaRegistry, which is the only shape that reads - * the layered item at all. - */ -describe('#22169 — the per-name layered read is re-asked after this pass backfills the name', () => { - it('a second row of a just-backfilled name heals to the backfilled body instead of backfilling again', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - const layeredReads: string[] = []; - const layered = protocol.getMetaItemLayered.bind(protocol); - (protocol as any).getMetaItemLayered = async (req: { type: string; name: string }) => { - layeredReads.push(req.name); - return layered(req); - }; - const legacy = (id: string, systemPermissions: string[]) => ({ - id, name: 'support_agent', managed_by: 'admin', active: true, - ...permissionSetRowFields(envBody({ name: 'support_agent', systemPermissions })), - }); - ql.permRows.push(legacy('ps_first', ['support.use']), legacy('ps_second', ['support.other'])); - - const out = await reconcilePermissionSetProjection(protocol, { ql, logger: { warn: () => {} } }); - - expect(protocol.saves, 'ONE definition is backfilled for the name').toHaveLength(1); - expect(out.backfilledIntoMetadata).toBe(1); - expect(layeredReads, 'asked for the first row, and again after the backfill wrote').toEqual(['support_agent', 'support_agent']); - expect(out.driftHealed).toBe(1); - for (const row of ql.permRows) { - expect(JSON.parse(row.system_permissions), `${row.id} carries the backfilled body`).toEqual(['support.use']); - } - }); -}); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts deleted file mode 100644 index 251b44ecf50..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ /dev/null @@ -1,1821 +0,0 @@ -// 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'`) keep their shipped - * declaration as the BASELINE (boot seeding / publish materialization). The - * 2026-07-14 direction confirmation that used to sit here — "the environment - * customizes them through the platform's standard ADR-0005 metadata overlay" - * — is **RETIRED** (ADR-0094 D5-R, 2026-08-09, recording ruling A, executed by - * #6858). Commit ee58392e1 rolled `permission` back to `allowEnvironmentOverlay: false` - * (the nine-type rollback), so ADR-0005's security row is enforced again: an overlay of the - * authorization surface IS the "silent privilege drift" it excludes. - * - * What that means for THIS file, per write point: - * - * - the middleware still TRANSLATES every data-door write into a metadata - * write — that half is unchanged. Whether the translated write is accepted - * is ADR-0005's tier gate, decided by the target's ARTIFACT provenance: - * a CODE-DECLARED set (`*.permission.ts`, a stack's `permissionSets`) is - * refused with 403 `NOT_OVERRIDABLE`; a set whose definition lives only in - * `sys_metadata` — created through the data door, or authored and - * published through the METADATA door (ADR-0070) — rides - * `allowRuntimeCreate`, still `true`, and keeps working; - * - that refusal used to be deliberately LEFT TO THE PRODUCER. **RETIRED** - * by the maintainer ruling of 2026-08-24 (verbatim, untranslated: 「同意 - * 第一步(创业阶段,Salesforce 式)」) — LOCK THE BASE, CLONE TO CUSTOMIZE. - * The data door now refuses a save that targets a package-declared set - * ITSELF, before translating it, with a message that names the clone path - * ({@link createPermissionSetWriteThrough}'s insert and update legs throw - * it; the `restore` leg consults the same lock before its re-author and - * reports the refusal on the durability channel instead — commit 9cfc1f7e9, argued - * at the leg; the rule and its reasoning live in - * `packaged-permission-set-lock.ts`). Two - * measured reasons the producer could not carry it alone: the producer's - * ADR-0005 tier gate is exactly what the documented - * `OS_METADATA_WRITABLE=permission` operator hatch switches off — on a - * deployment running with the hatch there was no refusal at all, which is - * the silent fork this ruling closes — and its message names the type's - * overlay flag rather than anything an admin can act on. - * - * That is NOT the parallel-allowlist failure Prime Directive #8 exists to - * prevent: the lock does not re-derive `isArtifactBacked`, it reads the - * engine SchemaRegistry — the one source this plugin already calls - * "package-declared" ({@link readDeclaredBody} here, and - * `permission-set-drift.ts`'s declared sweep). The `managed_by` column - * stays measurably NOT that fact and the lock still never reads it — - * `member_default`'s row is `managed_by:'admin'`, `twodoors_pkgset`'s is - * `managed_by:'package'`, and a `managed_by:'package'` row with no artifact - * behind it (published through the METADATA door, ADR-0070) keeps editing - * in place as ADR-0094 D5-R's surviving `allowRuntimeCreate` neighbour; - * - the two write points that CATCH a failed metadata write - * ({@link createPermissionSetWriteThrough}'s `restore` leg and - * {@link reconcilePermissionSetProjection}'s backfill) keep catching: both - * run after the record already exists, so a throw would strand the caller - * with a healthy-looking row and no way to hear about it. They log on the - * durability channel (#4632) and the backfill counts the failure — that is - * the degradation report, not a swallow. Neither targets an artifact-backed - * name in the first place (a packaged definition cannot be trashed, and the - * backfill only runs for names with NO metadata presence at all) — and the - * `restore` leg no longer relies on that fence alone: it consults the - * packaged-permission-set lock before re-authoring, refusing the mint on - * the same durability channel (commit 9cfc1f7e9). - * - * Cross-package composition stays a POSITION concern (bind several packages' - * sets to one position); package-first authoring (ADR-0070) gives - * runtime-created sets a home package. - */ - -import { PermissionSetSchema } from '@objectstack/spec/security'; -import { seedCtx } from './organization-scope.js'; -import { type SeedWriteRefusals } from './write-refusals.js'; -import { buildExistingByName, type ExistingByNameIndex } from './seed-name-lookup.js'; -import type { PermissionSetNameCollisionDiagnostic } from './permission-set-name-collision.js'; -import { - ENV_PROJECTION_MARKER, - assertPermissionSetNotPackageDeclared, - classifyPackagedPermissionSet, - type LayeredProbe, -} from './packaged-permission-set-lock.js'; -import { PermissionSetNameConflictError } from './errors.js'; - -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}`; -} - -/** - * The three write helpers take an optional `organizationId`, threaded into the - * execution context so reads route through `SqlDriver.applyTenantScope` and - * writes are stamped with that organization. Omitting it reproduces the - * pre-existing organization-less behaviour EXACTLY, which is what the ADR-0094 - * environment door still wants (its residue is deliberately outside the - * per-organization catalog work) and what a `single`-posture deployment wants. - * The PACKAGE door passes an organization under a walled posture — see - * `per-organization-catalog.ts`. - */ -export async function tryFind(ql: any, object: string, where: any, limit = 100, organizationId?: string): Promise { - try { - const rows = await ql.find(object, { where, limit }, { context: seedCtx(organizationId) }); - return Array.isArray(rows) ? rows : []; - } catch { return []; } -} - -/** - * Is this record still on the table? THREE outcomes, not two. - * - * {@link tryFind} deliberately collapses a failed read into `[]` — right for - * every caller that wants "the rows I could see", wrong for anyone who has to - * decide whether a row is GONE. `[]` from that helper means "no row matched OR - * the read blew up", and a caller reading it as "gone" reports a deletion that - * an outage invented. The retired seeder's organization enumeration - * stated the same rule for the same shape: zero organizations is "nothing to - * seed", an unreadable table is "we do not know". - * - * So `unknown` is its own answer and the caller must decide what it means — - * ⛔ never folded into `gone` here. The context and the read are otherwise - * IDENTICAL to `tryFind`'s (no `transaction` key, so an ambient handle still - * carries it), because the difference under test is the catch and nothing else. - */ -async function probeRecordSurvival( - ql: any, - object: string, - id: unknown, -): Promise<'gone' | 'present' | 'unknown'> { - try { - const rows = await ql.find(object, { where: { id }, limit: 1 }, { context: seedCtx() }); - return (Array.isArray(rows) ? rows : [])[0] ? 'present' : 'gone'; - } catch { return 'unknown'; } -} -/** - * ⛔ The `catch` RECORDS the refusal before it answers, when the caller passed - * a log to record into. - * - * Answering `null`/`false` alone is what made a refused write - * indistinguishable from "nothing to do": the caller's `seeded` counter never - * increments, the pass returns normally, and the boot reports a successful - * seed of zero rows. `refusals` is optional so the callers that already report - * their own outcome honestly — the package/environment door, whose publish - * answers `success: false` with a reason when it materialized nothing — are - * unchanged. It is passed by the boot CATALOG seeders, which had no such - * channel. Never a rethrow either way: these helpers report, they do not - * decide whether the deployment boots. - */ -export async function tryInsert( - ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals, -): Promise { - try { - return await ql.insert(object, data, { context: seedCtx(organizationId) }); - } catch (e) { refusals?.record(object, e); return null; } -} -export async function tryUpdate( - ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals, -): Promise { - try { - await ql.update(object, data, { context: seedCtx(organizationId) }); return true; - } catch (e) { refusals?.record(object, e); return false; } -} - -export interface ProjectionLogger { - info?: (m: string, meta?: Record) => void; - /** - * The GUARANTEED channel (#9754). `error` below stays optional — hosts do - * inject reduced sinks — so `warn` is where a durability report lands when - * `error` is absent, and a fallback that may itself be missing is not a - * fallback: `{ info }` alone used to be a legal sink here, and against it the - * reconcile summary's "N FAILED backfill(s)" printed nothing at all, while - * the `info` "reconciled" line was skipped too — the sink heard neither. - * Non-optional is what makes that silence unrepresentable instead of merely - * discouraged. - * - * ⛔ Do NOT "simplify" this by making `error` required instead — that - * forecloses the reduced sinks hosts legitimately pass (#9754 option C, - * measured and rejected). - * - * ⚠️ Call sites still spell the fallback `logger?.warn?.(…)`. That `?.` is not - * doubt about this declaration — it is the backstop for hosts the TYPE cannot - * reach (a plain-JS embedder, or a cast). Dropping it was measured: a sink - * that lies about its shape then throws `logger?.warn is not a function` - * INSIDE the per-row durability catch, aborting the very batch this function - * promises never to stop. Silence for a lying host is the lesser failure; the - * guarantee this member adds is at AUTHORING time, where it belongs. - */ - warn: (m: string, meta?: Record) => void; - /** - * Durability-degradation channel (AGENTS.md "Degradation log levels", #4632): - * a metadata write that was supposed to land and did not is an `error`, not a - * `warn` — nothing looks broken afterwards, which is exactly why the failure - * has to be loud. - * - * Signature matches `Logger.error` in `@objectstack/spec/contracts` - * (`message, error?, meta?` — the cause is its own argument), so the kernel - * logger satisfies this interface as-is. - */ - error?: (m: string, error?: Error, meta?: Record) => void; -} - -/** Aggregated outcome of a projection pass (shared with the boot seeders). */ -export interface PermissionSeedOutcome { - seeded: number; - updated: number; - /** - * Rows this seeder OWNS whose stored columns already equalled what it would - * have written, so no `UPDATE` was issued (#10946). - * - * ⚠️ Read this together with {@link updated}, never instead of it. "The row - * now matches the declaration" is `seeded + updated + unchanged`; `updated` - * alone means "a write was needed AND landed". A consumer that asks the - * first question while reading only the second reports a converged row as a - * failure — which is exactly what the ADR-0086 P2 publish materializer did - * before this counter existed, because an unconditional re-write made the - * two questions accidentally identical. They are not, and the boot loops - * that stopped re-writing unchanged rows are why. - */ - unchanged: number; - /** - * Names this pass DECLINED to touch because their record could not be read - * (#10946). Distinct from `unchanged` in every way that matters: nothing was - * compared, nothing was reconciled, and the declaration may or may not be - * materialized. Never fold it into a success count. - */ - unreadable: number; - skippedEnvAuthored: number; - skippedForeign: number; - /** - * [#18571] Declarations refused for want of an owning package - * (`_packageId`/`packageId` both absent), so no `managed_by:'package'` row - * was written — the ADR-0086 D3 ambiguity the refusal exists to prevent. - * - * ⚠️ Required, not optional: it is a REFUSAL count, and an absent key on a - * pass that refused reads exactly like a pass that had nothing to refuse. - * Every door that returns this outcome initializes it, so a caller may add - * it into the accounting unconditionally — `seeded + updated + unchanged + - * skippedEnvAuthored + skippedForeign + skippedUnowned + unreadable` is the - * number of named declarations this pass read. (Pre-existing caveat, - * unchanged: a write the engine REJECTS increments no counter — `tryInsert` - * answers `null` and `tryUpdate` `false`, and the refusal travels on - * `SeedWriteRefusals` instead.) Before this counter existed that sum was - * short by every unowned declaration, and the outcome said nothing at all - * about them. - * - * Named for its `CapabilitySeedOutcome.skippedUnowned` sibling on the - * capability axis, which counts the same refusal at the same ADR-0086 D3 - * boundary — ⛔ not a new vocabulary for one seeder. - */ - skippedUnowned: number; - /** Records retired because their definition was deleted from metadata. */ - deleted?: number; - /** - * [#17516] The diagnostic for each set counted in {@link skippedForeign} — - * the declaration was dropped WHOLE because another package owns that set - * name (ADR-0086 D4). - * - * ⚠️ This is the read-back half of that refusal, not a duplicate of the log - * line. The counter alone is what made the drop invisible: a caller holding - * it knows a number and nothing about which sets, which packages, or what to - * do about it. Absent — never `[]` — when the pass hit no collision, so a - * consumer can tell "none" apart from "this pass does not report them". - */ - collisions?: readonly PermissionSetNameCollisionDiagnostic[]; -} - -/** - * 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'); - -/** - * [#11097] Read the `customized` COLUMN as a definite boolean. - * - * ⛔ Deliberately NOT {@link asBool}, which answers `true` for `undefined` and - * `null` because its subject (`active`) defaults to on. `customized` defaults - * to **off** (`Field.boolean({ defaultValue: false })` on - * `sys_permission_set`), and a row that predates the flag stores NULL — so - * `asBool` would read every such row as "already customized" and the - * compare-before-write below would then decline to stamp the flag on exactly - * the legacy rows that need it. - * - * Absent, null, 0, '0', false and 'false' are all "not customized"; everything - * else is customized. - */ -const customizedFlag = (v: any): boolean => - !(v === undefined || v === null || v === false || v === 0 || v === '0' || v === 'false' || v === ''); - -/** - * `sys_permission_set` columns that are ROW STATE, not part of the metadata - * DEFINITION — the spec declares no such key, so they must never travel into a - * metadata body (#4669). Each entry maps a column to the normalizer the record - * expects, so the data door can write it straight onto the record. - * - * `active` is the on/off switch the Setup list views filter on and the two - * lifecycle actions toggle (`bodyExtra: { active: true|false }` in - * `objects/sys-permission-set.object.ts`). It is runtime state OF THE RECORD, - * never a capability boundary its author declared — which is precisely why it - * is not, and should not be, a key on `PermissionSetSchema`. - */ -const ROW_STATE_COLUMNS: Readonly any>> = { - active: asBool, -}; - -/** - * The body keys the permission SPEC declares, read from the Zod schema's own - * shape — derived, never transcribed (#4669). - * - * `sys_permission_set` is a projection of a metadata definition, but the TABLE - * carries columns the definition does not: {@link ROW_STATE_COLUMNS}, the - * timestamps, and the `managed_by` / `package_id` / `customized` provenance. - * Until #4001 a row-derived body that dragged those along still parsed — - * `PermissionSetSchema` stripped the extras silently — so handing a whole row - * to `saveMetaItem` appeared to work. #4001 sealed the schema `.strict()`, and - * every such body began failing validation with `[invalid_metadata] … - * Unrecognized key(s) on this permission set: 'active'`, which took the - * ADR-0094 D4 boot backfill to a 100% failure rate. - * - * Derived from `.shape` rather than hand-listed because a literal list here - * would go stale the moment the spec grows a key — silently dropping it from - * every projected body, i.e. reproducing the very defect this fixes one layer - * over. `permission-set-projection.test.ts` additionally pins that every key - * the row→body seams can emit is one the spec declares, so a spec RENAME fails - * a test instead of quietly losing the value at runtime. - * - * Resolved on first use, not at module load: `PermissionSetSchema` is a - * {@link lazySchema} proxy and touching `.shape` at import time would - * materialize it for every process that merely loads this module. - */ -let cachedSpecBodyKeys: ReadonlySet | null = null; -export function permissionSpecBodyKeys(): ReadonlySet { - return (cachedSpecBodyKeys ??= new Set( - Object.keys((PermissionSetSchema as unknown as { shape?: Record }).shape ?? {}), - )); -} - -/** - * Keep only what the permission spec declares. THE choke point every row→body - * seam passes through, so no storage column can reach `saveMetaItem` — neither - * from a live row nor from a body STORED before #4001 (data at rest written - * while the schema still stripped the extras can still carry `active`). - */ -function pickSpecDeclaredKeys(candidate: Record): Record { - const keys = permissionSpecBodyKeys(); - const body: Record = {}; - for (const [key, value] of Object.entries(candidate)) { - if (keys.has(key)) body[key] = value; - } - return body; -} - -/** - * 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. - * - * The result is filtered through {@link pickSpecDeclaredKeys}: a DEFINITION is - * what the spec declares, and nothing else off the row goes with it (#4669). - */ -export function permissionSetBodyFromRow(row: any): any { - const adminScope = row?.admin_scope ? parseMaybeJson(row.admin_scope, undefined) : undefined; - return pickSpecDeclaredKeys({ - 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 } : {}), - }); -} - -/** - * The row-state columns a data-door payload carries, normalized for the - * record — `null` when it carries none. These bypass the metadata store - * entirely: they are the record's own state (#4669). - */ -export function pickRowStateColumns(payload: any): Record | null { - if (!payload || typeof payload !== 'object') return null; - const out: Record = {}; - for (const [col, normalize] of Object.entries(ROW_STATE_COLUMNS)) { - if (col in payload) out[col] = normalize(payload[col]); - } - return Object.keys(out).length > 0 ? out : null; -} - -/** - * Does this data-door payload touch the DEFINITION at all? Identity (`id` / - * `name`) and {@link ROW_STATE_COLUMNS} do not; anything else is treated as a - * definition edit and routes through the metadata store (an unrecognized - * column therefore keeps the pre-#4669 behavior rather than silently skipping - * the write-through). - */ -function touchesDefinition(payload: Record): boolean { - return Object.keys(payload).some( - (k) => k !== 'id' && k !== 'name' && !(k in ROW_STATE_COLUMNS), - ); -} - -/** - * 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). - * - * [2026-08-24 ruling] The constant now LIVES in - * `packaged-permission-set-lock.ts` and is imported above, because the lock - * decides "is this a shipped artifact?" partly by the absence of this marker. - * Two spellings of it would let the two modules disagree about what an echo - * is, and only one of them guards a write door. - */ - -/** 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 the retired `bootstrapDeclaredPermissions` seeded from, - * and the one store the env projection never writes, so it can't be poisoned - * by our own registry sync. It is the body for a declared set with no overlay - * at all, and the reset target when an overlay IS lifted off one — which since - * ADR-0094 D5-R means a LEGACY row (older than commit ee58392e1) removed through the operator - * hatch, not a data-door delete: #6960 measures that delete refusing with 403 - * `NOT_OVERRIDABLE`. - * - * 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 body of items) { - // [#8378] The registered item IS the body — no `{ name, content }` - // envelope to unwrap (`PermissionSetSchema` rejects `content` as an - // unrecognized key, and nothing in the tree produces the envelope). - // 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 a PermissionSet body onto its `sys_permission_set` row from the - * ENVIRONMENT side. - * - * [ADR-0094] The record is a pure projection, so a missing row is CREATED - * (`managed_by:'admin'` — A4 #2920 unified vocab, formerly 'user'; a - * Studio-authored set appears in Setup, where the - * #2867 band-aid declined to create). A PACKAGE-OWNED row is also projected — - * its facets follow the body this pass is handed, while the - * `managed_by:'package'` + `package_id` provenance is PRESERVED: the row - * still belongs to the package. - * - * WHICH body that is, is the part this comment used to get wrong. The - * 2026-07-14 direction confirmation that used to sit here — "an env-scope - * overlay is the platform's standard ADR-0005 customization of a packaged - * definition, and deleting the overlay resets the row to the shipped - * declaration" — is **RETIRED**, in BOTH halves (ADR-0094 D5-R, 2026-08-09; - * the ruling A it records, executed by #6858; the file header above records the same - * retirement). Since commit ee58392e1 rolled `permission` back to - * `allowEnvironmentOverlay: false`: - * - * - **no new overlay of a packaged set can be minted.** A metadata write - * against a CODE-DECLARED (artifact-backed) set — `*.permission.ts`, a - * stack's `permissionSets` — is refused by the producer with 403 - * `NOT_OVERRIDABLE`, so for those names the body reaching here is the - * DECLARED one and the projected facets ARE the shipped declaration. The - * supported way to change them is the one ADR-0086 always named: edit the - * package and re-publish. What survives is the neighbouring - * `allowRuntimeCreate` tier (still `true`) — a set whose definition lives - * only in `sys_metadata`, created through the data door or authored and - * published through the METADATA door (ADR-0070). That tier edits the - * single stored definition IN PLACE: no code-vs-overlay layering, and - * nothing to reset to (ADR-0094 D5-R calls it the surviving neighbour, not - * D5's successor); - * - **`customized` therefore badges LEGACY state, not a supported channel.** - * `supportsOverlay` is unchanged, so an overlay row authored BEFORE the - * rollback still merges overlay-wins at read time - * ({@link projectPermissionMutation} hands us `overlay ?? declared`) and - * this pass still stamps the flag for it. The in-repo corpus had zero such - * rows when commit ee58392e1 measured it; - * - **"delete = reset" must NOT be read back into that.** #6960 measures the - * ordinary delete path refusing to lift exactly such a legacy overlay: on - * an environment-scoped kernel `deleteMetaItem` throws `NOT_OVERRIDABLE` / - * 403 for an artifact-backed target of a non-overridable type BEFORE it - * probes for the row, and a kernel with no `environmentId` refuses the - * same write as `override-artifact` intent — leaving the operator hatch - * (`OS_METADATA_WRITABLE=permission`) as the only documented removal. With - * no overlay to lift — the normal case — the delete is a no-op success and - * the row keeps projecting the declaration. {@link readDeclaredBody} stays - * the correct reset target IF an overlay is ever lifted; it is no longer a - * path the data door can walk. - */ -export async function upsertEnvPermissionSet( - ql: any, - ps: any, - _logger?: ProjectionLogger, - opts?: { - customized?: boolean; - /** - * [#11097] A batched existence oracle built by the CALLER over every name - * it is about to project, so a boot reconciliation pays one read for the - * whole overlay set instead of one per name. - * - * Omitted — the live single-mutation path, which has exactly one name and - * nothing to batch — keeps the per-item read verbatim, including its - * pre-existing treatment of a failed read (fall through to an insert the - * `name` unique index refuses). Tightening THAT path to the oracle's - * decline-on-unknown rule is a real behaviour change on a hot write seam - * and belongs to its own card, not to a round-trip fix. - */ - existing?: ExistingByNameIndex; - }, -): Promise { - const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0 }; - if (!ql || typeof ql.find !== 'function' || !ps?.name) return out; - - // [ADR-0094] `customized` marks a PACKAGE-owned row that an env overlay is - // currently shadowing, so the Setup list can badge it "customized" and the - // reset action reads honestly. An env-authored row is not a customization of - // anything (it IS the definition), so the flag only rides on package rows. - const customized = opts?.customized; - - let existing: any; - if (opts?.existing) { - // ⛔ THREE outcomes, not two (`seed-name-lookup.ts`): a read that could not - // ANSWER is not the answer "no such record". Collapsing them here would - // make a boot during a brief database outage conclude that every overlay - // needs creating — and a batched read fails for the WHOLE set at once, - // where the per-item read below could only ever mislead about one name. - const lookup = await opts.existing.get(String(ps.name)); - if (lookup.status === 'unknown') { - out.unreadable += 1; - return out; - } - existing = lookup.status === 'present' ? lookup.row : undefined; - } else { - existing = (await tryFind(ql, 'sys_permission_set', { name: ps.name }, 1))[0]; - } - if (!existing?.id) { - const row = { - id: genId('ps'), - name: ps.name, - ...permissionSetRowFields(ps), - // [#4669] `active` is ROW STATE, never read from the definition body: a - // new record starts active (same as the package seeder and the field's - // own `defaultValue`), and the data door writes the column directly - // afterwards. Taking it from the body would also let a body STORED - // before #4001 — which may still carry a stale `active` — silently - // re-flip a record an admin had just deactivated. - active: true, - // [A4 #2920] Unified provenance vocab: an env/Studio-authored set is - // ADMIN-owned (formerly stamped 'user'). No runtime path branches on the - // value except the 'package' guard, so this is a pure vocab rename. - managed_by: 'admin', - // [#11097] `false`, not `!!customized`, and this is NOT optional once the - // write-skip below exists. This row is being CREATED, so it is - // `managed_by:'admin'` one line up — an env-authored definition, not a - // customization of a packaged one — and the update branch's rule a few - // lines down already encodes exactly that (`existing.managed_by === - // 'package' ? !!customized : false`). Stamping the caller's raw opinion - // here instead (main's behaviour) means a fresh overlay-backed admin row - // is born `customized: true`; the NEXT `projectPermissionMutation` call - // for the same name recomputes `patch.customized` via that SAME rule, - // gets `false`, and — now that writes are equality-gated — that mismatch - // is a real, measured drift, not a no-op. Verified by ablation: with - // `!!customized` here, `#11097 — env overlay reconciliation: round - // trips > does not grow the steady-state round-trip count` and - // `#11097 — drift STILL reconciles > only the DRIFTED overlay is - // written` both fail — the "steady state" boot immediately following a - // creation issues a real corrective UPDATE for every overlay-backed - // admin row, forever, not once historically. So this insert-time fix is - // required for the round-trip claim itself to hold, not an unrelated - // rider — the equality check needed on the write side implies the - // matching write on the create side. - ...(customized !== undefined ? { customized: false } : {}), - }; - const created = await tryInsert(ql, 'sys_permission_set', row); - if (created) { - out.seeded += 1; - // [#11097] The oracle is a SNAPSHOT taken before the caller's loop, so it - // cannot see this insert. Two projections of the same name in one pass - // would otherwise each attempt an insert, and the second would be refused - // by the `name` unique index instead of taking the update branch below. - opts?.existing?.remember(String(ps.name), row); - } - return out; - } - - // Facets follow the effective body; provenance columns are never touched - // here — a package-owned row keeps its owner while carrying the overlay's - // customization, and an env row keeps its user/platform/legacy provenance. - // [#4669] Facets only — `active` is row state and is NOT projected from the - // body (a projection pass must never re-flip a record's on/off switch). - const patch: Record = { id: existing.id, ...permissionSetRowFields(ps) }; - // Only stamp `customized` on package-owned rows (an overlay of a packaged - // set). For env rows the concept doesn't apply — clear any stale flag. - if (customized !== undefined) { - patch.customized = existing.managed_by === 'package' ? !!customized : false; - } - - // [#11097] Write only when the stored row actually differs from the patch. On - // every boot the reconciler re-projected each env overlay unconditionally, - // paying a remote `UPDATE` per overlay to store what was already there. - // - // ⛔ THE COMPARISON MUST COVER EVERY COLUMN THE PATCH WRITES, and - // `recordDiffersFromBody` alone does NOT: it compares the facet/display - // columns, and `customized` is deliberately not among them (it is provenance, - // not definition — no metadata body can declare it). Skipping on the facets - // alone would therefore stop maintaining a flag the Setup list badges on and - // the reset action reads — the record would keep saying "customized" after - // its overlay was lifted, or never say it after one appeared. So the flag - // gets its own term, evaluated against the SAME `existing.managed_by` - // condition the patch uses, and only against the patch's own value: when the - // caller passes no `customized` opinion the patch carries no such key and - // there is nothing to compare. - // - // ⚠️ EQUALITY decides, never presence. Replacing this with an unconditional - // `unchanged` would give the reconciler a perfect round-trip count while it - // silently stopped healing anything. - const facetsDiffer = recordDiffersFromBody(existing, ps); - const customizedDiffers = customized !== undefined - && customizedFlag(existing.customized) !== customizedFlag(patch.customized); - if (!facetsDiffer && !customizedDiffers) { - out.unchanged += 1; - return out; - } - - 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. - * - * [#21789] ⭐ The echo also states its PROVENANCE, and it states the lock's - * verdict. The metadata manager's get is registry-first, so the protocol's - * layered read serves this echo as the item's `code` layer, and the envelope's - * `provenance` is read off that layer. Unstamped, every set with an overlay row - * (an org's own set, a clone, a set in a writable runtime package) reported a - * code layer with no provenance, which is how a client reads "a code package - * ships this" (objectui's permission-matrix editor rendered such a set locked - * while every write door accepted the save). So the echo carries - * `_provenance: 'org'` exactly when {@link classifyPackagedPermissionSet}, the - * classifier both write doors ask, answers `org` for the name: the reported - * state and the enforced state are one judgment. A `packaged` verdict (a - * legacy overlay of a code-shipped set) or an `unknown` one leaves the echo - * unstamped, as before, so the read keeps reporting the lock the doors keep - * enforcing. - * - * [#23233] ⭐ A tenant-authored echo also names its OWNER: `ownerPackageId`, - * the package its stored row is bound to, as the protocol's by-name read - * serves it ({@link readServedPackageBinding}). The security catalog reads a - * set the engine registry does not hold from this echo — on an environment - * kernel the protocol hydrates no stored row into the engine registry — and - * takes its owner from `_packageId` alone. Unstamped, a set published into a - * writable runtime package was served as UNOWNED while `getMetaItem` named the - * package, so the suggestion collector skipped its `isDefault: true` and no - * `everyone` suggestion was ever recorded (ADR-0090 D5: packages suggest, - * admins confirm). The owner is carried, never derived: `stripDecorations` - * still removes whatever `_packageId` the body arrived with (the layered read's - * `overlay` layer is the stored document and carries none), and only the - * protocol's answer is stamped back. A `packaged` or `unknown` verdict carries - * none, as before — a code-shipped set's owner is its artifact's, which the - * catalog reads from the engine registry ahead of this echo. - * - * 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, - tenantAuthored = false, - ownerPackageId?: string, -): Promise { - try { - if (!metadata || typeof metadata.registerInMemory !== 'function' || !name) return; - if (overlayBacked && body?.name) { - metadata.registerInMemory('permission', name, { - ...stripDecorations(body), - ...(tenantAuthored ? { _provenance: 'org' } : {}), - ...(tenantAuthored && ownerPackageId !== undefined ? { _packageId: ownerPackageId } : {}), - [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 */ } -} - -/** - * [#23233] The package the stored row a by-name read SERVES for `name` is - * bound to: the `_packageId` the protocol's single-item read states on the - * item from that row's `package_id` column. `undefined` when the row is bound - * to no package, or the protocol has no single-item read (minimal embeddings, - * unit-test stubs), which keys no row by package either. - * - * ⛔ The one read of that binding in this module — the data door's update leg - * ({@link createPermissionSetWriteThrough}, #21861) and the registry echo - * ({@link syncEvaluatorRegistry}) both ask it. Never from a patch, the - * projected record or a registry item: those are copies, and the row is the - * fact. A read that fails is not caught here; each caller decides what a - * binding it could not read means for its own write. - */ -async function readServedPackageBinding(protocol: any, name: string): Promise { - if (typeof protocol?.getMetaItem !== 'function') return undefined; - const served = await protocol.getMetaItem({ type: 'permission', name }); - const packageId = served?.item?._packageId; - return typeof packageId === 'string' && packageId !== '' ? packageId : undefined; -} - -/** 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, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 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 } | null | undefined, - /** - * [#11097] Forwarded to {@link upsertEnvPermissionSet} so a boot pass over - * many overlay names pays ONE existence read for the whole set. The live - * mutation projector passes nothing and keeps its per-item read. - */ - opts?: { existing?: ExistingByNameIndex }, -): Promise { - if (evt?.type !== 'permission' || evt.state === 'draft' || !evt.name) return null; - const { ql, metadata, logger } = deps; - let body: any = null; - let overlayBacked = false; - // [#21789] The layered read, handed to the lock's classifier below as its - // second source, exactly as the write doors hand it theirs. - let layeredProbe: LayeredProbe | undefined; - if (protocol && typeof protocol.getMetaItemLayered === 'function') { - // [ADR-0131 D6] The environment → code read: no organization is part - // of a metadata read, so the projection reads what every reader reads. - const layered = await protocol.getMetaItemLayered({ type: 'permission', name: evt.name }); - // `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) { - layeredProbe = { status: 'read', envelope: layered }; - 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, { - customized: overlayBacked, - ...(opts?.existing ? { existing: opts.existing } : {}), - }); - // [#11097] ⚠️ `unchanged` COUNTS HERE, and leaving it out was a real defect - // waiting on the write-skip above. This sync is an IN-MEMORY registry write, - // not a database round trip, and the evaluator resolves permission sets from - // that registry REGISTRY-FIRST — so on every steady-state boot (records - // already matching, hence no `UPDATE`) an overlay-backed name would have gone - // unsynced, and the evaluator would have kept enforcing the stale DECLARED - // body while the record and the Setup list showed the overlay. The gate is - // "the record is now in the desired state", which is `seeded + updated + - // unchanged` — never "a write happened". A FAILED write still leaves all - // three at zero and still skips the sync, exactly as before. - if (out.seeded + out.updated + out.unchanged > 0) { - // [#21789] The echo's provenance is the lock's verdict for the name — see - // {@link syncEvaluatorRegistry}. - const tenantAuthored = overlayBacked - && classifyPackagedPermissionSet(evt.name, ql, layeredProbe).status === 'org'; - // [#23233] …and a tenant-authored echo names the package its stored row is - // bound to, read from the protocol — see {@link syncEvaluatorRegistry}. - let ownerPackageId: string | undefined; - if (tenantAuthored) { - try { - ownerPackageId = await readServedPackageBinding(protocol, evt.name); - } catch (e) { - // Functional, not durability: nothing persisted is lost and nothing - // grants differently — the echo is registered unowned, exactly as it - // was before it carried an owner, so it still enforces the new body. - // What is missing is said once, with what restores it. - logger?.warn?.( - '[security] the package binding of this permission set could not be read, so its registry echo ' + - 'names no owning package: the security catalog serves the set as unowned, and an `isDefault: true` ' + - 'set records no audience-binding suggestion (ADR-0090 D5), until the name is projected again — ' + - 'the next save, publish or boot re-reads the binding. Enforcement is unaffected.', - { name: evt.name, error: String((e as Error)?.message ?? e) }, - ); - } - } - await syncEvaluatorRegistry(metadata, evt.name, body, overlayBacked, tenantAuthored, ownerPackageId); - } - 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. - * - * Row-state columns ({@link ROW_STATE_COLUMNS}) are deliberately NOT merged — - * they are the record's state, applied to the record itself by the - * write-through (#4669). The merged result is filtered through - * {@link pickSpecDeclaredKeys} because `base` may be a body STORED before - * #4001 sealed the schema and can still carry a stripped-at-the-time `active`; - * without the filter that legacy row would make every subsequent data-door - * edit of the set fail spec validation. - */ -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 ('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 pickSpecDeclaredKeys(body); -} - -/** - * Effective (layered, overlay-wins) body for a record's name, else the row - * itself. - * - * `preReadLayered` lets a caller that ALREADY holds the layered envelope for - * this name hand it over instead of paying a second round trip. The write - * door's package-provenance pre-pass reads exactly this envelope one step - * earlier, so threading it through keeps the update path at the same number of - * metadata reads it had before the lock existed. `undefined` means "I have - * none" and restores the original read verbatim, including its fallback. - */ -async function effectiveBodyForRow( - protocol: any, - ql: any, - row: any, - preReadLayered?: unknown, -): Promise { - try { - const layered = preReadLayered !== undefined - ? preReadLayered as any - : 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 provenance- - * forging gate, the delegated-admin gate, and the ordinary CRUD/FLS checks - * have all passed before a write is translated. The driver write never - * executes — `opCtx.result` is the projected record — so no data-plane path - * can desync record from metadata. - * - * A PACKAGE-OWNED row is writable through here too: its update/delete - * translate into env-scope overlay operations (customize / reset) — the - * ADR-0005 layering carries the two doors now, instead of a flat refusal. - * System-context writes pass through untouched: they ARE the projector / - * seeder channel. Kernels without a capable metadata protocol (minimal - * embeddings, unit-test stubs) pass through for env rows and keep the legacy - * two-doors refusal for package rows — with no overlay layer there is - * nothing to carry a customization. - */ -export function createPermissionSetWriteThrough( - deps: WriteThroughDeps, -): (opCtx: any, next: () => Promise) => Promise { - const { ql, logger } = deps; - - /** - * Read the layered envelope for ONE name, reporting whether the read - * ANSWERED — never collapsing a failed read into "no artifact". - * - * The envelope is the lock's second artifact source (kernels with no readable - * SchemaRegistry, the same fallback `projectPermissionMutation` uses), and it - * is the same envelope the update leg then merges its column patch into — so - * this read replaces the one `effectiveBodyForRow` used to make rather than - * adding to it. - */ - const probeLayered = async ( - protocol: any, - name: string, - ): Promise<{ probe: LayeredProbe; envelope: unknown }> => { - try { - const envelope = await protocol.getMetaItemLayered({ type: 'permission', name }); - return { probe: { status: 'read', envelope }, envelope }; - } catch (e) { - return { - probe: { status: 'failed', reason: String((e as Error)?.message ?? e) }, - envelope: undefined, - }; - } - }; - - /** - * [#21861] The `saveMetaItem` argument that writes an update back into the - * stored row it edits: `{ packageId }` when that row is bound to a package, - * nothing when it is not. - * - * A `sys_metadata` row is keyed `(org, type, name, package_id)`, and a save - * that names no package targets the package-less row. So an update leg that - * named none, for a set whose only row is bound to a writable runtime - * package (`PUT /meta/permission/:name?package=`), minted a second, - * package-less row carrying the edit beside the untouched package-bound one: - * two active rows for one name. - * - * The row is the one the update merges its patch into — the `overlay` layer - * of `envelope`, which {@link effectiveBodyForRow} takes as the base — and its - * binding is read from the metadata door's own single-item read, which serves - * that row by the same served-row resolution the layered read uses and states - * the row's `package_id` on it as `_packageId`. ⛔ Never from the patch, the - * projected record (the projector writes no binding onto an admin row), or a - * registry item: those are copies, and the row is the fact. - * - * No `overlay` layer means no stored row, so there is nothing to fork and the - * save stays package-less, exactly as before. Every target reaching this read - * has already passed the lock (verdict `org`), so a code-shipped set never - * gets here. A read that fails is not caught: guessing the binding would - * choose which row the save lands in. - */ - const storedRowPackageArg = async ( - protocol: any, - name: string, - envelope: unknown, - ): Promise<{ packageId?: string }> => { - const overlay = (envelope as { overlay?: unknown } | null | undefined)?.overlay; - if (overlay === null || overlay === undefined) return {}; - // [#23233] The module's one read of the binding — see - // `readServedPackageBinding`, which also answers nothing for a protocol - // with no single-item read. - const packageId = await readServedPackageBinding(protocol, name); - return packageId !== undefined ? { packageId } : {}; - }; - - 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' }); - 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) { - // Single-store kernel: there is no overlay layer to translate a - // package-set customization into, so the legacy ADR-0086 two-doors - // protection applies HERE (the outer security gate delegates the - // update/delete package-row check to this middleware): a - // package-managed row stays read-only through the data door. - // - // [ADR-0094 D5-R] This is no longer the ONLY reason a packaged set is - // read-only through the data door — since commit ee58392e1 a CAPABLE kernel refuses - // an artifact-backed set too, at the protocol's ADR-0005 tier gate. The - // remedy this message names ("edit the package and re-publish") is - // therefore right on every kernel; only the stated cause is specific to - // this branch. The two conditions keep separate wordings deliberately - // (#5240 — one condition, one wording): they are distinguishable and an - // operator needs to know which one they hit. Note also that this branch - // keys on `managed_by:'package'` — a record-provenance heuristic that is - // the best this file can do with no protocol to ask, and measurably NOT - // the artifact-provenance fact the capable path's gate reads. - if (op === 'update' || op === 'delete') { - const targets = await resolveTargetRows(ql, opCtx); - const pkg = targets.find((t: any) => t?.managed_by === 'package'); - if (pkg) { - const err: any = new Error( - `[Security] Access denied: '${String(pkg.name ?? pkg.id)}' is a package-managed permission set ` + - `(managed_by:'package') and this kernel has no metadata overlay layer to carry an environment ` + - `customization — change it by editing its package and re-publishing (ADR-0086 two-doors).`, - ); - err.name = 'PermissionDeniedError'; - err.status = 403; - throw err; - } - } - return next(); - } - - const actor = opCtx?.context?.userId ? String(opCtx.context.userId) : undefined; - const actorArg = actor ? { actor } : {}; - - 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. - // - // [2026-08-24 ruling — lock the base, clone to customize; commit 9cfc1f7e9] This - // leg consults the SAME lock as the insert and update legs — but the - // refusal lands on the durability channel instead of being thrown, and - // the engine un-trash above it stands. The placement is argued, not - // inherited: - // - // - the write the ruling locks is the metadata MINT (`saveMetaItem` - // below): hatch open (`OS_METADATA_WRITABLE=permission`), it would - // author an environment overlay of a packaged set, which - // `reconcilePermissionSetProjection` re-projects onto the record on - // every boot, forever. The check runs BEFORE that write — the same - // "refuse before the write" property the insert and update legs - // have, at this leg's own write. - // - refusing BEFORE the un-trash is not honestly implementable: the - // rows are still trashed there, `resolveTargetRows` reads through - // ordinary `find`, and no trash state exists since #2377 retired - // `enable.trash` (#3146, parked, will define whether trashed rows - // are visible to reads — the natural answer is that they are not). - // A pre-pass would resolve nothing on a real engine while unit - // doubles (no trash concept, rows always visible) showed it green — - // coverage certified by a test the real path would not have. - // - refusing AFTER and compensating (re-trash) is structurally - // unavailable: this middleware's own delete leg guarantees a - // packaged row's record survives the data door (the overlay - // tombstones; the driver delete never runs), it holds no raw driver - // handle, and the trash primitive belongs to unlanded #3146. A - // compensation that can fail turns the refusal into a lie — the - // caller is told "refused" while the state says "restored". - // - a THROW here would misreport a completed engine operation and - // half-apply a multi-row restore — the stranding this leg's - // catch-not-throw disposition exists to avoid (see the file header, - // and the RESTORE case in `permission-set-projection.test.ts`). - // - // With the mint refused, the restored record carries no overlay, so - // boot reconciliation re-projects the DECLARED body onto it — the - // environment converges to the package truth rather than to a fork. - // `unknown` provenance refuses the mint too (fail-closed, the lock's - // own three-verdict contract): accepting on a failed read would reopen - // exactly the hatch-open gap this closes, on every transient. - await next(); - const restored = await resolveTargetRows(ql, opCtx); - for (const row of restored) { - if (!row?.name) continue; - const name = String(row.name); - try { - // `'update'` for the same reason the metadata-door gate passes it - // (`packaged-permission-set-lock-gate.ts`): re-authoring a - // package-declared name is an override of the shipped base, so the - // remedy the refusal teaches is the clone path. - assertPermissionSetNotPackageDeclared(name, ql, 'update', (await probeLayered(protocol, name)).probe); - } catch (lockErr) { - // A REFUSAL, not a failed write — distinct wording from the catch - // below (#5240: one condition, one wording), same channel and the - // same #9754 error→warn fallback chain. - const message = - '[security] restored permission set was deliberately NOT re-authored into metadata — the ' + - 'packaged-permission-set lock refused the write (2026-08-24 ruling: lock the base, clone to ' + - 'customize; an environment overlay of a package-declared set would silently fork it from its ' + - 'package, and provenance that cannot be resolved refuses fail-closed for the same reason). The ' + - 'record is back and lists normally; for a package-declared set, boot reconciliation re-projects ' + - 'the declared body onto it. To customize a packaged set, clone it and edit the clone.'; - if (logger?.error) logger.error(message, lockErr as Error, { name }); - else logger?.warn?.(message, { name, error: String((lockErr as Error)?.message ?? lockErr) }); - continue; - } - try { - await protocol.saveMetaItem({ type: 'permission', name: row.name, item: permissionSetBodyFromRow(row), ...actorArg }); - } catch (e) { - // [#4632 — AGENTS.md "Degradation log levels"] Durability, not - // functionality: the record is back and lists normally, but its - // definition never returned to the metadata store — the stores - // disagree silently until someone notices the set behaves like a - // legacy data-door row. - const message = - '[security] restored permission set was NOT re-authored into metadata (ADR-0094 D3) — the record is ' + - 'back and looks healthy, but the metadata store has no definition for it, so a metadata-driven ' + - 're-provision will not recreate it. Fix: make the record body spec-valid (the error names the ' + - 'offending key) and re-save the set through Setup, or re-run boot reconciliation.'; - // `ProjectionLogger.error` is OPTIONAL, so `logger?.error?.(…)` printed - // NOTHING against a sink that has only `warn` — the durability - // degradation described above would then be reported by nobody at all - // (#9657). Reach for `error`, fall back to `warn`, never to silence. - // The fallback itself is now GUARANTEED BY THE TYPE: `warn` is - // non-optional on `ProjectionLogger` (#9754), so no TS caller can - // hand over a sink this line evaporates against. The `?.` below is - // the backstop for untyped hosts only — see the interface. - if (logger?.error) logger.error(message, e as Error, { name: row.name }); - else logger?.warn?.(message, { name: row.name, error: String((e as Error)?.message ?? e) }); - } - } - 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); - // [2026-08-24 ruling — lock the base, clone to customize] A name an - // installed package DECLARES is not available for an environment - // definition: with the `OS_METADATA_WRITABLE=permission` operator hatch - // open, `saveMetaItem` below would mint a fresh `sys_metadata` overlay - // of a packaged set, and `reconcilePermissionSetProjection` re-projects - // that overlay onto the record on every boot, unconditionally, forever. - // Refused here, before the write, with a message that names the clone - // path. Fail-closed: unresolvable provenance refuses too. - // - // [commit 8f6d83147] ⭐ It runs BEFORE the duplicate-name check below, and the - // order is the fix rather than a tidy-up. A package-declared set has a - // PROJECTED ROW, so its name is duplicate AND locked at once — and the - // admin most likely to arrive here is the one who opened the Clone - // dialog on a packaged set and typed the base set's own name, which is - // the single most likely thing to type. Duplicate-first answered that - // caller `already exists`: true, and the less useful of two true - // refusals — it names no remedy, while `NOT_OVERRIDABLE` is the one - // that explains the actual situation and teaches the clone path. So - // the refusal that carries the remedy speaks first, and the ordinary - // duplicate (verdict `org`) still falls through to the check below - // unchanged. - // - // ⚠️ One case moves besides the packaged one: an ordinary duplicate - // attempted while NO artifact source can answer now takes the lock's - // fail-closed `unknown` refusal (403, retry when the metadata layer is - // readable) instead of the 409. Both are refusals and neither writes, - // which is why the ordering is judged on the case that is reachable on - // purpose; pinned so it is declared rather than incidental. - assertPermissionSetNotPackageDeclared( - name, ql, 'insert', (await probeLayered(protocol, name)).probe, - ); - // [commit 8f6d83147] The duplicate-name refusal carries `UNIQUE_VIOLATION` — the - // wire identity this collision already has when the `name` index - // catches it instead. See `PermissionSetNameConflictError`. - const dup = (await tryFind(ql, 'sys_permission_set', { name }, 1))[0]; - if (dup) throw new PermissionSetNameConflictError(name); - // 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 }); - const record: any = (await projectAndFetch(protocol, name)) ?? { name }; - // [#4669] Row state does not round-trip through metadata — the - // projector created the record with the column default, so an explicit - // `active` in the payload (the Clone action sends one) is applied here. - const rowState = pickRowStateColumns(row); - if (rowState && record.id && await tryUpdate(ql, 'sys_permission_set', { id: record.id, ...rowState })) { - Object.assign(record, rowState); - } - results.push(record); - } - 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; - } - // [#4669] A patch that touches ONLY row state (`active` — what the - // activate/deactivate actions send as `bodyExtra`) is not a definition - // write at all: it goes to the driver untouched, so the column write - // keeps its ordinary engine semantics (history, updated_at, FLS) and no - // spurious "customization" overlay is minted on a packaged set. Routing - // it through the metadata store is what #4001's strict schema rejects. - if (!touchesDefinition(patch)) return next(); - // [2026-08-24 ruling — lock the base, clone to customize] THE write door - // the ruling names. A Studio/API save that targets a package-declared set - // is refused loudly, naming the sanctioned path, so no silent overlay row - // is minted again. Two properties of the placement are load-bearing: - // - // - it runs AFTER the row-state carve-out above, so the activate / - // deactivate actions (a bare `{ active }` patch) keep writing their - // column — switching a packaged set off is not a customization of it - // (#4669), and a lock that swallowed it would break the surface it - // exists to protect; - // - it is a PRE-PASS over every target, not a per-row check inside the - // write loop below: a filtered update spanning several rows must not - // write the first few and then refuse the last, leaving the caller - // with a half-applied edit and one error. - // - // This deliberately pre-empts the producer's own ADR-0005 tier gate, - // reversing the D5-R disposition recorded in this file's header ("that - // refusal is deliberately LEFT TO THE PRODUCER"). Two measured reasons: - // the producer's gate is exactly what the documented - // `OS_METADATA_WRITABLE=permission` hatch switches off — so on the - // deployments this card came from there is no refusal at all — and its - // message then ("the type has not opted into per-org overlay writes") - // named nothing an admin can act on. It is NOT a second copy of - // `isArtifactBacked` (Prime Directive #8): it reads the engine - // SchemaRegistry, the one source this plugin already calls - // "package-declared" in `readDeclaredBody` above. See `packaged-permission-set-lock.ts`. - const layeredByName = new Map(); - for (const row of targets) { - const name = String(row.name); - if (layeredByName.has(name)) continue; - const { probe, envelope } = await probeLayered(protocol, name); - assertPermissionSetNotPackageDeclared(name, ql, 'update', probe); - layeredByName.set(name, envelope); - } - const rowState = pickRowStateColumns(patch); - const results: any[] = []; - for (const row of targets) { - const envelope = layeredByName.get(String(row.name)); - const base = await effectiveBodyForRow(protocol, ql, row, envelope); - const body = mergeRowPatchIntoBody(base, patch); - body.name = row.name; - // [#21861] Into the row the base came from — see `storedRowPackageArg`. - const packageArg = await storedRowPackageArg(protocol, String(row.name), envelope); - await protocol.saveMetaItem({ type: 'permission', name: row.name, item: body, ...packageArg, ...actorArg }); - // Row state rides along on the same patch but lands on the record, not - // in the definition (the projector above never touches these columns). - if (rowState) await tryUpdate(ql, 'sys_permission_set', { id: row.id, ...rowState }); - 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). - // - // The RESET is the designed outcome and stays exactly as it is. What this - // loop owes the CALLER is the ability to tell the two endings apart: the - // outcome below used to start at `true` and could only ever be assigned - // `true` again, so a reset answered the data door with the same - // `{"success":true}` a real deletion does — the difference was named only - // in the `logger.info` line above, which no caller can read. A machine - // surface that reports a refusal as a deletion is the one thing - // "machine-readable surfaces must not lie" forbids, and on a - // security-configuration write it tells an operator a permission set is - // gone while it is still being enforced. - // - // The outcome is therefore the COUNT of target RECORDS that actually went - // — the number arm of this engine method's declared result ("how many rows - // the delete removed"), which the data door turns into an honest - // `success: false` on a 200. Every target removed keeps answering `true`, - // the boolean arm's value for a completed by-id delete, so a real deletion - // is unchanged on the wire. - // - // ⛔ NOT `false` for the reset: the boolean arm's `false` is the driver - // contract's "no row matched", which the data door turns into a - // `404 RECORD_NOT_FOUND` — and the record is still right there, so that - // would trade this lie for a louder one and hand a UI a reason to drop the - // row it must keep showing. - // - // Zero also covers every other way a record survives this loop: a metadata - // delete that lands on a package-owned RECORD (left to the package door), - // a retire whose `ql.delete` failed, and a read-back that could not answer - // at all. All three leave the row in place — or leave us unable to say it - // does not — and none is a deletion the caller should be told happened. - // - // ⛔ The outcome is read off the RECORD, never off the re-projection's own - // `deleted` counter. On a kernel wired to the ADR-0094 AWAITED projector — - // the production shape — `deleteMetaItem` has already retired the record - // by the time control returns here, so the idempotent re-projection below - // finds no row and legitimately reports `deleted: 0` for a delete that - // really happened. Reading that counter would have answered - // `success: false` for EVERY delete, which is the same defect with its - // sign flipped: honest about the packaged set and a fresh lie about the - // ordinary one. `permission-set-projection.test.ts` holds both legs. - let removed = 0; - 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', - }); - if (res && (res.seeded + res.updated) > 0) { - logger?.info?.('[security] permission set reset to its declared baseline (artifact-backed; ADR-0094)', { name: row.name }); - } - // The caller addressed a RECORD by id, so that is the question this - // answers: is it still there? A row re-projected under the same id is - // the reset; a row that is gone is the deletion. - // - // ⛔ The probe has THREE outcomes and this loop counts only ONE of them. - // Probing through `tryFind` — whose catch returns `[]` — would have made - // a FAILED read indistinguishable from "the row is gone", and answered - // `success: true` with the record sitting exactly where it was: this - // card's own lie with a different trigger (an outage instead of a - // packaged set). `unknown` is therefore counted as a SURVIVOR, the - // fail-closed direction: a caller told "not deleted" re-reads the record - // and finds out, a caller told "deleted" has no reason to look again. - // It is also the only direction that cannot be produced by a broken - // read — `gone` now requires a read that actually ANSWERED. - const survival = await probeRecordSurvival(ql, 'sys_permission_set', row.id); - if (survival === 'unknown') { - // Durability channel: the write half of this delete already ran, so - // the row's fate is decided and only our READING of it failed. Say so - // once, naming what the caller was told and how to settle it. - logger?.warn?.( - '[security] could not read the sys_permission_set record back after its metadata delete, so the ' + - 'data-door answer reports it as NOT deleted (fail-closed). The delete itself already ran; re-read the ' + - 'record to see whether it was removed or reset to its declared baseline.', - { name: row.name, id: row.id }, - ); - } - if (survival === 'gone') removed += 1; - } - // `targets` is non-empty here — the early return above sends an empty - // target set to the driver — so this is never the vacuous `true`. - opCtx.result = removed === targets.length ? true : removed; - 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; - /** - * Records whose backfill FAILED — each one is a definition the metadata - * store does not have and now never got. Counted (not just logged) so a - * caller/test can see the degradation without grepping a log: #4669 stayed - * invisible for a whole release precisely because the only signal was a - * `warn` and the counters stayed at zero. - */ - backfillFailed: 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; - // [#4669] `active` is row state, not a spec key — a definition body cannot - // declare it, so a record is never "drifted" on account of it. - 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 — THAT record, by its id, so every - * row of a duplicated name converges (#22169) — 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, backfillFailed: 0, - }; - const failedNames: string[] = []; - 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)); - } - } - // [#11097] ONE existence read for every overlay name, hoisted out of the loop - // below. Each iteration used to issue its own `SELECT … WHERE name = ? LIMIT - // 1` inside `upsertEnvPermissionSet` — one sequential HTTP request per overlay - // on the remote libsql/Turso database every hosted environment runs. - // - // ⚠️ Deliberately NOT reusing the `tryFind(ql, 'sys_permission_set', {}, 1000)` - // page read a few lines below, tempting though that is (it reads the same - // table and would cost nothing extra). That page is CAPPED at 1000 rows, and a - // truncated page reads as "absent" — which INSERTS. An environment past the - // cap would re-attempt an insert for every overlay beyond it on every boot. - // The chunked `$in` read has no such cap by construction. - const existingByName = await buildExistingByName( - ql, - 'sys_permission_set', - [...overlayNames], - logger, - ); - for (const name of overlayNames) { - const res = await projectPermissionMutation(protocol, deps, { - type: 'permission', name, state: 'active', - }, { existing: existingByName }); - // ⚠️ `unchanged` is deliberately NOT summed in. This counter reports records - // this pass CREATED OR CHANGED, and now that an already-matching record is - // no longer re-written, a steady-state boot honestly reports 0 here where it - // used to report one per overlay. Reading the old number as "N overlays are - // healthy" was always reading a write count as a health count. - out.projectedFromMetadata += (res?.seeded ?? 0) + (res?.updated ?? 0); - } - - // 2 + 3. env-authored records: backfill or heal. - // - // [#22169] The layered read is LAZY and asked ONCE PER NAME. The trust rule - // below consults it only when no SchemaRegistry answers, yet it used to be - // awaited for every candidate ROW before that rule ran — four serial - // statements per row on the protocol's read, measured on a real - // `ObjectStackProtocolImplementation`, every one of them discarded on a - // kernel whose registry is readable (every hosted one). Keyed by name because - // a name's answer does not depend on which of its rows asked; dropped again - // after this pass backfills that name, because the backfill is a write that - // changes the answer. - const layeredByName = new Map(); - const layeredFor = async (name: string): Promise => { - if (layeredByName.has(name)) return layeredByName.get(name); - let layered: any = null; - try { - layered = await protocol.getMetaItemLayered({ type: 'permission', name }); - } catch { layered = null; } - layeredByName.set(name, layered); - return layered; - }; - 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 - // 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)) { - const layered = await layeredFor(String(row.name)); - 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; - // The name has metadata presence now, so a later row of it must see - // the definition this write created rather than the pre-write answer. - layeredByName.delete(String(row.name)); - } catch (e) { - out.backfillFailed += 1; - failedNames.push(String(row.name)); - // [#4632 — AGENTS.md "Degradation log levels"] DURABILITY degradation: - // the record keeps listing and keeps resolving, so nothing looks - // broken — while the definition it is supposed to project stays absent - // from the only authoritative store. Said ONCE, at the first failure, - // with the consequence and the fix; the rest are counted and named in - // the summary line below. #4669: this was a `warn` with no counter, - // which is why a 100%-failing backfill sat green for a release. - if (out.backfillFailed === 1) { - const message = - '[security] permission-set backfill into metadata FAILED (ADR-0094 D4) — this environment has ' + - '`sys_permission_set` records with NO metadata definition backing them, and the one-time backfill ' + - 'did not write one. Nothing will look broken: the records still list in Setup and the evaluator ' + - 'still resolves them from the table — but the definitions are absent from the metadata store, so a ' + - 'metadata-driven re-provision (fresh environment, package reinstall) recreates none ' + - 'of them, and every boot retries and fails identically. Fix: make the record body spec-valid — the ' + - 'error below names the offending key; `permissionSetBodyFromRow()` already drops storage columns ' + - '(`active`, timestamps, provenance), so a rejection here means the stored facet JSON itself is ' + - 'off-contract — then reboot to re-run reconciliation, or delete the orphan record.'; - // `ProjectionLogger.error` is OPTIONAL, so `logger?.error?.(…)` printed - // NOTHING against a sink that has only `warn` — the durability - // degradation described above would then be reported by nobody at all - // (#9657). Reach for `error`, fall back to `warn`, never to silence. - // The fallback itself is now GUARANTEED BY THE TYPE: `warn` is - // non-optional on `ProjectionLogger` (#9754), so no TS caller can - // hand over a sink this line evaporates against. The `?.` below is - // the backstop for untyped hosts only — see the interface. - if (logger?.error) logger.error(message, e as Error, { name: row.name }); - else logger?.warn?.(message, { name: row.name, error: String((e as Error)?.message ?? e) }); - } - } - } 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). - // - // [#22169] ⛔ The row THIS LOOP READ is healed, by its `id`, and nothing - // else. Handing the body to `upsertEnvPermissionSet` instead re-resolved - // the record BY NAME (first row, lowest id), so with more than one row - // per name only that one was ever written: every later row was reported - // "re-projected" and left drifted, on every boot, forever — measured on a - // real engine as 16 of 24 rows still drifted and warned about on boot 2, - // 3, … . The by-name read also had an INSERT branch that a refused read - // reached (`tryFind` answers `[]` for a throw), minting a duplicate of a - // row this loop was holding. A by-id update has no existence question to - // get wrong. Facets only (`permissionSetRowFields`), exactly the columns - // the upsert's update leg wrote: identity, `active` and provenance are - // never touched by a heal. - const healed = await tryUpdate(ql, 'sys_permission_set', { - id: row.id, - ...permissionSetRowFields(effective), - }); - // Said AFTER the write, so the line reports what happened — the old - // order announced a re-projection the by-name write then never made. - if (healed) { - out.driftHealed += 1; - logger?.warn?.( - '[security] sys_permission_set record drifted from its metadata definition — re-projected (metadata wins; ADR-0094 D4)', - { name: row.name, id: row.id }, - ); - } else { - logger?.warn?.( - '[security] sys_permission_set record drifted from its metadata definition — the re-projection write was ' + - 'REFUSED, so the record keeps its drifted facets until a boot whose write lands. Enforcement is ' + - 'unaffected: the evaluator resolves the metadata definition, never this record (ADR-0094 D4).', - { name: row.name, id: row.id }, - ); - } - } - } - - if (out.backfillFailed > 0) { - // The summary carries the same level as the degradation it summarizes — - // an `info` "reconciled" line over a failed backfill is the reassuring - // half-truth this rule exists to remove. - const summary = - `[security] sys_permission_set projection reconciled with ${out.backfillFailed} FAILED backfill(s) ` + - '(ADR-0094 D4) — those records have no metadata definition and will not survive a re-provision. ' + - 'See the first-failure error above for the offending key and the fix.'; - const summaryMeta = { ...out, failedNames: failedNames.slice(0, 10) }; - // Same defect as the first-failure report above, one scope out (#9748). No - // `catch` guards this line, so `check:durability-log-level` could not see - // it and #9657 left it spelled `logger?.error?.(…)` — which printed NOTHING - // against a sink that has only `warn`. Worse here than a plain omission: - // the `else` below is skipped too, so such a sink heard neither the count - // nor the reassuring "reconciled" line, while the first-failure report - // (repaired by #9657) still arrived. Fall back to `warn`, not silence. - // The fallback itself is now GUARANTEED BY THE TYPE: `warn` is - // non-optional on `ProjectionLogger` (#9754). The `?.` is the backstop for - // untyped hosts only — see the interface. - if (logger?.error) logger.error(summary, undefined, summaryMeta); - else logger?.warn?.(summary, summaryMeta); - } else { - 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 691eeef3992..3110a63b328 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2296,14 +2296,6 @@ export class SecurityPlugin implements Plugin { }); } - // [#20013] The writes the ADR-0094 permission-set data door executed - // ITSELF — it returned without calling `next()`, so no engine write ran. - // Filled by the wrapper that registers the door (below), read by the - // security middleware's post-`next()` assertion. Keyed by the operation - // context the two middlewares share, and private to this plugin, so no - // other middleware can put a write in it. - const writesExecutedByDataDoor = new WeakSet(); - // Register security middleware ql.registerMiddleware(async (opCtx: any, chainNext: () => Promise) => { // [#21771] Whether THIS operation was issued from inside another engine @@ -4258,27 +4250,7 @@ export class SecurityPlugin implements Plugin { // that silently stops gating and a deployment that never finds out. // ⛔ Do not soften this into a warning: a middleware that cannot say a // write was checked must not report that it was. - // - // [#20013] It covers the Layer 0 tenant wall's stored-row judgement too, - // with ONE stand-down, and only for a seam that carries the wall alone: - // the ADR-0094 permission-set data door (registered below, inside this - // middleware) executes an insert or update of `sys_permission_set` - // ITSELF, through the metadata protocol, and never calls `next()`. No - // engine write runs there, so no hook chain runs, and the only - // `organization_id` such a write can carry is the one the payload - // judgement above already cleared; the stored-row half has nothing left - // to judge. Measured on the base: a platform administrator's insert - // there, under the `isolated` posture, is admitted while Layer 0 walls - // the object, so without this stand-down the guard would refuse a write - // it has no reason to. The fact is OBSERVED by the wrapper that - // registers the data door (it records a write the door never passed on - // to `next()`), never declared by an operation field another middleware - // could set. A seam carrying a row-level `check` is NOT stood down: that - // judgement has no payload half for an insert, and the data door keeps - // failing closed under one exactly as before. - const standsDown = - !businessCheckSeamInstalled && tenantWallSeamInstalled && writesExecutedByDataDoor.has(opCtx); - if (writeImageCheckSeam && writeImageCheckSeam.honoured !== true && !standsDown) { + if (writeImageCheckSeam && writeImageCheckSeam.honoured !== true) { const developerMessage = businessCheckSeamInstalled ? `[Security] Access denied: the ${opCtx.operation} on '${opCtx.object}' was executed without the row-level CHECK ` + `being evaluated — the engine did not run OperationContext.postHookWriteImageCheck. ` + diff --git a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts deleted file mode 100644 index 1c185efeaf6..00000000000 --- a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#18570] The batched existence read's OWN failure reaches the author even - * when the caller injected no logger — the sixth instance of the - * doubly-optional `logger?.warn?.(…)` shape, repaired through the ONE - * derivation #18091 landed for it (`reportThroughSink`). - * - * ## Why every case here is DIFFERENTIAL, never a bare "did it print" - * - * The card that filed this site did not read it off the code — it drove an - * unreadable-database pass twice and compared: with NO logger injected the run - * produced exactly ONE author-visible line (an already-repaired #18091 site) - * while this one stayed silent, and with a logger injected the same run - * produced BOTH. A test that only asserts "an injected logger was called" - * reproduces none of that: the old `logger?.warn?.(…)` passes it unchanged, - * because the mute was never on the injected arm. - * - * So case 1 runs a **lit control on the same subject in the same run** — - * `reportThroughSink` with no sink, the one derivation every repaired site - * reaches the console through. A zero from this site is a reading only while that - * control fires; if the harness ever stops capturing `console.warn`, the - * control goes to zero too and the case fails instead of passing vacuously. - * - * ## The three halves that must not move while the mute goes away - * - * - **Case 2** — an injected sink still gets the line, with its receiver and - * its structured meta, and the console stays out of it. That is the half the - * pre-existing suites (`bootstrap-seed-round-trips`, - * `permission-set-projection`) already pin - * from the seeder side; it is restated here because this file is what a - * later author edits. - * - **Case 3** — a read that ANSWERED is silent on every channel with no sink. - * This is the discriminating control #18023 established: routing a healthy - * boot's output to the console would turn every such control into noise. - * ⛔ Without this case, "always `console.warn`" would pass case 1. - * - **Case 4** — a host whose `warn` is not callable (a plain-JS embedder, or - * a cast) must not THROW, and must still reach the author. That is the third - * spelling `seed-refusal-sink.ts` refuses (`if (logger) logger.warn(…)`), - * and it is the reason this call site asks `typeof` rather than truthiness. - */ - -import { describe, expect, it, vi } from 'vitest'; - -import { buildExistingByName } from './seed-name-lookup.js'; -import { reportThroughSink } from './seed-refusal-sink.js'; - -/** A driver seam that cannot answer at all — the outage arm. */ -function unreadableQl() { - return { - find: async () => { - throw new Error('database is unreachable'); - }, - }; -} - -/** A driver seam that answers, and answers "none of these names exist". */ -function emptyPageQl() { - const calls: unknown[][] = []; - return { - calls, - find: async (...args: unknown[]) => { - calls.push(args); - return [] as any[]; - }, - }; -} - -/** - * A driver seam whose page is a PREFIX of the answer — `readNamePage` asks for - * `budget + 1` rows, so handing back that many is what truncation looks like. - * Unscoped, a one-name read budgets `UNSCOPED_PAGE_FLOOR` (20) rows. - */ -function truncatedPageQl() { - return { - find: async () => Array.from({ length: 21 }, (_, i) => ({ id: `row-${i}`, name: 'alpha' })), - }; -} - -/** Collect `console.warn` for the duration of one run. */ -async function captureConsoleWarn(run: () => Promise): Promise { - const seen: unknown[][] = []; - const spy = vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { - seen.push(args); - }); - try { - await run(); - } finally { - spy.mockRestore(); - } - return seen; -} - -const BATCHED_READ_FAILED = 'batched seed existence read failed'; -const BATCHED_READ_TRUNCATED = 'batched seed existence read TRUNCATED'; - -describe('[#18570] batched seed existence read — its own failure is audible with no logger', () => { - it('reports on the console beside a LIT CONTROL in the same run (the differential the card was graded on)', async () => { - const seen = await captureConsoleWarn(async () => { - // The site under repair, driven exactly as the card drove it: an - // unreadable database and NO logger argument at all. - await buildExistingByName(unreadableQl() as any, 'sys_capability', ['alpha', 'beta']); - - // THE LIT CONTROL, same run, same subject (author-visible output with no - // sink injected): the derivation every repaired site reaches the console through. - reportThroughSink(undefined, '[control] lit control', { unreadable: 2, total: 2 }); - }); - - const lines = seen.map((args) => String(args[0])); - - // The control fires — so a zero from the site under repair would be a - // reading rather than a dead harness. - expect(lines.filter((l) => l.includes('[control] lit control'))).toHaveLength(1); - - // And the site under repair is no longer the silent one. Pre-#18570 this - // run produced the control line ALONE. - expect(lines.filter((l) => l.includes(BATCHED_READ_FAILED))).toHaveLength(1); - expect(lines).toHaveLength(2); - - // The structured half travels with it — the console arm is the same report, - // not a degraded restatement of it. - const meta = seen.find((args) => String(args[0]).includes(BATCHED_READ_FAILED))?.[1] as Record< - string, - unknown - >; - expect(meta).toMatchObject({ object: 'sys_capability', names: 2 }); - }); - - it('reports the TRUNCATED cause the same way, budget and all', async () => { - const seen = await captureConsoleWarn(async () => { - await buildExistingByName(truncatedPageQl() as any, 'sys_capability', ['alpha']); - }); - - expect(seen).toHaveLength(1); - expect(String(seen[0]?.[0])).toContain(BATCHED_READ_TRUNCATED); - expect(seen[0]?.[1]).toMatchObject({ object: 'sys_capability', names: 1, rowBudget: 20 }); - }); - - it('still hands the line to an injected sink — with its receiver — and leaves the console alone', async () => { - const received: Array<{ self: unknown; message: string; meta: unknown }> = []; - // A CLASS-based sink, because the hazard `seed-refusal-sink.ts` records is a - // delivery form that drops the receiver: `(logger?.warn ?? console.warn)(…)` - // calls a bare function and a host reaching for `this` throws. - class HostSink { - readonly tag = 'host'; - warn(message: string, meta?: Record) { - received.push({ self: this, message, meta }); - } - } - const sink = new HostSink(); - - const seen = await captureConsoleWarn(async () => { - await buildExistingByName(unreadableQl() as any, 'sys_permission_set', ['alpha'], sink, 'org_1'); - }); - - expect(received).toHaveLength(1); - expect(received[0]?.self).toBe(sink); - expect(received[0]?.message).toContain(BATCHED_READ_FAILED); - expect(received[0]?.meta).toMatchObject({ - object: 'sys_permission_set', - names: 1, - organization: 'org_1', - }); - - // ⛔ Not both channels: an injected host owns the report. - expect(seen).toHaveLength(0); - }); - - it('stays silent on every channel when the read ANSWERED — the discriminating control', async () => { - const ql = emptyPageQl(); - let verdict: string | undefined; - - const seen = await captureConsoleWarn(async () => { - const index = await buildExistingByName(ql as any, 'sys_capability', ['alpha']); - verdict = (await index.get('alpha')).status; - }); - - // The read really happened and really answered — `absent` is a fact here, - // never a swallowed failure. - expect(ql.calls).toHaveLength(1); - expect(verdict).toBe('absent'); - - // ⛔ A healthy pass buys no console noise. This is what keeps case 1 from - // being satisfiable by an unconditional `console.warn`. - expect(seen).toHaveLength(0); - }); - - it('reaches the author — without throwing — for a host whose `warn` is not callable', async () => { - // The shape a plain-JS embedder or a cast produces, and the one - // `if (logger) logger.warn(…)` turns into `logger.warn is not a function` - // inside a degradation path. - const lyingHost = { warn: 'not a function' } as unknown as { warn: (m: string) => void }; - - const seen = await captureConsoleWarn(async () => { - await buildExistingByName(unreadableQl() as any, 'sys_capability', ['alpha'], lyingHost as any); - }); - - expect(seen).toHaveLength(1); - expect(String(seen[0]?.[0])).toContain(BATCHED_READ_FAILED); - }); -}); - -/** - * [#11451, commit c33f18592] A caller that passes no `equals` predicate emits - * exactly the `where` key set it emitted before the predicate existed: - * `['name']`, not `['name']` plus `undefined`-valued keys that `toEqual` - * would quietly accept. `permission-set-projection.ts`' overlay pass is such - * a caller. The fence used to ride the declared-permission seeder's suite and - * is pinned here, at the function, now that the seeder is retiring. - * - * Both reads are pinned: the batched page and the per-item degradation read - * it falls back to. The predicate-carrying case is the positive control: it - * proves this instrument sees a spread key when one is there. - */ -describe('#11451: a caller that passes no predicate emits its old key set', () => { - it('the batched read emits only `name`', async () => { - const ql = emptyPageQl(); - await buildExistingByName(ql as any, 'sys_permission_set', ['alpha', 'beta']); - expect(ql.calls).toHaveLength(1); - expect(Object.keys((ql.calls[0]![1] as any).where)).toEqual(['name']); - }); - - it('the per-item degradation read emits only `name`', async () => { - const wheres: Record[] = []; - // The batched `$in` read is refused, so the per-item path is taken; each - // per-item read is recorded and answered "no such row". - const ql = { - find: async (_object: string, q?: { where?: Record }) => { - const name = q?.where?.name; - if (name && typeof name === 'object' && '$in' in (name as object)) throw new Error('batched read refused (trigger)'); - wheres.push(q?.where ?? {}); - return [] as any[]; - }, - }; - const index = await buildExistingByName(ql as any, 'sys_permission_set', ['alpha'], { warn: () => {} }); - expect((await index.get('alpha')).status).toBe('absent'); - expect(wheres).toHaveLength(1); - expect(Object.keys(wheres[0]!)).toEqual(['name']); - }); - - it('positive control: a caller that passes a predicate emits it', async () => { - const ql = emptyPageQl(); - await buildExistingByName(ql as any, 'sys_permission_set', ['alpha'], undefined, undefined, { managed_by: 'platform' }); - expect(Object.keys((ql.calls[0]![1] as any).where)).toEqual(['name', 'managed_by']); - }); -}); diff --git a/packages/plugins/plugin-security/src/seed-name-lookup.ts b/packages/plugins/plugin-security/src/seed-name-lookup.ts deleted file mode 100644 index 966c6602879..00000000000 --- a/packages/plugins/plugin-security/src/seed-name-lookup.ts +++ /dev/null @@ -1,506 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * ONE batched existence read for a boot seeder whose input set is known in - * full before its loop starts (#10946). - * - * ## Why this exists - * - * `bootstrapDeclaredPermissions` and `bootstrapDeclaredPositions` are - * read-then-write reconcilers over a list the caller already holds. Written - * as a per-item `SELECT … WHERE name = ? LIMIT 1` inside a `for await` loop, - * each declared item costs its own database ROUND TRIP — invisible on a local - * file database, one sequential HTTP request per leg on a remote libsql/Turso - * database, i.e. on every hosted environment. Measured on a real per-environment - * kernel build with every `@libsql/client` call counted, the two loops together - * grew a REBUILD (tables present, rows already seeded, nothing to change) by - * **exactly 4.0000 round trips per declared item, R² = 1.000000** on both axes. - * - * Schema sync had already been batched (`TursoDriver.supports.batchSchemaSync`), - * which is why objects/views/artifact seeds add 0.00 round trips each on the - * same rig; identity content was the one content axis still paying per item. - * - * ## The seam that has to be judged carefully - * - * ⛔ **A read that CANNOT ANSWER is not the answer "none of them exist."** That - * conflation is the whole risk of hoisting the read: one swallowed failure - * would make every boot conclude that nothing is seeded and re-create - * everything — a far worse defect than the round trips being removed. The - * per-item shape was accidentally immune to it (a failed read fell through to - * an insert that failed too, for that one item only); a batched read is not, - * because one failure now speaks for the entire set. - * - * So the seam is judged on **whether the driver returned a result set**, never - * on whether the array came back empty: - * - * - a thrown read → could not answer - * - a response that is neither an array - * nor `{ records: [...] }` → could not answer - * - a page carrying MORE rows than it - * budgeted for (commit e1d773eb7) → could not answer - * - `[]` → ANSWERED: none of these names exist - * - * "Could not answer" degrades — loudly warned — to the per-item read the loops - * used before, which is the case that matters for a driver that simply does not - * do `$in`: everything then proceeds exactly as it did pre-#10946, only slower. - * When the per-item read cannot answer either (the database is genuinely - * unreachable), the oracle reports {@link ExistingLookupResult} `unknown` and - * the seeder declines to touch that name at all. - * - * ⚠️ That last step is STRICTER than the code it replaces, deliberately. The old - * loop turned a failed read into an insert attempt and relied on the `name` - * unique index to refuse it — a database constraint standing in for a decision - * the seeder should have been making. On any deployment where that index is - * absent or not yet created, the old shape DUPLICATED rows instead of declining. - * - * None of this is a lenient fallback for off-contract input (AGENTS.md Prime - * Directive #12): the batched and per-item reads ask the driver the same - * question, and the answer has one meaning. - * - * ## The page budget, and why it is measured rather than trusted (commit e1d773eb7) - * - * A `LIMIT` cannot express "one row per name", so the page needs a cap and no - * cap is CORRECT. `limit: names.length` was exact only while one row could exist - * per name; #10103 found that and repaired the SCOPED arm, where the honest - * bound really is two rows per name. The unscoped arm kept the old cap, and its - * bound is not two — it is the number of organizations, because - * `sys_capability.name` and `sys_permission_set.name` are unique PER - * ORGANIZATION (#8461 / ADR-0120 D1) and ADR-0066 D1 encourages admins to EXTEND - * the registry inside their own organization. `names.length * 2` would have been - * the same defect with a larger constant. - * - * So the cap stopped being a promise and became a MEASUREMENT: the read asks for - * one row MORE than it is willing to hold, and a page that comes back with that - * extra row is a prefix of the answer rather than the answer. It joins the two - * causes above — could not answer — and degrades to the same per-item read. - * - * ⚠️ That is a BEHAVIOUR CHANGE for the two callers that read unscoped - * (`bootstrapDeclaredCapabilities`, `permission-set-projection`'s overlay pass), - * and the direction is deliberate: in the truncating case they used to get a - * SILENT WRONG answer — names reported `absent` whose rows were sitting in the - * table, sending each to its insert branch — and now get a LOUD SLOW one, N - * round trips and a warning naming the budget. An install that does not truncate - * (every stock one: one row per name) reads exactly the same page it read - * before, in exactly one round trip. - * - * ## Chunking - * - * `$in` binds one parameter per name, and SQLite builds cap bound parameters - * (`SQLITE_MAX_VARIABLE_NUMBER`, historically 999). {@link NAME_CHUNK_SIZE} - * keeps a single read well under every such cap, so the cost is - * `ceil(N / 500)` reads — constant for every realistic declaration count and, - * unlike an unchunked read, incapable of turning a large environment's boot - * into a hard driver error. - * - * ## Organization scope (#10103) - * - * The catalog these seeders write is materialized PER ORGANIZATION under a - * walled posture, so this oracle answers "does THIS organization have a row for - * this name" — not "does any row anywhere carry this name". Two consequences, - * and the second is the one that bites: - * - * - the read is threaded with the organization, so it routes through - * `SqlDriver.applyTenantScope` (the governed chokepoint) rather than - * re-implementing a wall predicate here; - * - "first row wins" stops being right. `applyTenantScope` returns the - * caller's rows AND organization-less ones, so a page can carry two rows for - * one name, in driver order. Taking whichever came first would let a PRE-FIX - * organization-less row answer for this organization, the loop would take its - * update branch, and no per-organization copy would ever be created — a - * silent no-op that leaves a walled deployment exactly as broken as before - * while reporting success. It was measured. So the two are separated: - * {@link ExistingLookupResult} `present` means THIS organization's own row, - * and an organization-less leftover comes back on the `absent` result as - * {@link ExistingLookupResult} `organizationLessResidue`, so the caller both - * creates the copy and can report the leftover loudly. - * - * A pass with no organization (the `single`-posture carve-out) is unchanged in - * every respect: nothing is threaded, and the first row is the row. - * - * ## Where the degradation line goes (#18570) - * - * The degradation above is only worth writing if somebody READS it, and the - * doubly-optional `logger?.warn?.(…)` this site used to spell evaluates to - * NOTHING when the caller injected no sink: the batched read failed, the oracle - * quietly switched to the slow path, and no human was told. That is the same - * shape repaired on the permission-set axis (#17516), the capability axis - * (#18023) and the two declared-metadata seeders' five sites (#18091) — this is - * its sixth instance, and it takes the derivation #18091 landed for exactly - * this rule ({@link reportThroughSink}) rather than a sixth hand-written copy. - * - * ⚠️ Delivery only. The wording, the structured meta and the two named causes - * are axis-specific and stay here, which is the split `seed-refusal-sink.ts` - * documents. Nothing about WHEN this site reports changes: a read that answered - * is as silent as it always was, on every channel. - */ - -import { resolveOwnOrganizationRow, seedCtx as lookupCtx } from './organization-scope.js'; -import { reportThroughSink, type CollisionReportSink } from './seed-refusal-sink.js'; - - - -/** Names bound into one `$in` read. See the chunking note in the module header. */ -export const NAME_CHUNK_SIZE = 500; - -export interface SeedLookupLogger { - info?: (m: string, meta?: Record) => void; - warn?: (m: string, meta?: Record) => void; -} - -/** - * What the oracle knows about one name. THREE outcomes, not two: `absent` is a - * fact the driver reported, `unknown` is the absence of any fact at all. - * - * ⛔ Collapsing `unknown` into `absent` is the whole hazard of hoisting the - * read, and the reason this is a union rather than `row | undefined`. A caller - * that treats "I could not find out" as "it is not there" INSERTS — and does so - * for every name at once, because a batched read fails for the whole set. The - * per-item shape hid this behind the unique index (the blind insert was refused - * by the database, which is a guard, not a design); the tri-state makes the - * seeder decline on its own. - */ -export type ExistingLookupResult = - | { status: 'present'; row: any } - /** - * No row for this name IN THIS ORGANIZATION. `organizationLessResidue` names a - * pre-fix row that belongs to no organization and is visible here only through - * the driver's compatibility arm — the caller creates its own copy anyway and - * reports the leftover (#10103). - */ - | { status: 'absent'; organizationLessResidue?: any } - | { status: 'unknown' }; - -const ABSENT: ExistingLookupResult = { status: 'absent' }; -const UNKNOWN: ExistingLookupResult = { status: 'unknown' }; - -/** - * The existence oracle a seed loop consults in place of its own per-item read. - * - * ⚠️ {@link ExistingByNameIndex.remember} is not an optimization — it is what - * keeps hoisting the read out of the loop behaviour-preserving. The per-item - * read saw rows the SAME loop had just inserted, so a name declared twice in - * one batch resolved as present on the second pass and took the caller's - * collision branch (which, for permission sets, is the loud ADR-0086 D4 - * "owned by another package" refusal). A snapshot taken before the loop cannot - * see those inserts: without `remember`, the second declaration would attempt - * an insert instead, the unique index would refuse it, and a refusal that used - * to be reported would become a silent nothing. So every caller that inserts - * records the row it created. - */ -export interface ExistingByNameIndex { - /** What is known about `name` — see {@link ExistingLookupResult}. */ - get(name: string): Promise; - /** Record a row the calling loop just created under `name`. */ - remember(name: string, row: any): void; -} - -/** - * [commit e1d773eb7] Rows per requested name an UNSCOPED page is willing to hold before - * it stops trying to answer in one read. - * - * ⚠️ A BUDGET, not a bound — the distinction is the whole of commit e1d773eb7. Nothing - * bounds rows-per-name here: `sys_capability.name` and `sys_permission_set.name` - * are unique PER ORGANIZATION (#8461 / ADR-0120 D1) and ADR-0066 D1 encourages - * admins to EXTEND the registry inside their own organization, so one name - * legitimately carries a row per organization plus the platform's. Any constant - * multiplier is therefore the same defect with a larger number, which is why - * this one buys nothing except SPEED: {@link readNamePage} asks for one row more - * than it, so exceeding it is DETECTED rather than silently truncated. - * - * The shape (`names.length * 4`, floored) matches the sibling generous cap on - * the same table in `security-plugin.ts`'s permission-set loader. - */ -const UNSCOPED_ROWS_PER_NAME = 4; - -/** Floor for the unscoped budget, so a one-name read is not budgeted at four. */ -const UNSCOPED_PAGE_FLOOR = 20; - -/** - * [#10103] Rows per requested name a SCOPED page must hold — and here the - * number IS a proven bound rather than a budget. `applyTenantScope` returns - * `organization_id = :tenant OR organization_id IS NULL`, and the declared name - * index is unique per organization, so each name has at most this organization's - * row plus one organization-less leftover. Kept exact deliberately: a scoped - * page that overflows it means the uniqueness the catalog is built on is not - * holding, and commit e1d773eb7's probe turns that into a loud degradation instead of a - * silent truncation. - */ -const SCOPED_ROWS_PER_NAME = 2; - -/** How many rows this page is willing to hold. See the two constants above. */ -function pageRowBudget(names: string[], organizationId?: string): number { - return organizationId - ? names.length * SCOPED_ROWS_PER_NAME - : Math.max(names.length * UNSCOPED_ROWS_PER_NAME, UNSCOPED_PAGE_FLOOR); -} - -/** - * One page, or WHY there is none — the two `cause`s are different events with - * the same consequence (see {@link readNamePage}). - */ -type NamePage = - | { readonly ok: true; readonly rows: any[] } - | { readonly ok: false; readonly cause: 'unreadable' | 'truncated'; readonly budget: number }; - -/** - * Read one page of names — `ok: false`, distinct from an empty page, when this - * read cannot answer. - * - * ## [commit e1d773eb7] Truncation is "could not answer", not "none of them exist" - * - * A `LIMIT` cannot say "one row per name", so any cap this read picks can be - * exceeded by a healthy install (see {@link UNSCOPED_ROWS_PER_NAME}). The rows - * that fall off are the highest `id`s under #4363's `ORDER BY id ASC` tie- - * breaker, so WHOLE NAMES vanish from the page — and a name missing from the - * page reads as `absent`, which routes its caller to the INSERT branch. That is - * the same conflation the module header refuses for a failed read, arriving - * through a page that succeeded. - * - * So the cap is turned into a MEASUREMENT: ask for `budget + 1` rows and - * compare. - * - * - `page.length <= budget` — the driver had no `budget + 1`-th row to give, - * so this page is provably the COMPLETE set for these names; - * - `page.length > budget` — there is at least one more row than this read is - * willing to hold, so the page is a PREFIX of the answer and cannot be read - * as one. `ok: false`, and the caller degrades to the per-item read exactly - * as it does for a driver without `$in`. - * - * Both directions are exact, which is what makes the budget a free choice: no - * legitimate page is ever mistaken for a truncated one, and no truncated page is - * ever mistaken for a complete one. - */ -async function readNamePage( - ql: any, - object: string, - names: string[], - organizationId?: string, - equals?: Readonly>, -): Promise { - const budget = pageRowBudget(names, organizationId); - let rows: any; - try { - rows = await ql.find( - object, - { - // [commit c33f18592] `...(equals ?? {})` spreads NOTHING when no predicate was - // given, so a caller that passes none emits the exact key set it - // emitted before — not the same keys plus `undefined`-valued ones, - // which `toEqual` would have quietly accepted. - where: { name: { $in: names }, ...(equals ?? {}) }, - // [commit e1d773eb7] ONE MORE than the budget, always — the extra row is the - // probe, and reading it back is how truncation is told from a page that - // merely happens to be full. - limit: budget + 1, - }, - { context: lookupCtx(organizationId) }, - ); - } catch { - return { ok: false, cause: 'unreadable', budget }; - } - // `ObjectQL.find` resolves a BARE array — MEASURED, not read off the - // declared type. `IDataEngine.find` says `Promise`, and a declared type - // is not proof here: this repo's own `ObjectStackAdapter.find()` resolves a - // `QueryResult` envelope and never an array. So the engine behind this seam - // was booted for real (a real `ObjectQL` over a real `SqlDriver`) and driven, - // and it answered `[object Array]` with no own `records` key on a populated - // page and on an empty one alike — see `engine-find-bare-array.pin.test.ts`, - // which pins this seam. The `{ records }` limb this used to carry was - // therefore unreachable: dead code that read as a contract. - // - // What is NOT changed: a non-array is still `null` here, and `null` is still - // `unreadable` below. Removing a dead limb must not quietly convert a seam - // that GAPS into one that invents an empty answer. - const page: any[] | null = Array.isArray(rows) ? rows : null; - if (page === null) return { ok: false, cause: 'unreadable', budget }; - if (page.length > budget) return { ok: false, cause: 'truncated', budget }; - return { ok: true, rows: page }; -} - -/** - * The per-item read the loops used before #10946 — the degradation path. - * - * `remember` is a deliberate no-op: this oracle re-reads the database on every - * call, so it already sees rows the loop inserted a moment ago. - */ -function perItemIndex( - ql: any, - object: string, - organizationId?: string, - equals?: Readonly>, -): ExistingByNameIndex { - return { - async get(name: string): Promise { - let rows: any; - try { - // Limit 5, not 1, when scoped: a single row would be whichever the - // driver ordered first, and this read must be able to tell this - // organization's row from an organization-less leftover. - // - // [commit c33f18592] The predicate rides the DEGRADATION read too. A fallback - // that dropped it would ask a WIDER question than the batched read it - // is standing in for — and for the caller that needs one, wider is not - // "slower but the same": it is a different row. - rows = await ql.find( - object, - { where: { name, ...(equals ?? {}) }, limit: organizationId ? 5 : 1 }, - { context: lookupCtx(organizationId) }, - ); - } catch { - return UNKNOWN; - } - // Bare array, driven — the same measurement `readNamePage` above records, - // taken at THIS seam too (`engine-find-bare-array.pin.test.ts` drives the - // per-item path separately, because one seam's reading is not the other's). - // A non-array stays `UNKNOWN`: a read that did not answer is never "absent". - const list = Array.isArray(rows) ? rows : null; - if (list === null) return UNKNOWN; - return resolveForOrganization(list, organizationId); - }, - remember() { /* re-read every call — nothing to cache */ }, - }; -} - -/** - * Which of the rows a read returned for ONE name is this organization's, - * expressed as this module's tri-state. - * - * The organization split itself is NOT decided here — it delegates to - * {@link resolveOwnOrganizationRow}, the one spelling of that question the - * catalog has. Two spellings of "which row is mine" is exactly the shape that - * produced the defect this scoping repairs (one question, two implementations, - * the ungoverned copy winning), so this function only translates that answer - * into `present` / `absent` + leftover. - * - * Unscoped (the `single`-posture pass) the first row is the row, exactly as - * before. - */ -function resolveForOrganization(rows: any[], organizationId?: string): ExistingLookupResult { - const { own, organizationLessResidue } = resolveOwnOrganizationRow(rows, organizationId); - if (own) return { status: 'present', row: own }; - return organizationLessResidue ? { status: 'absent', organizationLessResidue } : ABSENT; -} - -/** - * Build the existence lookup a seed loop should use: ONE batched read for the - * whole name set, degrading to the per-item read when that read cannot answer. - * - * `names` may contain duplicates and blanks; both are dropped before the read. - */ -export async function buildExistingByName( - ql: any, - object: string, - names: readonly (string | null | undefined)[], - logger?: SeedLookupLogger, - /** - * Answer for THIS organization (#10103). Omitted = the pre-existing - * installation-wide question, which is what a `single`-posture pass wants. - */ - organizationId?: string, - /** - * [commit c33f18592] An extra EQUALITY predicate ANDed onto the `$in`, for a caller - * whose existence question is narrower than "a row with this name". - * - * `bootstrapSystemCapabilities`' curated half asks for the platform's OWN - * organization-less row (`managed_by: 'platform'` + `organization_id: null`, - * #8470), not the first row that happens to share the name. Post-#8461 those - * are different questions: `sys_capability.name` is unique per ORGANIZATION, - * so one name can have a row per organization plus the platform's. - * - * ⚠️ The predicate should keep the result a SINGLETON per name, and the - * curated one discharges that by construction: the declared unique key is - * `(COALESCE(organization_id, '__global__'), name)` (ADR-0120 D3), so the - * NULL-organization bucket admits at most one row per name — "exactly the - * bucket this key part keeps a singleton", as `sys-capability.object.ts` puts - * it. Narrowing can only SHRINK a page, so passing a predicate never makes - * truncation likelier than the unpredicated read it replaces. - * - * [commit e1d773eb7] That used to be a CORRECTNESS precondition the caller had to - * discharge — an unscoped page was capped at `names.length`, so a - * non-singleton question truncated, and a truncated page read as `absent`, - * which INSERTS. {@link readNamePage} now measures its own truncation, so a - * caller that breaks the singleton property gets the per-item read (slower, - * and it says so) rather than a wrong answer. What the predicate still buys is - * WHICH row answers: unscoped, the first row of the page is the row, so a - * question wide enough to match somebody else's copy resolves to it — the - * separate harm commit c33f18592 closed, and one no page budget can repair. - */ - equals?: Readonly>, -): Promise { - // Every row the page carried for a name, so the organization split can be - // judged per name rather than by arrival order. - const index = new Map(); - const fromIndex: ExistingByNameIndex = { - async get(name: string): Promise { - // The batched read ANSWERED for every requested name, so a miss here is - // the driver's own "no such row", not a gap in what we know. - return resolveForOrganization(index.get(name) ?? [], organizationId); - }, - remember(name: string, row: any) { - if (!name || !row) return; - const rows = index.get(name); - if (rows) rows.push(row); else index.set(name, [row]); - }, - }; - - const wanted: string[] = []; - const seen = new Set(); - for (const raw of names) { - if (raw == null) continue; - const name = String(raw); - if (!name || seen.has(name)) continue; - seen.add(name); - wanted.push(name); - } - if (wanted.length === 0) return fromIndex; - - for (let i = 0; i < wanted.length; i += NAME_CHUNK_SIZE) { - const outcome = await readNamePage(ql, object, wanted.slice(i, i + NAME_CHUNK_SIZE), organizationId, equals); - if (!outcome.ok) { - // ⛔ NOT "none of them exist" — see the module header. Fall back to the - // per-item read so behaviour is exactly what it was before the hoist. - // - // [commit e1d773eb7] TWO events, ONE consequence. A truncated page is not a broken - // driver — the read worked and the answer is simply wider than one page — - // so it is named separately, because the remedies differ: an unreadable - // database is an outage, while a truncated page is an install whose - // catalog carries more rows per name than this read budgets for, and the - // only cost is the round trips the batching removed. - // - // [#18570] Delivered through the ONE derivation, so a caller that injected - // no sink still hears it. `SeedLookupLogger.warn` is OPTIONAL where - // `CollisionReportSink.warn` is not, so the host is handed over only once - // it really carries a callable `warn` — the same `typeof` question - // `reportThroughSink` itself asks, moved one frame up so the declared - // types stay honest rather than being asserted past. A host with no usable - // `warn` therefore takes the console arm instead of buying silence. - reportThroughSink( - typeof logger?.warn === 'function' ? (logger as CollisionReportSink) : undefined, - outcome.cause === 'truncated' - ? '[security] batched seed existence read TRUNCATED — more rows carry these names than one page holds, so the page cannot answer; falling back to one read per item' - : '[security] batched seed existence read failed — falling back to one read per item', - { - object, - names: wanted.length, - ...(outcome.cause === 'truncated' ? { rowBudget: outcome.budget } : {}), - ...(organizationId ? { organization: organizationId } : {}), - }, - ); - return perItemIndex(ql, object, organizationId, equals); - } - for (const row of outcome.rows) { - const name = row?.name; - if (name == null) continue; - // [#10103] EVERY row is kept, not just the first. A scoped page can carry - // this organization's row and an organization-less leftover for the same - // name, in driver order, and `resolveForOrganization` — not arrival order - // — decides which one answers. Unscoped, the first row is still the row: - // the caller's own uniqueness rules decide what a duplicate name means and - // this read does not reorder that judgement. - const key = String(name); - const rows = index.get(key); - if (rows) rows.push(row); else index.set(key, [row]); - } - } - return fromIndex; -} diff --git a/packages/plugins/plugin-security/src/seed-refusal-sink.ts b/packages/plugins/plugin-security/src/seed-refusal-sink.ts deleted file mode 100644 index b92e7554007..00000000000 --- a/packages/plugins/plugin-security/src/seed-refusal-sink.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#18091] `reportThroughSink` — the ONE derivation of "a seeder refusal - * reaches the author even when no logger was injected". - * - * ## Why this is a module and not a third hand-written copy - * - * The doubly-optional call `logger?.warn?.(…)` evaluates to NOTHING when the - * caller passed no logger: the declaration is dropped, one internal counter - * moves, and no human is told. That shape has now been repaired - * instance-by-instance TWICE — once on the permission-set axis (#17516) and - * once on the capability axis (#18023) — and each repair restated the same two - * lines at its own call site. A shape repaired one instance at a time is a - * CLASS that has not been fixed, which is why the five remaining refusal sites - * in the two declared-metadata seeders share ONE delivery derivation instead of - * gaining five more copies of it. - * - * What is shared here is exactly the part that is axis-INDEPENDENT: WHERE the - * line goes. ⛔ What is deliberately NOT shared is the wording, the token, the - * record and the consequence — those are axis-specific (four of the six parts a - * refusal report is made of), and folding them into one generic sentence is the - * failure mode `seed-refusal-diagnostics.ts` is written to avoid. - * - * ## The three spellings this replaces, and why each is wrong - * - * - ⛔ `logger?.warn?.(…)` — silent with no sink. THE defect. - * - ⛔ `(logger?.warn ?? console.warn)(…)` — evaluates to a bare function and - * calls it with `this === undefined`; a class-based host sink - * (`@objectstack/core`'s `ObjectLogger`) reaches for `this` and throws. The - * property-access call form below keeps the receiver — the same measured - * conclusion `logSeedDurabilityFailure`, `reportPermissionSetNameCollisions` - * and `reportCapabilityNameCollisions` all record. - * - ⛔ `if (logger) logger.warn(…)` — correct for a sink whose `warn` the TYPE - * guarantees, but it THROWS for a host the type cannot reach (a plain-JS - * embedder, or a cast). `permission-set-projection.ts` records that measured - * hazard on `ProjectionLogger.warn`: a sink that lies about its shape - * throws `logger?.warn is not a function` inside a per-row durability catch - * and aborts the very batch that function promises never to stop. - * - * So the guard below is a `typeof` check rather than a truthiness test, and it - * is strictly better than BOTH halves of the old trade-off: a lying host no - * longer buys its silence with a throw — it gets the console, and the author - * still hears the refusal. - * - * ⚠️ This is the REFUSAL channel only. The end-of-pass `logger?.info?.(…)` - * summary in each seeder keeps its outer `?.` deliberately: a pass that did its - * work and refused nothing MUST stay silent on every console channel with no - * sink injected, which is the discriminating control #18023 landed. Routing a - * healthy boot's info line to `console.info` would turn every one of those - * controls into noise and buy no author anything. - */ - -import type { CollisionReportSink } from './permission-set-name-collision.js'; - -/** - * Re-exported for the reason #18088 gave when it did the same: a consumer of - * this delivery rule never declares a second structural copy of the sink. - */ -export type { CollisionReportSink }; - -/** - * Print one refusal so it reaches the author. - * - * `warn`, not `error`: every caller here reports FUNCTIONAL degradation (#4632) - * — the deployment is visibly less than it was authored to be. Nothing claimed - * to be persisted silently failed to land; the write was deliberately never - * attempted, or the read that would have decided it never answered. - * - * @param logger the host sink, or `undefined` when none was injected - * @param message the fully worded, axis-specific line — ⛔ never assembled here - * @param meta the structured half of the same finding - */ -export function reportThroughSink( - logger: CollisionReportSink | undefined, - message: string, - meta?: Record, -): void { - // ⛔ `typeof`, not truthiness — see the module header's third bullet. The - // property-access call form keeps the receiver for a class-based sink. - if (logger && typeof logger.warn === 'function') logger.warn(message, meta); - else console.warn(message, meta); -} diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index f5aa8eb1fda..fb100d080c5 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -154,7 +154,7 @@ "status": "live", "verifiedAt": "2026-08-28", "proof": "packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts#me-apps-and-everyone-baseline", - "evidence": "packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#registerCurrentUserEndpoints (both consumers live in this registrar's route closures: /auth/me/permissions builds the rank table and the most-visible merge across the caller's resolved sets and projects the map onto the response, and /me/apps applies the same merge and DROPS an app whose merged value is 'hidden' — a filter that runs before the fail-open branch, so it survives a missing SecurityPlugin); packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#tabRank (the four-value rank table itself — hidden 0 < default_off 1 < default_on 2 < visible 3, built once per handler; an unrecognised value is skipped rather than defaulted); packages/plugins/plugin-security/src/permission-set-projection.ts#permissionSetBodyFromRow (the sys_permission_set.tab_permissions column parsed onto the resolved set, with `permissionSetRowFields` writing it back and `mergeRowPatchIntoBody` patching it); packages/core/src/security/assemble-execution-context.ts#entryFields (`tabPermissions: authz.tabPermissions` onto ExecutionContext)", + "evidence": "packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#registerCurrentUserEndpoints (both consumers live in this registrar's route closures: /auth/me/permissions builds the rank table and the most-visible merge across the caller's resolved sets and projects the map onto the response, and /me/apps applies the same merge and DROPS an app whose merged value is 'hidden' — a filter that runs before the fail-open branch, so it survives a missing SecurityPlugin); packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#tabRank (the four-value rank table itself — hidden 0 < default_off 1 < default_on 2 < visible 3, built once per handler; an unrecognised value is skipped rather than defaulted); packages/core/src/security/assemble-execution-context.ts#entryFields (`tabPermissions: authz.tabPermissions` onto ExecutionContext)", "note": "Re-verified 2026-08-23 (commit 905019b1b) and every pointer REPLACED — all three of the previous ones were dead, in two different ways, and the gate could see neither. (a) `plugin-hono-server/src/hono-plugin.ts:1200` was past EOF: that file is 717 lines and contains no `tabPermissions` reference at all — the same code movement that killed this entry's sibling `systemPermissions` pointer (commit de6e2bfcb, PR #11209) moved /me/apps and /auth/me/permissions into `current-user-endpoints.ts`. (b) `runtime/src/security/resolve-execution-context.ts:205` and `rest/src/rest-server.ts:1551` were dead WITHIN bounds — both files are long enough for the cited line, and neither mentions `tabPermissions` anywhere (0 occurrences, measured); the handler's own comment at current-user-endpoints.ts:922-926 records why, namely that resolveCtx() carries neither systemPermissions nor tabPermissions, so /me/apps re-resolves the sets itself. THE VERDICT IS UNCHANGED and was never in question — `live` with a bound dogfood proof, and the behaviour still ships; what was dead is the citation, which is exactly the rot commit 905019b1b's line bound now makes impossible to leave behind silently. The 2026-07-30 reading survives the re-measurement verbatim: the rank merge reads all four values, not only 'hidden'. Two consumers, deliberately both cited: /me/apps ENFORCES (a hidden app is dropped), /auth/me/permissions PROJECTS the merged map for the renderer. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) and REPOINTED. This entry had SEVEN line citations, more than any other in the batch, and the re-closure is a study in how that ages: `assemble-execution-context.ts:328` was still exact; the five hono lines had all slid a few lines each (`:947` onto `await resolvePermissionSets(...)`, `:963` onto the `const apps = [...]` filter head, `:772` onto the `systemPermissions` set one line above the rank table) — near-misses that read as right to anyone who opens the file; and `permission-set-projection.ts:287` had rotted 30 lines onto `cachedSpecBodyKeys`, a different mechanism entirely. ANCHOR RESIDUAL, stated because it is the batch's weakest: both hono consumers are anonymous route closures inside ONE exported registrar, so the distinction the seven lines carried survives only in this prose. `tabRank` is cited beside the registrar for that reason — it is specific to the tab machinery, so deleting the merge reds the anchor even though the registrar would survive. Re-closed by hand against c459da6bc." }, "rowLevelSecurity": { diff --git a/scripts/adr-anchors/packages__plugins__plugin-security__src__permission-set-projection.ts.json b/scripts/adr-anchors/packages__plugins__plugin-security__src__permission-set-projection.ts.json deleted file mode 100644 index 43d5b6592d2..00000000000 --- a/scripts/adr-anchors/packages__plugins__plugin-security__src__permission-set-projection.ts.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "file": "packages/plugins/plugin-security/src/permission-set-projection.ts", - "adrs": [ - "ADR-0094", - "ADR-0005", - "ADR-0086" - ], - "invariant": "This file IS ADR-0094's machinery (write-through + awaited projector + boot reconciliation), and since 2026-08-09 it is also the site of ADR-0094 D5-R. The 2026-07-14 direction confirmation that once sat in its header — package sets are customized through an ADR-0005 env overlay — is RETIRED: #6483 rolled `permission` back to `allowOrgOverride: false` (the flag is `allowEnvironmentOverlay` since protocol 18), so a metadata write against a CODE-DECLARED (artifact-backed) set is refused 403 NOT_OVERRIDABLE, while a set whose definition lives only in `sys_metadata` rides `allowRuntimeCreate` and still works. Two rules follow and must not be quietly undone: (1) the refusal is LEFT TO THE PRODUCER — never re-derive artifact-backing here to pre-empt it, and never add a tolerant fallback (`??`, catch-and-continue) around the refused `saveMetaItem`; `isArtifactBacked` is the protocol's rule and the `managed_by` column is measurably not that fact; (2) the two write points that DO catch (the `restore` leg and the reconcile backfill) catch because the record already exists by then — they report on the durability channel (#4632) and must keep doing so rather than being 'fixed' into throwing. Restoring the overlay channel needs an ADR-0005 revision ratifying an admission pair, not an edit here or in the registry." -} diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index f6af9a4bac3..2f9468962f2 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -391,14 +391,7 @@ const MAX_HELPER_DEPTH = 4; * read off the output would only prove the instrument agrees with itself. */ const POSITIVE_CONTROLS = [ - { - file: 'packages/plugins/plugin-security/src/permission-set-projection.ts', - why: - 'The SHARED `tryInsert`/`tryUpdate` #12923 repaired. `catch (e) { refusals?.record(object, e); return null; }` ' - + '-- silent by the log axis, a write in the try, and answering through the #12923 accumulator. It must be a ' - + 'member AND it must come back CHANNELLED, or the instrument cannot tell a repair from a swallow.', - tier: 'channelled', - }, + { file: 'packages/plugins/plugin-sharing/src/primary-bu-projection.ts', why: diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs index 04e0104f698..0237c0441a5 100644 --- a/scripts/tenant-audit-census.mjs +++ b/scripts/tenant-audit-census.mjs @@ -1531,12 +1531,7 @@ export const UNTYPED_RECEIVERS = [ engine: true, what: '`ql: any` seed helper writing `schema.name` -- a runtime object name off the registered schema', }, - { - file: 'packages/plugins/plugin-security/src/permission-set-projection.ts', - receiver: 'ql', - engine: true, - what: '`ql: any` writing `sys_permission_set` -- a catalog row object retired by C3 stage 8 (ADR-0131 D3), so the name is no longer a declared object; the module is unwired and deleted with stage 7a', - }, + { file: 'packages/plugins/plugin-security/src/position-write-through.ts', receiver: 'ql', From bb4760b9ba7f4ab30bd5c9543eb926d7959a07a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 00:23:34 +0000 Subject: [PATCH 02/12] test(cli): no writer of sys_permission_set remains; the census, ledgers and liveness row follow the projector's deletion A CLI integration test pins the C3 epic's zero-row acceptance on both kernel shapes: on a fresh database, a package-door publish, a writable base's direct publish and a boot over the stored definitions leave no sys_permission_set row, counted from the file through the test's own connection; a control proves the counter sees a row a writer leaves. The tenant-audit census and its counts are regenerated with the census script, with the prose figures it holds to the census updated; the engine-double ledger is regenerated, and the two baselines drop the entries of the deleted suites. The liveness README's unbound permission-set-projection proof row retires with the invariant, and the uninstall dogfood header no longer describes a projector row. Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- ...04-7a-permission-set-projection-retired.md | 4 +- .../docs/permissions/tenant-audit-census.mdx | 44 ++--- ...08-tenant-audit-write-call-sites.counts.md | 23 ++- ...ermission-set-zero-row.integration.test.ts | 161 ++++++++++++++++++ ...stall-revokes-named-grants.dogfood.test.ts | 9 +- packages/spec/liveness/README.md | 1 - scripts/engine-double-contract.baseline.json | 16 -- scripts/engine-double-contract.pinned.json | 40 ----- scripts/objectql-double-limit.baseline.json | 6 - 9 files changed, 198 insertions(+), 106 deletions(-) create mode 100644 packages/cli/test/permission-set-zero-row.integration.test.ts diff --git a/.changeset/15204-7a-permission-set-projection-retired.md b/.changeset/15204-7a-permission-set-projection-retired.md index 778210e2e31..f924809020e 100644 --- a/.changeset/15204-7a-permission-set-projection-retired.md +++ b/.changeset/15204-7a-permission-set-projection-retired.md @@ -24,6 +24,6 @@ What a consumer sees: - FROM `permissionSetRowFields(definition)` TO nothing: no row is written. Delete the call. - FROM the types `PermissionSeedOutcome`, `ProjectionDeps`, `ProjectionReconcileOutcome` and `WriteThroughDeps` TO nothing. Delete the import. -Unchanged: the packaged-permission-set lock still refuses a metadata-door save of a set a code package ships (`403 NOT_OVERRIDABLE`), and the boot still reports environment overlays that shadow such a set. `OverlayDetectionOptions.logger` now asks only for `warn`, the one channel the reading reports on. +Unchanged: the security catalog's echo of a metadata-door permission set or position on an environment kernel, which lives in its own module since #23308. The packaged-permission-set lock still refuses a metadata-door save of a set a code package ships (`403 NOT_OVERRIDABLE`), and the boot still reports environment overlays that shadow such a set. `OverlayDetectionOptions.logger` now asks only for `warn`, the one channel the reading reports on. -**`@objectstack/spec`** (patch): the `permission.tabPermissions` liveness entry no longer cites the deleted projection. +**`@objectstack/spec`** (patch): the `permission.tabPermissions` liveness entry no longer cites the deleted projection, and the liveness README's registered-but-unbound `permission-set-projection` proof row is retired with the invariant it described (no `@proof` tag carries it any more). diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index a84d1ca73c7..b0d7b799cae 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 40 of them — just under a fifth +typed `any` have no type to read, and there are 37 of them — just under a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 225 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 222 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **225** | +| 175 write call sites | quoted in the merged changeset | **222** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **153 of 225** decidable, **72** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 120 decidably elevated, 0 decidably not, 97 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **152 of 222** decidable, **70** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 119 decidably elevated, 0 decidably not, 95 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 40 sites reached +one, and both are counted in the generated tables below: the 37 sites reached through an erased (`any`) receiver, and the 50 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -120 of 225 (53%) as decidably elevated, with 97 more whose elevation is a +119 of 222 (54%) as decidably elevated, with 95 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 225`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 222`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -223,30 +223,30 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **225** | -| …whose object name is statically decidable | 153 | -| …whose object name is chosen at run time | 72 | +| write call sites on the application surface | **222** | +| …whose object name is statically decidable | 152 | +| …whose object name is chosen at run time | 70 | | …against an object with tenancy ENABLED | 151 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 164 | +| threading a tenant context | 161 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 53 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 120 | +| threading a decidably ELEVATED (`isSystem`) context | 119 | | threading a context that is decidably NOT elevated | 0 | -| threading a context whose elevation is a run-time fact | 97 | +| threading a context whose elevation is a run-time fact | 95 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 185 | | receiver erased, placed by the object NAME | 27 | -| receiver erased, placed by an `object: string` PARAMETER | 7 | -| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 6 | +| receiver erased, placed by an `object: string` PARAMETER | 5 | +| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 103 | +| object name spelled inline | 102 | | object name spelled through a `const` | 50 | -| object name is an `object: string` parameter | 9 | +| object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 63 | ### Subtractions the census could NOT defend — enforced @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-13 at `66949b772`. +Measured on 2026-10-14 at `c0f645856`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 648 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 646 | +| engine-shaped types recognised | 73 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 160 | +| same-named calls subtracted as non-engine | 159 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index c5af45e04ff..b752b250cf1 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 225 | -| Object name statically decidable | 153 | -| Object name chosen at run time | 72 | +| Write call sites | 222 | +| Object name statically decidable | 152 | +| Object name chosen at run time | 70 | | Against a tenancy-enabled object | 151 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 164 | +| Threading a tenant context | 161 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 120 | +| Threading a decidably elevated context | 119 | | Threading a decidably non-elevated context | 0 | -| Threading a context of undecidable elevation | 97 | +| Threading a context of undecidable elevation | 95 | ## Subtractions the census could NOT defend — enforced @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-13 at `66949b772`. +Measured on 2026-10-14 at `c0f645856`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 648 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 646 | +| engine-shaped types recognised | 73 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 160 | +| same-named calls subtracted as non-engine | 159 | ## Every site @@ -161,9 +161,6 @@ Measured on 2026-10-13 at `66949b772`. | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `update` | `GRANT_OBJECT` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | undeclared-name | elevated | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `delete` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 3 | diff --git a/packages/cli/test/permission-set-zero-row.integration.test.ts b/packages/cli/test/permission-set-zero-row.integration.test.ts new file mode 100644 index 00000000000..7cd292ed4da --- /dev/null +++ b/packages/cli/test/permission-set-zero-row.integration.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * C3 (ADR-0131 D3) epic acceptance for stage 7a: no writer of + * `sys_permission_set` remains. On a fresh database, a package-door publish + * of a `permission`, a writable base's direct publish of one, and the boot + * over the stored definitions leave ZERO rows in that table. + * + * ## Why it is measured this way + * + * The last writer was the ADR-0094 projector (`projectPermissionMutation` → + * `upsertEnvPermissionSet`). Stage 6b-2 deleted the seeders and the publish + * materializer, after which the projector alone wrote an `admin` row on every + * publish and re-projected stored sets at boot; stage 8 unwired it and retired + * the row object; stage 7a deletes it. A row could only come back through a + * writer someone wires again, so the count is taken from the FILE, through a + * connection of this test's own, after real publishes through the real + * protocol and a real second boot — not from the engine, whose answer for an + * unregistered object would be an error rather than a count. A table that + * does not exist counts as zero rows: that is the expected state on a fresh + * database, and it is the same answer the authorization path gets (none). + * + * The counter's own control writes one row the way a writer would and reads + * it back as one, so "zero" is never a reader that cannot see a row. + * + * Both kernel shapes run: the host (`os serve`) shape, and the environment + * kernel a hosted tenant runs, whose protocol is delegated to + * `createMetadataProtocolPlugin`. + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectKernel, AppPlugin, DefaultDatasourcePlugin } from '@objectstack/runtime'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { createMetadataProtocolPlugin } from '@objectstack/metadata-protocol'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security'; + +const ENV = 'env_zero_row'; +const TABLE = 'sys_permission_set'; +/** A package whose drafts the package door publishes. */ +const DRAFT_PKG = 'app.zero_row_drafts'; +/** A writable base that publishes its set directly (ADR-0070). */ +const BASE_PKG = 'app.zero_row_base'; + +const stack: any = { + manifest: { id: 'com.example.zero_row', name: 'zero_row', version: '0.0.1', type: 'app' }, + objects: [{ name: 'zero_doc', label: 'Zero Doc', fields: { title: { type: 'text', label: 'Title' } } }], + permissions: [{ name: 'zero_declared_reader', label: 'Declared reader', objects: { zero_doc: { allowRead: true } } }], +}; + +type Kind = 'environment' | 'host'; + +async function boot(kind: Kind, dbFile: string, rootDir: string): Promise { + const kernel = new ObjectKernel(); + await kernel.use(new ObjectQLPlugin(kind === 'environment' ? { environmentId: ENV, registerProtocol: false } : {}) as any); + await kernel.use(new (DefaultDatasourcePlugin as any)({ driver: 'sqlite', config: { filename: dbFile } }, { dev: false })); + if (kind === 'environment') await kernel.use(createMetadataProtocolPlugin({ environmentId: ENV }) as any); + await kernel.use(new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' }, rootDir }) as any); + await kernel.use(new AppPlugin(stack) as any); + await kernel.use(new PlatformObjectsPlugin() as any); + await kernel.use(new AuthPlugin({ secret: 'zero-row-secret-zero-row-secret-zero' }) as any); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(stack)) as any); + await kernel.bootstrap(); + return kernel; +} + +/** Rows in `sys_permission_set`, read from the file; a missing table is zero rows. */ +async function rowsInFile(dbFile: string): Promise { + const probe = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + const knex = (probe as any).knex; + try { + if (!(await knex.schema.hasTable(TABLE))) return 0; + const [{ n }] = await knex(TABLE).count({ n: '*' }); + return Number(n); + } finally { + await knex.destroy(); + } +} + +/** Stored, active, env-wide `permission` definitions — the publishes really landed. */ +async function storedPermissionNames(dbFile: string): Promise { + const probe = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + const knex = (probe as any).knex; + try { + const rows = await knex('sys_metadata').select('name').where({ type: 'permission', state: 'active' }).whereNull('organization_id'); + return rows.map((r: any) => String(r.name)).sort(); + } finally { + await knex.destroy(); + } +} + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +const fresh = (label: string) => { + const dir = mkdtempSync(join(tmpdir(), `zero-row-${label}-`)); + dirs.push(dir); + return { dir, dbFile: join(dir, 'zero.db') }; +}; + +for (const kind of ['host', 'environment'] as Kind[]) { + describe(`${kind} kernel: no writer of sys_permission_set remains (C3 stage 7a acceptance)`, () => { + it('a package-door publish, a writable base\'s direct publish and the boot over them leave zero rows', async () => { + const { dir, dbFile } = fresh(kind); + expect(await rowsInFile(dbFile), 'a fresh database').toBe(0); + + let kernel = await boot(kind, dbFile, dir); + try { + const protocol = await kernel.getServiceAsync('protocol'); + const set = (name: string) => ({ name, label: name, isDefault: true, objects: { zero_doc: { allowRead: true } } }); + + // The package door: a draft into the package, then its atomic publish. + await protocol.saveMetaItem({ type: 'permission', name: 'zero_pkg_default', item: set('zero_pkg_default'), packageId: DRAFT_PKG, mode: 'draft' }); + const published = await protocol.publishPackageDrafts({ packageId: DRAFT_PKG, actor: 'system' }); + expect(published, JSON.stringify(published)).toMatchObject({ outcome: 'published', failed: [] }); + + // A writable base's direct publish. + await protocol.saveMetaItem({ type: 'permission', name: 'zero_base_set', item: set('zero_base_set'), packageId: BASE_PKG, mode: 'publish' }); + } finally { + await kernel.shutdown(); + } + + // Non-vacuity: both publishes landed as stored definitions. + expect(await storedPermissionNames(dbFile)).toEqual(expect.arrayContaining(['zero_base_set', 'zero_pkg_default'])); + expect(await rowsInFile(dbFile), 'after the two publishes').toBe(0); + + // The boot over the stored definitions — where the retired reconcile + // re-projected every stored set into a row. + kernel = await boot(kind, dbFile, dir); + await kernel.shutdown(); + expect(await rowsInFile(dbFile), 'after a boot over the stored definitions').toBe(0); + }, 180_000); + }); +} + +describe('control: the counter sees a row a writer leaves', () => { + it('one row written into sys_permission_set reads back as one', async () => { + const { dbFile } = fresh('control'); + const writer = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + const knex = (writer as any).knex; + try { + await knex.schema.createTable(TABLE, (t: any) => { + t.string('id').primary(); + t.string('name'); + t.string('managed_by'); + }); + await knex(TABLE).insert({ id: 'ps_1', name: 'zero_pkg_default', managed_by: 'admin' }); + } finally { + await knex.destroy(); + } + expect(await rowsInFile(dbFile)).toBe(1); + }); +}); diff --git a/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts b/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts index 196ea52b24c..0d2bb182af8 100644 --- a/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts +++ b/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts @@ -1,12 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // ADR-0090 D5 ("no ghost grants") on the protocol's own uninstall door, -// `deletePackage` — after ADR-0131 C3 stage 6b-2, where no seeder or -// materializer writes a `sys_permission_set` row: a package's set is served by -// the security catalog from the registry, and a grant names it. (Until stage 7 -// retires the ADR-0094 projector, the publish below still has it write an -// `admin` projection row that no authorization path reads; the uninstall -// removes it.) +// `deletePackage`. No `sys_permission_set` row exists to write or remove +// (ADR-0131 C3: no seeder, materializer or projector writes one): a package's +// set is served by the security catalog, and a grant names it. // // The withdrawal (`registry.uninstallPackage`) runs before the uninstall // cleanups and releases the package's names, so the door captures the diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index a941585edc6..62aac8e7053 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -528,7 +528,6 @@ still registered — otherwise its `@proof:` tag reads as an orphan — and reco | Proof | Why unbound | |---|---| | `form-widget-resolution` | no proof written yet (ADR-0054 Phase 2); `view.form.*` is governed and can carry it | -| `permission-set-projection` | a storage invariant (ADR-0094), not an authorable property | | `flow-runas-schedule` | guards `flow.runAs`, already bound to `flow-runas-identity` — one entry carries one `proof` | | `showcase-scope-depth-fallback` | guards `permission.objects.readScope`, already bound — this is the CLI-wiring sibling | | `showcase-agent-intersection` | a runtime principal-resolution invariant (`onBehalfOf`), not authorable | diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index 1d7795819a7..db86142a923 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -376,22 +376,6 @@ "why": "HAND-MIRRORS the guard already (a copy of the #4434 fix's `if`), which is the second copy of the contract this gate exists to remove — but @objectstack/plugin-security does not depend on @objectstack/objectql, so replacing the copy with the producer's predicate needs a devDependency change.", "closes": "add @objectstack/objectql to devDependencies, then replace the mirrored `if` with assertEngineDeleteDispatch(options)" }, - { - "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", - "verb": "delete", - "unguarded": 1, - "kind": "DEBT", - "why": "@objectstack/plugin-security does not depend on @objectstack/objectql. Pinning needs a devDependency + lockfile change, which is a separate reviewable act.", - "closes": "add @objectstack/objectql to devDependencies, then open the fake's delete with assertEngineDeleteDispatch(opts)" - }, - { - "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", - "verb": "update", - "unguarded": 1, - "kind": "DEBT", - "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 35. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", - "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" - }, { "file": "packages/plugins/plugin-security/src/security-plugin.test.ts", "verb": "update", diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 41ab95df765..5f2231c8da2 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3341,31 +3341,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts", - "verb": "delete", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts", - "verb": "update", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts", "verb": "delete", @@ -3386,21 +3361,6 @@ "verb": "findOne", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts", - "verb": "update", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", - "verb": "findOne", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-set-resolution-memo.test.ts", "verb": "findOne", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 29f86e9182c..08ff34574f5 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -585,15 +585,9 @@ "packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts": { "unjudged": 1 }, - "packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts": { - "blind": 1 - }, "packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/permission-set-projection.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-security/src/resolve-permission-sets-for-context.pin.test.ts": { "unjudged": 1 }, From 6de1dfb5d9c222a0f127395da711208a0ff0c52a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 05:48:22 +0000 Subject: [PATCH 03/12] feat(plugin-security): the catalog reference report lists positions naming a permission set the catalog cannot resolve ADR-0131 D4. A position definition whose permissionSets names a set the security catalog does not resolve grants its holders nothing through that name. The boot report now lists each such position once, with its owner and the unresolved names, and the next boot after the package is reinstalled clears it. Claude-Session: https://claude.ai/code/session_01KppP2MoD91F3pXiXXrAmcF Co-Authored-By: Claude --- .../src/catalog-reference-report.test.ts | 104 +++++++++++++++++- .../src/catalog-reference-report.ts | 86 +++++++++++++-- 2 files changed, 180 insertions(+), 10 deletions(-) diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts index 26a94c5d461..5e672dc30dd 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts @@ -389,6 +389,102 @@ describe('[ADR-0131 D4] a removed declaration is reported; re-adding it clears t }); }); +describe('[ADR-0131 D4] a position definition naming a permission set the catalog does not resolve — listed per position', () => { + /** A package that declares one permission set, and a package position binding it beside the auditor set. */ + const SET_PKG = 'com.example.s3.sets'; + const PKG_SET = { name: 's3_pkg_set', label: 'Package set', objects: {} }; + const BINDING_POSITION = 's3_bound_lead'; + + const installSet = (b: Booted) => + (b.engine as any).registry.registerItem('permission', structuredClone(PKG_SET), 'name', SET_PKG); + /** The set leaves the registry the way an uninstall of its package removes it. */ + const uninstallSet = (b: Booted) => (b.engine as any).registry.unregisterItem('permission', PKG_SET.name); + const bindPositions = (b: Booted) => { + (b.engine as any).registry.registerItem( + 'position', + { name: BINDING_POSITION, label: 'Bound lead', permissionSets: [PKG_SET.name, AUDITOR.name, PKG_SET.name] }, + 'name', + PKG, + ); + }; + + it('a fresh deployment reports no position: every built-in, declared and packaged position binds a set the catalog resolves', async () => { + const b = await boot(); + installSet(b); + bindPositions(b); + // Anti-vacuity: the catalog really serves positions that name sets. + const named = (await b.catalog.list('position')).filter((e) => Array.isArray(e.definition.permissionSets) + && (e.definition.permissionSets as unknown[]).length > 0).map((e) => e.name); + expect(named).toEqual(expect.arrayContaining([FIELD_LEAD.name, BINDING_POSITION])); + expect((await b.report()).positions).toEqual([]); + }); + + it('lists each position once, with only the names that resolve nowhere, its owner, and position order', async () => { + const b = await boot(); + installSet(b); + bindPositions(b); + uninstallSet(b); + b.declared.permission.delete(AUDITOR.name); + + const report = await b.report(); + expect(report.stopped).toBeUndefined(); + expect(report.positions).toEqual([ + { position: BINDING_POSITION, packageId: PKG, permissionSets: [PKG_SET.name, AUDITOR.name] }, + { position: FIELD_LEAD.name, packageId: null, permissionSets: [AUDITOR.name] }, + ]); + // A definition belongs to no organization: nothing is filed under one. + expect(findings(report)).toEqual([]); + }); + + it('clears by itself once the package is installed again: the next report lists nothing', async () => { + const b = await boot(); + installSet(b); + bindPositions(b); + uninstallSet(b); + expect((await b.report()).positions).toEqual([ + { position: BINDING_POSITION, packageId: PKG, permissionSets: [PKG_SET.name] }, + ]); + installSet(b); + expect((await b.report()).positions).toEqual([]); + }); + + it('logs one error line naming each position and its unresolved sets, and the clean line only once nothing is left', async () => { + const b = await boot(); + installSet(b); + bindPositions(b); + uninstallSet(b); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + await reportCatalogReferences(b.engine as any, { catalog: b.catalog, logger }); + expect(logger.error.mock.calls.map(([, , meta]: any[]) => meta)).toEqual([{ + count: 1, + permissionSets: [PKG_SET.name], + positions: { items: [{ position: BINDING_POSITION, packageId: PKG, permissionSets: [PKG_SET.name] }] }, + }]); + expect(logger.info).not.toHaveBeenCalled(); + + installSet(b); + logger.error.mockClear(); + await reportCatalogReferences(b.engine as any, { catalog: b.catalog, logger }); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledTimes(1); + }); + + it('an unreadable position list stops the report: no finding, and the stop is said', async () => { + const b = await boot(); + await insertRows(b, 'sys_user_position', [ + { id: 'up_ghost', user_id: 'usr_a', position: 'ghost_lead', organization_id: ORG }, + ]); + for (const list of [vi.fn().mockRejectedValue(new Error('registry offline')), vi.fn().mockResolvedValue(undefined)]) { + const catalog = { resolve: b.catalog.resolve.bind(b.catalog), list }; + const report = await collectCatalogReferences(b.engine as any, { catalog }); + expect({ stopped: report.stopped, organizations: report.organizations, positions: report.positions }).toEqual({ + stopped: 'catalog-unreadable', organizations: [], positions: [], + }); + expect(list).toHaveBeenCalledWith('position'); + } + }); +}); + describe('[ADR-0131 D4] a read that did not happen is never "resolves nowhere"', () => { it('an unreadable catalog stops the report: no finding, and the stop is said', async () => { const b = await boot(); @@ -418,13 +514,14 @@ describe('[ADR-0131 D4] a read that did not happen is never "resolves nowhere"', .toEqual({ stopped: 'scan-unreadable', reading: 'sys_user_permission_set', organizations: [] }); }); - it('a composition without the objects reads nothing and names what it lacks', async () => { + it('a composition without the objects scans no table, still reads the positions, and names what it lacks', async () => { const engine: CatalogReferenceReportEngine = { getObject: () => undefined, find: vi.fn() }; const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - const catalog = { resolve: vi.fn(), list: vi.fn() }; + const catalog = { resolve: vi.fn(), list: vi.fn().mockResolvedValue([]) }; const report = await reportCatalogReferences(engine, { catalog, logger }); expect(report.absent).toEqual(['sys_user_position', 'sys_user_permission_set']); expect(engine.find).not.toHaveBeenCalled(); + expect(catalog.list.mock.calls).toEqual([['position']]); expect([logger.info, logger.warn, logger.error].map((f) => f.mock.calls.length)).toEqual([0, 0, 0]); }); }); @@ -444,7 +541,8 @@ describe('[ADR-0131 D3/D4] SecurityPlugin runs the report at kernel:bootstrapped ]); const handlers = b.hooks.get('kernel:bootstrapped') ?? []; - expect(handlers.length).toBeGreaterThanOrEqual(2); + // The grant-name backfill, then this report (ADR-0131 D4). + expect(handlers).toHaveLength(2); for (const handler of handlers.slice(0, -1)) await handler(); const writes = [ vi.spyOn(b.engine, 'insert'), vi.spyOn(b.engine, 'update'), vi.spyOn(b.engine, 'delete'), diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.ts b/packages/plugins/plugin-security/src/catalog-reference-report.ts index 55b1d17119f..97027983240 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.ts @@ -27,7 +27,18 @@ * resolve (a row-only set) — stay unnamed, and an unnamed grant confers * nothing. * - * It reads no `sys_position` row. The catalog row objects are retired (C3 + * And it lists PER POSITION, since a definition belongs to the environment and + * to no organization: + * + * 4. **Positions naming no permission set** — position definitions in the + * catalog whose `permissionSets` name a set the catalog does not resolve. + * The authorization resolver reads a position's sets from its definition, + * so a holder of the position is granted nothing through such a name. The + * usual cause is a package that declared the set and is now uninstalled or + * disabled while a position still binds it; reinstalling the package clears + * the line at the next boot, with nothing else to do. + * + * It reads no `sys_position` row: positions are read from the catalog. The catalog row objects are retired (C3 * stage 8), so no composition provisions that table, and a Setup-authored * position row with no definition — or a name several such rows carry — is * the upgrade ceremony's to convert or report (ADR-0131 D10, C7), never a boot @@ -49,7 +60,8 @@ * report: it says which store could not be read and reports no finding past * that point, because an unread answer is never taken for an absent name. A * composition without one of the two objects reads the other and names the - * absent one in the result. + * absent one in the result; the position definitions are read from the catalog + * in every composition. * * ## When it runs * @@ -63,7 +75,7 @@ * ADR anchors: ADR-0131 D3 (one catalog home), D4 (references by name). */ -import type { SecurityCatalogReader, SecurityCatalogType } from '@objectstack/core'; +import type { SecurityCatalogEntry, SecurityCatalogReader, SecurityCatalogType } from '@objectstack/core'; import { GRANT_OBJECT, GRANT_SET_ID_FIELD, GRANT_SET_NAME_FIELD } from './grant-permission-set-name.js'; /** The user ↔ position assignment object (ADR-0057 D4). */ @@ -122,6 +134,16 @@ export interface OrganizationCatalogReferences { unnamedGrants: UnnamedGrant[]; } +/** One position definition that names permission sets the catalog does not resolve. */ +export interface PositionNamingUnresolvedSets { + /** The position's name. */ + position: string; + /** The package the definition names as its owner, or `null` for a stack or environment definition. */ + packageId: string | null; + /** The names in its `permissionSets` the catalog does not resolve, in declared order. */ + permissionSets: string[]; +} + /** Why the report stopped before it judged every reference. */ export type CatalogReferenceReportStop = | 'scan-unreadable' @@ -130,6 +152,8 @@ export type CatalogReferenceReportStop = /** What one report found. Only organizations with a finding are listed, the organization-less bucket first. */ export interface CatalogReferenceReport { organizations: OrganizationCatalogReferences[]; + /** Position definitions naming a permission set the catalog does not resolve, by position name. */ + positions: PositionNamingUnresolvedSets[]; /** The objects this composition does not provision; their part of the report was not read. */ absent: string[]; /** Set when a read did not happen; no finding is reported past it. */ @@ -203,6 +227,20 @@ async function scanAll( return out; } +/** Every position definition in the catalog; a read that fails, or answers no list, stops the report. */ +async function listPositions(catalog: SecurityCatalogReader): Promise { + let entries: unknown; + try { + entries = await catalog.list('position'); + } catch (e) { + throw new ReportStop('catalog-unreadable', 'the security catalog', e); + } + if (!Array.isArray(entries)) { + throw new ReportStop('catalog-unreadable', 'the security catalog', new Error('the position list answered no list')); + } + return entries as SecurityCatalogEntry[]; +} + /** The catalog's answer per name, each name asked once. */ function catalogResolver(catalog: SecurityCatalogReader): (type: SecurityCatalogType, name: string) => Promise { const memo = new Map(); @@ -233,6 +271,7 @@ export async function collectCatalogReferences( ): Promise { const report: CatalogReferenceReport = { organizations: [], + positions: [], absent: [], }; const has = (object: string): boolean => { @@ -290,16 +329,30 @@ export async function collectCatalogReferences( } } + for (const entry of await listPositions(deps.catalog)) { + const declared = entry?.definition?.permissionSets; + if (!Array.isArray(declared)) continue; + const unresolved: string[] = []; + for (const value of declared) { + const name = nameOf(value); + if (name === undefined || unresolved.includes(name) || (await resolves('permission', name))) continue; + unresolved.push(name); + } + if (unresolved.length === 0) continue; + report.positions.push({ position: entry.name, packageId: entry.packageId ?? null, permissionSets: unresolved }); + } } catch (e) { if (!(e instanceof ReportStop)) throw e; report.stopped = e.stop; report.stoppedReading = e.reading; report.stoppedError = errorText(e.reason); report.organizations = []; + report.positions = []; return report; } report.organizations = [...byOrganization.values()].sort((a, b) => compareKeys(a.organizationId, b.organizationId)); + report.positions.sort((a, b) => compareKeys(a.position, b.position)); return report; } @@ -395,12 +448,31 @@ function logReport(report: CatalogReferenceReport, logger?: CatalogReferenceRepo ); } - const clean = report.organizations.length === 0; - // A composition with neither object has nothing to report on. + if (report.positions.length > 0) { + const sets = new Set(report.positions.flatMap((p) => p.permissionSets)); + logError( + logger, + `[security] ${report.positions.length} position definition(s) name a permission set the security catalog ` + + 'does not resolve at this boot (ADR-0131 D4), so a holder of the position is granted nothing through that ' + + 'name. Nothing was changed. Fix: install or enable again the package that declared the permission set (the ' + + 'next boot then clears this line by itself), declare the set again, or remove the name from the ' + + "position's permissionSets.", + { + count: report.positions.length, + permissionSets: [...sets].sort(), + positions: listed(report.positions.map((p) => ({ + position: p.position, packageId: p.packageId, permissionSets: p.permissionSets, + }))), + }, + ); + } + + const clean = report.organizations.length === 0 && report.positions.length === 0; + // A composition with neither object has no assignment or grant to report on. if (clean && report.absent.length < 2) { logger?.info?.( - '[security] catalog reference report: every position assignment and permission-set grant names an item ' + - 'the security catalog resolves at this boot', + '[security] catalog reference report: every position assignment, permission-set grant and position ' + + "definition's permission set names an item the security catalog resolves at this boot", { absent: report.absent }, ); } From 5a2d2062864d529ed872123b2c535895dc79c43b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 05:48:22 +0000 Subject: [PATCH 04/12] test: re-tighten the spans the stage-8 test half loosened to hold on both trees Stage 8 is on main, so each pin states the post-stage-8 answer exactly: the uninstall sweep's outcome and refusal count, the two kernel:bootstrapped handler counts, the clone route's prescription, the data door's 404 for the retired catalog objects, the tenancy inventory's empty global set, and the uninstall runner's request. Claude-Session: https://claude.ai/code/session_01KppP2MoD91F3pXiXXrAmcF Co-Authored-By: Claude --- ...protocol.uninstall-cleanups-runner.test.ts | 6 +-- ...ata-repository.package-writability.test.ts | 38 ++++++++---------- .../tenancy-by-object-classification.test.ts | 11 +++-- .../src/cleanup-package-permissions.test.ts | 40 +++++++++---------- ...grant-permission-set-name-backfill.test.ts | 9 ++--- .../permission-set-clone-door.dogfood.test.ts | 12 ++---- .../test/wall-catalog-refusal.dogfood.test.ts | 26 +++++------- 7 files changed, 63 insertions(+), 79 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts index c4e0707d7dc..aaed2892eac 100644 --- a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts +++ b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts @@ -99,9 +99,9 @@ describe('#21490: runUninstallCleanups — the uninstall-cleanup registry\'s one const res = await protocol.deletePackage(request); - // Its own request — plus, when the package declared permission sets, their - // names (`declared`, ADR-0131 D4), read before its withdrawal. - expect(runner.mock.calls).toEqual([[expect.objectContaining(request)]]); + // Its own request, exactly: this package declared no permission set, so + // no `declared` names (ADR-0131 D4) ride along. + expect(runner.mock.calls).toEqual([[request]]); expect(res.cleanups).toEqual(await runner.mock.results[0]!.value); expect(res.cleanups).toEqual([{ name: 'security.package-permissions', success: true, removed: 4 }]); }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts index f2b6445ee31..e92e5daefa9 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts @@ -574,11 +574,12 @@ describe('#7682 — the refusal discriminates on package writability', () => { `Cannot overlay 'permission' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` + 'is locked against in-place edits. ', )).toBe(true); - // The clone route's spelling moved with C3 stage 8 (the data route of the - // retired `sys_permission_set` object → the catalog's clone door); the - // sentence around it did not. - expect(message).toContain('Clone it under a new name to customize it (the "Clone" action on the permission set, or POST /api/v1/'); - expect(message).toContain(' with a new name).'); + // The clone route is the catalog's clone door: the data route of the + // `sys_permission_set` object retired with it (C3 stage 8). + expect(message).toContain( + 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' + + 'or POST /api/v1/security/permission-sets/:name/clone with a new name).', + ); expect(message).not.toContain('OS_METADATA_WRITABLE'); expect(message).not.toContain('redeploy'); expect(message.endsWith('See docs/adr/0126-packaged-metadata-customization-model.md.')).toBe(true); @@ -1254,17 +1255,10 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, const ADR_0126 = 'See docs/adr/0126-packaged-metadata-customization-model.md.'; const ACTION_SWITCH = 'Switch it off (POST /api/v1/actions/_activation/:object/:action, body {enabled: false}, ' + ':object = global for an object-less action; operator-only where one install serves several organizations).'; - // `{ROUTE}`: the clone route, whose spelling moved with C3 stage 8 (the - // retired object's data route → the catalog's clone door); see `sentenceMatches`. + // The clone route is the catalog's clone door: the data route of the + // `sys_permission_set` object retired with it (C3 stage 8). const PERMISSION_CLONE = 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' - + 'or POST {ROUTE} with a new name).'; - /** Exact equality, with a `{ROUTE}` placeholder standing for one `/api/v1/…` path. */ - const sentenceMatches = (actual: string, expected: string): boolean => { - const [head, tail] = expected.split('{ROUTE}'); - if (tail === undefined) return actual === expected; - return actual.startsWith(head) && actual.endsWith(tail) - && /^\/api\/v1\/\S+$/.test(actual.slice(head.length, actual.length - tail.length)); - }; + + 'or POST /api/v1/security/permission-sets/:name/clone with a new name).'; const FLOW_PATHS = 'Clone it under a new name to customize it (POST /api/v1/automation/:name/clone, body {name, label}), ' + 'or switch it off (POST /api/v1/automation/:name/toggle, body {enabled: false}; ' + 'operator-only where one install serves several organizations).'; @@ -1296,9 +1290,10 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, it(`type door, save: a packaged ${type} is refused NOT_OVERRIDABLE with its row's path, no hatch`, async () => { const err = await putWith(repo, { type, name: `pkg_${type}`, intent: 'override-artifact' }) as any; expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - const expected = `Metadata item '${type}/pkg_${type}' is provided by a managed package and is sealed ` - + `against in-place edits. ${paths} ${ADR_0126}`; - expect(sentenceMatches(String(err.message), expected), `${String(err.message)}\n≠ ${expected}`).toBe(true); + expect(String(err.message)).toBe( + `Metadata item '${type}/pkg_${type}' is provided by a managed package and is sealed ` + + `against in-place edits. ${paths} ${ADR_0126}`, + ); expect(Array.from(engine.rows.values())).toEqual([]); }); } @@ -1368,9 +1363,10 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, code: 'ITEM_LOCKED', status: 403, lockSource: 'package', packageId: READ_ONLY_PKG, docs: 'docs/adr/0010-metadata-protection-model.md', }); - const expected = `Cannot overlay '${type}' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` - + `is locked against in-place edits. ${paths} ${ADR_0126}`; - expect(sentenceMatches(String(err.message), expected), `${String(err.message)}\n≠ ${expected}`).toBe(true); + expect(String(err.message)).toBe( + `Cannot overlay '${type}' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` + + `is locked against in-place edits. ${paths} ${ADR_0126}`, + ); }); } diff --git a/packages/objectql/src/tenancy-by-object-classification.test.ts b/packages/objectql/src/tenancy-by-object-classification.test.ts index 65195991ab1..90c610a20d7 100644 --- a/packages/objectql/src/tenancy-by-object-classification.test.ts +++ b/packages/objectql/src/tenancy-by-object-classification.test.ts @@ -137,11 +137,14 @@ describe('#13491 the inventory — a verdict per object, never a namespace', () it('#8672 inherits PER OBJECT — the same namespace holds both verdicts', () => { expect(classifyPlatformObjectTenancy('sys_file')).toBe('tenant-scoped'); + // The inventory's one `global` entry, `sys_permission_set`, retired with + // its object (C3 stage 8): no `global` verdict is recorded today. A new one + // brings its own discriminating pair here. + expect(Object.entries(PLATFORM_OBJECT_TENANCY).filter(([, e]) => e.tenancy === 'global')).toEqual([]); + expect(classifyPlatformObjectTenancy('sys_permission_set')).toBe('unclassified'); // The discriminating half: the old blanket predicate answered the SAME - // thing for every platform object, which is the exemption the ruling - // withdrew. Each verdict the inventory records is honoured as recorded — - // read off the inventory, since its one `global` entry - // (`sys_permission_set`) retired with its object (C3 stage 8). + // thing for both of these, which is the exemption the ruling withdrew. + expect(isPlatformObjectOutOfTenantAuditScope('sys_permission_set')).toBe(true); expect(isPlatformObjectOutOfTenantAuditScope('sys_file')).toBe(false); for (const [name, entry] of Object.entries(PLATFORM_OBJECT_TENANCY)) { expect(classifyPlatformObjectTenancy(name), name).toBe(entry.tenancy); diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts index 3895d8f8053..265c1829a74 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts @@ -7,9 +7,9 @@ import { cleanupPackagePermissions } from './cleanup-package-permissions'; /** * The permission-set names the uninstalling door read off the package before - * its withdrawal (`UninstallDeclared`, ADR-0131 D4). Handed to the sweep, they - * revoke the same grants whether it also finds the package's sets by their - * catalog rows (before C3 stage 8) or by these names alone (after it). + * its withdrawal (`UninstallDeclared`, ADR-0131 D4). Since C3 stage 8 they are + * the only way the sweep reaches the package's sets: no catalog row stands for + * a set. */ const DECLARED = { permission: ['crm_readonly', 'crm_admin'] }; @@ -17,8 +17,8 @@ const DECLARED = { permission: ['crm_readonly', 'crm_admin'] }; * A store holding both shapes a grant has carried: every grant names its set * (ADR-0131 D4) and still carries the legacy id of the set's catalog row. The * catalog rows themselves are here too — what a release before the row objects - * were retired (C3 stage 8) wrote — so the sweep revokes the same grants - * whether it finds the package's sets by its rows or by its declared names. + * were retired (C3 stage 8) wrote — and the sweep reads and deletes none of + * them: it finds the package's sets by their declared names alone. */ function makeQl() { const tables: Record = { @@ -69,7 +69,7 @@ describe('cleanupPackagePermissions (#2747)', () => { const ql = makeQl(); const out = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); - expect(out).toMatchObject({ userGrants: 2, suggestions: 2, refused: 0 }); + expect(out).toEqual({ ...NOTHING, userGrants: 2, suggestions: 2 }); // no ghost grants: no grant naming the package's sets survives expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_other']); @@ -79,10 +79,8 @@ describe('cleanupPackagePermissions (#2747)', () => { it('never touches env-authored or foreign-package sets or their grants (ADR-0086 D4 provenance)', async () => { const ql = makeQl(); await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); - const names = ql.tables.sys_permission_set.map((r: any) => r.name); - expect(names).toContain('other_set'); - expect(names).toContain('member_default'); - expect(names).toContain('admin_full_access'); + // No catalog row is touched, the package's own legacy rows included. + expect(ql.tables.sys_permission_set.map((r: any) => r.id)).toEqual(['ps_crm_ro', 'ps_crm_admin', 'ps_other', 'ps_env', 'ps_legacy']); const grants = ql.tables.sys_user_permission_set.map((r: any) => r.permission_set); expect(grants).toEqual(['member_default', 'other_set']); }); @@ -91,7 +89,7 @@ describe('cleanupPackagePermissions (#2747)', () => { const ql = makeQl(); await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); const second = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); - expect(second).toMatchObject({ userGrants: 0, suggestions: 0, refused: 0 }); + expect(second).toEqual(NOTHING); }); it('no-ops safely on a missing package id or a non-engine handle', async () => { @@ -122,8 +120,8 @@ describe('cleanupPackagePermissions (#2747)', () => { expect(nothingToDo.sets + nothingToDo.positionBindings + nothingToDo.userGrants + nothingToDo.suggestions).toBe(0); expect(refused).not.toEqual(nothingToDo); - // At least the 2 grants and the 2 suggestion rows. - expect(refused.refused).toBeGreaterThanOrEqual(4); + // The 2 grants and the 2 suggestion rows. + expect(refused.refused).toBe(4); expect(nothingToDo.refused).toBe(0); }); @@ -160,7 +158,7 @@ describe('cleanupPackagePermissions (#2747)', () => { // The `?.` on `warn` is the backstop for hosts the TYPE cannot reach. // A refusal report must never become the thing that breaks an uninstall. const out = await cleanupPackagePermissions(refusingQl(), 'com.example.crm', {}, DECLARED); - expect(out.refused).toBeGreaterThanOrEqual(4); + expect(out.refused).toBe(4); }); }); }); @@ -184,21 +182,21 @@ describe('[ADR-0131 D4, ADR-0090 D5] the grants naming a set the package declare permission: [...DECLARED.permission, 'crm_catalog_user'], }); - // The two grants naming the package's row-backed sets and the two naming - // its registry-only set. + // The two grants naming the sets the fixture also holds legacy rows for, + // and the two naming the set that never had one. expect(out.userGrants).toBe(4); expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_other', 'ups_named_other']); }); - it('control — handed no names, a grant naming a registry-only set survives', async () => { + it('control — handed no names, the sweep revokes no grant', async () => { const ql = withNamedGrants(); const out = await cleanupPackagePermissions(ql, 'com.example.crm'); - expect(out).toMatchObject({ suggestions: 2, refused: 0 }); - // Whatever the row legs reach while the catalog rows still exist, no grant - // naming a set the sweep was not handed is revoked. + expect(out).toEqual({ ...NOTHING, suggestions: 2 }); + // No grant is revoked: the legacy rows the fixture holds for the package's + // sets are not a way in. expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual( - expect.arrayContaining(['ups_env', 'ups_other', 'ups_named_1', 'ups_named_2', 'ups_named_other']), + ['ups_1', 'ups_2', 'ups_env', 'ups_other', 'ups_named_1', 'ups_named_2', 'ups_named_other'], ); }); }); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts index bd5802bec94..0d954f95e5d 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -674,12 +674,11 @@ describe('[ADR-0131 D4] grant name backfill — boot wiring', () => { await seedPrincipals(world, 'single'); await clearAllNames(world.engine); - // The `kernel:bootstrapped` handlers, in registration order: this backfill - // first, the catalog reference report (`catalog-reference-report.test.ts`) - // last — and between them the row-only position backfill until C3 stage 8 - // unwires it. Only the first runs here. + // Two `kernel:bootstrapped` handlers, in registration order: this + // backfill, then the catalog reference report, which + // `catalog-reference-report.test.ts` drives. Only the first runs here. const handlers = world.hooks.get('kernel:bootstrapped') ?? []; - expect(handlers.length).toBeGreaterThanOrEqual(2); + expect(handlers).toHaveLength(2); const errorsBefore = world.pluginLogger.error.mock.calls.length; const warningsBefore = world.pluginLogger.warn.mock.calls.length; await handlers[0](); diff --git a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts index ab7e476bd7f..e4c241c17fd 100644 --- a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts @@ -69,15 +69,9 @@ describe('the permission-set clone door, on a booted showcase', () => { expect(source?.packageId, 'the source is not packaged on this boot').toBeTruthy(); expect(source?.definition.adminScope, 'the source carries no adminScope').toBeTruthy(); - // The source's catalog ROWS are removed first, where the row object is - // still registered: the door reads the catalog, so the clone must land - // with no row to read. (C3 stage 8 retires the object, and there are - // then no rows at all.) - if (ql.getObject?.('sys_permission_set')) { - const rows = await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX }); - for (const row of rows) await ql.delete('sys_permission_set', { where: { id: row.id }, context: SYSTEM_CTX }); - expect(await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX })).toEqual([]); - } + // The door reads the catalog, and no row stands for the source: C3 + // stage 8 retired the `sys_permission_set` row object. + expect(ql.getObject?.('sys_permission_set')).toBeFalsy(); const res = await clone(token, DELEGATE_SET, { name: DELEGATE_COPY, label: 'My Field Ops Delegate' }); const text = await res.clone().text(); diff --git a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts index 54813c3396a..823b0bad08f 100644 --- a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts @@ -22,10 +22,9 @@ // Pinned here: // - walled: org A's catalog writes at `/meta` (save, create, delete) answer // `403 FORBIDDEN`, and B's resolution does not move; -// - walled: the data door's catalog creates answer `403 PERMISSION_DENIED` -// while the catalog row objects are registered, and `404 OBJECT_NOT_FOUND` -// once C3 stage 8 retires them; either way nothing reaches the environment -// ledger; +// - walled: the data door's catalog creates answer `404 OBJECT_NOT_FOUND` +// (C3 stage 8 retired the catalog row objects), and nothing reaches the +// environment ledger; // - walled: the permission-set clone door refuses org A a clone // `403 PERMISSION_DENIED`, and nothing is saved; // - walled CONTROLS: org A still assigns a catalog permission set to its own @@ -172,24 +171,19 @@ for (const posture of ['isolated', 'group'] as const) { expect((await definitionOf('permission', SHARED))?.item?.name).toBe(SHARED); }); - it('the data door refuses org A a catalog create, and nothing reaches the environment ledger', async () => { - // While the catalog row objects are registered the walled data-door - // refusal answers; once C3 stage 8 retires them (ADR-0131 D3) the data - // door serves no such object at all. - const refusal = (object: string) => (ql.getObject?.(object) - ? { status: 403, code: 'PERMISSION_DENIED' } - : { status: 404, code: 'OBJECT_NOT_FOUND' }); + it('the data door serves org A no catalog object 404 OBJECT_NOT_FOUND, and nothing reaches the environment ledger', async () => { + // C3 stage 8 retired the catalog row objects (ADR-0131 D3): the catalog + // is written through the metadata door alone. + expect(ql.getObject?.('sys_permission_set')).toBeFalsy(); + expect(ql.getObject?.('sys_position')).toBeFalsy(); const set = await stack.apiAs(tokA, 'POST', '/data/sys_permission_set', { name: 'w_dd_set', label: 'Data door set' }); - expect(await envelopeOf(set)).toEqual(refusal('sys_permission_set')); + expect(await envelopeOf(set)).toEqual({ status: 404, code: 'OBJECT_NOT_FOUND' }); expect((await definitionOf('permission', 'w_dd_set'))?.item).toBeFalsy(); const position = await stack.apiAs(tokA, 'POST', '/data/sys_position', { name: 'w_dd_pos', label: 'Data door position' }); - expect(await envelopeOf(position)).toEqual(refusal('sys_position')); + expect(await envelopeOf(position)).toEqual({ status: 404, code: 'OBJECT_NOT_FOUND' }); expect((await definitionOf('position', 'w_dd_pos'))?.item).toBeFalsy(); - if (ql.getObject?.('sys_position')) { - expect(await ql.find('sys_position', { where: { name: 'w_dd_pos' }, context: SYS })).toEqual([]); - } }); it("the clone door refuses org A a clone of an environment set 403 PERMISSION_DENIED, and nothing is saved", async () => { From 36bf566c22bb45bcf455e118ef5d8cadae4c6d87 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 05:48:23 +0000 Subject: [PATCH 05/12] refactor(plugin-security): remove the retired catalog row objects' write gates and the walled data door, and correct the texts that named them ADR-0131 D3, C3 stage 8-post. Removes the four security-middleware gates on sys_permission_set, sys_position, sys_capability and sys_position_permission_set, the delegated-admin catalog cases, the duplicate-name refusal class, and the unwired walled data-door refusal with its anchor and system-context citation. Drops the packaged-permission-set lock's insert-only text, the durability gate vocabulary rows of the deleted position backfill, and corrects the comments, descriptions and doc comments that still named the deleted modules. Regenerated: spec references and the en translation bundles. Claude-Session: https://claude.ai/code/session_01KppP2MoD91F3pXiXXrAmcF Co-Authored-By: Claude --- .../15204-8post-catalog-gates-retired.md | 22 + content/docs/permissions/system-context.mdx | 2 +- content/docs/references/api/protocol.mdx | 6 +- .../docs/references/identity/eval-user.mdx | 4 +- .../areas/access-security.json | 2 +- .../src/security/permission-sets.ts | 4 +- .../src/security/position-row-definition.ts | 7 +- ...ocol.adr0005-org-override-rollback.test.ts | 11 +- .../apps/translations/en.objects.generated.ts | 2 +- .../translations/es-ES.objects.generated.ts | 2 +- .../translations/ja-JP.objects.generated.ts | 2 +- .../translations/zh-CN.objects.generated.ts | 2 +- .../src/identity/sys-invitation.object.ts | 2 +- .../src/builtin-positions.boot.test.ts | 91 +-- ...ed-admin-gate-holding-organization.test.ts | 117 +--- .../src/delegated-admin-gate.test.ts | 166 ----- .../src/delegated-admin-gate.ts | 242 +------ .../plugin-security/src/errors.test.ts | 1 - .../plugins/plugin-security/src/errors.ts | 83 --- .../src/object-posture-gate.ts | 3 +- .../sys-audience-binding-suggestion.object.ts | 2 +- .../src/objects/sys-user-position.object.ts | 4 +- .../src/packaged-permission-set-lock-gate.ts | 14 +- .../src/packaged-permission-set-lock.ts | 59 +- ...d-permission-set-overlay-detection.test.ts | 11 +- ...ckaged-permission-set-overlay-detection.ts | 16 +- .../src/permission-set-name-collision.ts | 6 +- .../src/security-plugin.test.ts | 617 ------------------ .../plugin-security/src/security-plugin.ts | 563 +--------------- .../src/store-fault-fail-closed.test.ts | 240 +------ .../src/translations/en.objects.generated.ts | 4 +- .../translations/es-ES.objects.generated.ts | 4 +- .../translations/ja-JP.objects.generated.ts | 4 +- .../translations/zh-CN.objects.generated.ts | 4 +- .../src/walled-catalog-data-door.ts | 88 --- .../plugin-security/src/write-refusals.ts | 3 +- .../src/objects/sys-sharing-rule.object.ts | 7 +- .../src/sharing-rule-provenance.ts | 4 +- .../spec/src/api/error-code-ledger.zod.ts | 26 - packages/spec/src/api/protocol.zod.ts | 11 +- packages/spec/src/identity/eval-user.zod.ts | 6 +- packages/spec/src/identity/position.zod.ts | 8 +- .../spec/src/kernel/metadata-plugin.zod.ts | 4 +- packages/spec/src/security/permission.zod.ts | 5 +- ...ity__src__walled-catalog-data-door.ts.json | 7 - ...check-durability-degradation-log-level.mjs | 6 +- scripts/measure-durability-swallow-family.mjs | 6 +- 47 files changed, 168 insertions(+), 2332 deletions(-) create mode 100644 .changeset/15204-8post-catalog-gates-retired.md delete mode 100644 packages/plugins/plugin-security/src/walled-catalog-data-door.ts delete mode 100644 scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json diff --git a/.changeset/15204-8post-catalog-gates-retired.md b/.changeset/15204-8post-catalog-gates-retired.md new file mode 100644 index 00000000000..f07333a93a9 --- /dev/null +++ b/.changeset/15204-8post-catalog-gates-retired.md @@ -0,0 +1,22 @@ +--- +'@objectstack/plugin-security': patch +'@objectstack/platform-objects': patch +'@objectstack/spec': patch +'@objectstack/core': patch +--- + +fix(plugin-security): the data-door write gates of the retired catalog row objects are removed, the boot report names positions that bind a permission set nobody serves, and the texts that still named the retired objects and modules are corrected (ADR-0131 D3/D4, C3 stage 8-post) + +Clause-②: no + +Stage 8 retired the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` row objects, so no write to them reaches the engine. This change removes what only guarded those writes. No export of any package's entry changes, and no request that reaches the server is answered differently. + +- **`@objectstack/plugin-security`.** + - The security middleware's four gates on those objects are gone: the package-provenance gate on `sys_permission_set`, the platform/package row gate on `sys_position` and `sys_capability`, the curated capability-name gate on `sys_capability`, and the audience-anchor binding gate on `sys_position_permission_set`. The delegated-administration gate no longer judges `sys_position_permission_set` bindings or `sys_permission_set` authoring; it still governs `sys_user_position` assignments, `sys_user_permission_set` grants and `sys_member`. The rules those gates carried still hold where the catalog now lives: `everyone` and `guest` are code-declared positions an environment cannot overlay, an `everyone` binding is accepted only through the audience-binding suggestion, which refuses a set the anchor forbids, and a capability cannot be created or overlaid through the metadata door at all. + - The walled data-door refusal for those objects is deleted. Stage 8 already left it unwired, and no composition registers the objects it judged; the metadata door's walled refusal is unchanged. + - The internal duplicate-name refusal class that only the retired permission-set data door raised is removed. + - The packaged-permission-set lock's refusal no longer has a "choose a different name" variant: only the retired data door's create raised it. The metadata door's refusal text is unchanged, and the published signatures keep their `operation` parameter. + - The boot's catalog reference report lists one more finding, per position: a position definition whose `permissionSets` names a set the security catalog does not resolve, usually because the package that declared the set is uninstalled or disabled. A holder of such a position is granted nothing through that name. The line clears by itself at the next boot after the package is installed again. It is logged at `error` once per boot, like the report's other lines. +- **`@objectstack/platform-objects`** and **`@objectstack/plugin-security`** object metadata: the descriptions of `sys_invitation.positions`, `sys_user_position` (object and `position`), and `sys_audience_binding_suggestion`'s permission-set name no longer name the retired objects. The translated texts follow. +- **`@objectstack/spec`**: the `projectionApplied` and `materializeApplied` descriptions on the metadata save, publish and batch-publish responses give a current example (`datasource`, `flow`) instead of `permission` → `sys_permission_set`. `EvalUser.positions` describes the positions assigned through `sys_user_position`. `PermissionSet.description`'s doc comment no longer names the deleted projection. The error-code ledger drops `@objectstack/plugin-security`'s `UNIQUE_VIOLATION` row, which named the removed refusal; `UNIQUE_VIOLATION` stays registered by its other emitters. +- **`@objectstack/core`**: `positionDefinitionFromRow`'s doc comment no longer describes the deleted row-only position backfill as current. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 3e42dd3a5e1..431411d1e59 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -184,7 +184,7 @@ One of the largest consumers of the flag; | 54 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | -| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, plugin-security, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), at the permission-set clone door, and at the data door on `sys_position`, `sys_permission_set` and `sys_capability` while those objects are registered. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite`, `packages/runtime/src/domains/automation.ts#refuseWalledFlowDefinitionWrite` | +| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), and at the permission-set clone door. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite`, `packages/runtime/src/domains/automation.ts#refuseWalledFlowDefinitionWrite` | | 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | | 58 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | | 59 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index b15cd76bbc7..bedffc6fd7b 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2339,7 +2339,7 @@ Installed package with runtime lifecycle state | **version** | `string` | ✅ | Version token of the row this promotion left, and the token the ADR-0008 optimistic-concurrency chain runs on: send it back as the `If-Match` request header on the next write to that item and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of silently overwritten. A keyed digest of the stored content hash, never the hash itself — the same form `SaveMetaItemResponseSchema.version` and the item read's `version` serve. Opaque to callers — echo it verbatim, never parse it. Currently emitted as `hmac-sha256:` plus 64 hex characters, but the format is not part of this contract. | | **seq** | `integer` | ✅ | Monotonic sequence number of the `op='publish'` metadata event this promotion appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. | | **seedApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string; … }` | optional | Outcome of materializing a published `seed` body into data rows. Present ONLY when the published type is `seed` — publishing a seed is what makes its rows live, so the load rides along with the metadata promotion. Best-effort: a seed-load problem is surfaced here, never thrown, so a caller must check `seedApplied.success` instead of assuming the 200 covered the data. Absent on the batch path, which suppresses the per-item apply and loads every seed body in one later pass. | -| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string }` | optional | Outcome of the ADR-0086 P2 publish-time materializer — the step that projects the published body into its data-plane row (e.g. `permission` → `sys_permission_set`, under the owning package). Present ONLY when a materializer is registered for this metadata type, which is why it is optional: its absence means "no materializer ran", never "it failed". Best-effort, same contract as `seedApplied`. | +| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string }` | optional | Outcome of the ADR-0086 P2 publish-time materializer — the step that applies the published body to its data-plane state (e.g. `flow`, whose draft credentials become the live ones). Present ONLY when a materializer is registered for this metadata type, which is why it is optional: its absence means "no materializer ran", never "it failed". Best-effort, same contract as `seedApplied`. | | **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model. The same receipt `{@link SaveMetaItemResponseSchema}` carries, because the projector runs on BOTH write doors: a direct active save and this draft→active promotion. Present ONLY when a projector is registered for this metadata type. Best-effort — a projector failure is reported here and logged, never thrown. | | **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to the DRAFT body this promotion carried to `active` (the same key `SaveMetaItemResponseSchema` carries, because the gate runs on both write doors, D1). The promotion SUCCEEDED; these are what the gate has to say about it anyway. Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean publish's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the promotion and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. This door is the one Studio's designer takes on every edit (draft save, then publish), and a Studio / MCP / AI author has no CLI at all — which is the gap this key exists to close. | | **message** | `string` | optional | Human-readable receipt, e.g. `Published draft — type=view, name=cases [seq=3]`. The producer sets it on every publish today; it stays optional to match the producer's own signature and its `SaveMetaItemResponse` twin, and because an absent human-readable string strips no data — the failure mode this key exists to prevent. | @@ -2397,7 +2397,7 @@ Installed package with runtime lifecycle state | **published** | `{ type: string; name: string; version: string; advisories?: object[] }[]` | ✅ | Every draft promoted to active, in publish order. Empty on every refusal path. | | **failed** | `{ type: string; name: string; error: string; code?: string; … }[]` | ✅ | Items that did not publish. Because the batch is all-or-nothing (ADR-0067 D2), a non-empty list means NOTHING landed: `published: []`, `publishedCount: 0`. | | **seedApplied** | `{ success: boolean; inserted?: integer; updated?: integer; error?: string; … }` | optional | Aggregate outcome of materializing EVERY published `seed` body in one multi-pass loader run (cross-seed references need the whole set). Present ONLY when the batch published at least one seed. Two producers, one key: the batch itself self-applies (`applySeedBodies`), and the REST door back-fills the same key for custom protocols that do not — never both (an externalId-less seed would double-insert). Best-effort: a seed problem is surfaced here, never thrown. | -| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; failures: object[] }` | optional | ADR-0086 P2 — aggregate result of publish-time materializers across the batch (e.g. `permission` → `sys_permission_set`), including side-effect failures surfaced by the per-item effects loop. Present ONLY when at least one published item had a registered materializer or a side-effect failure. Best-effort, same contract as `seedApplied`. | +| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; failures: object[] }` | optional | ADR-0086 P2 — aggregate result of publish-time materializers across the batch (e.g. `flow`, whose draft credentials become the live ones), including side-effect failures surfaced by the per-item effects loop. Present ONLY when at least one published item had a registered materializer or a side-effect failure. Best-effort, same contract as `seedApplied`. | | **probes** | `any` | optional | ADR-0038 L3 post-publish runtime probe report — one real read per published artifact (seeded objects have rows, views are readable, widget dataset selections execute). DELIBERATELY OPAQUE in this contract: the key is declared and carried through verbatim, but its inner shape is intentionally not modeled until a consumer needs a field of it. Present only when something was publishable; probes never fail the publish. | | **commitId** | `string` | optional | ADR-0067 — id of the commit this publish recorded. Absent when nothing published. | | **unhiddenApps** | `string[]` | optional | ADR-0045 §3 — names of the apps whose `_unpublished` gate this publish cleared (publish = live AND visible; a materialized additive build has no drafts, only this flip). Attached by the REST door, not the protocol helper. Present ONLY when at least one app flipped — on a mid-loop failure it names the apps that DID persist, beside `unhideError` ('s split report). Spelling is a permanent wire contract (see the door's note). | @@ -2639,7 +2639,7 @@ Installed package with runtime lifecycle state | **version** | `string` | ✅ | Version token of the row this write left, and the token the ADR-0008 optimistic-concurrency chain runs on: send it back as the `If-Match` request header on the next write to that item and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of silently overwritten. The item read serves the same token for the same row (`GetMetaItemResponseSchema.version`). A keyed digest of the stored content hash, never the hash itself (under the registered crypto provider's key, or a process-scoped key while none is registered). Opaque to callers — echo it verbatim, never parse it. Currently emitted as `hmac-sha256:` plus 64 hex characters, but the format is not part of this contract. | | **seq** | `integer` | ✅ | Monotonic sequence number of the metadata event this write appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. | | **state** | `Enum<'draft' \| 'active'>` | ✅ | Lifecycle the body was written into: "draft" when the request asked for draft mode (`?mode=draft`), otherwise "active" (published and live). A draft is staged only — it is not served to the runtime until published. | -| **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model (e.g. `permission` → `sys_permission_set`). Present ONLY when a projector is registered for this metadata type, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. | +| **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model (e.g. `datasource` → its live connection pool). Present ONLY when a projector is registered for this metadata type, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. | | **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to this body on its way to `active`. The write SUCCEEDED; these are what the gate has to say about it anyway (closing D3). Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean save's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the write and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. Runtime-only: the CLI surfaces the same findings on its own stdout, and a Studio / MCP / AI author has no CLI at all, which is the gap this key exists to close. The gate runs on both write doors (D1), and both report: `POST /meta/:type/:name/publish` carries the same key on `PublishMetaItemResponseSchema`. | | **message** | `string` | optional | | diff --git a/content/docs/references/identity/eval-user.mdx b/content/docs/references/identity/eval-user.mdx index 83751f4e494..976a1f7a147 100644 --- a/content/docs/references/identity/eval-user.mdx +++ b/content/docs/references/identity/eval-user.mdx @@ -22,7 +22,7 @@ behavior is the footgun this eliminates. ## Which axis `positions` is — and which it is not (#15136) It is the **security** axis: the built-in identity names plus the -`sys_position` names the principal actually holds through `sys_user_position` +position names the principal actually holds through `sys_user_position` (ADR-0057 D4) and their active membership, plus the `everyone` anchor (ADR-0090 D5) — exactly the set `/auth/me/permissions` reports and `resolveUserAuthzGrants` resolves. Every surface derives it from that one @@ -69,7 +69,7 @@ const result = EvalUserSchema.parse(data); | **id** | `string` | ✅ | User ID | | **name** | `string` | optional | Display name | | **email** | `string` | optional | Email address | -| **positions** | `string[]` | optional (default: `[]`) | Canonical position/identity names the user holds — built-in identity names plus sys_position assignments, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role | +| **positions** | `string[]` | optional (default: `[]`) | Canonical position/identity names the user holds — built-in identity names plus the positions assigned through sys_user_position, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role | | **isPlatformAdmin** | `boolean` | optional | The PLATFORM_ADMIN standing of ADR-0095 D3: true when the platform resolves the user, per request, to the PLATFORM_ADMIN posture rung — from the deployment's declared administrator list (OS_PLATFORM_OWNER_EMAIL) under every tenancy posture, or from an unscoped admin_full_access grant under the single posture. The predicate platform-operator gates read (current_user.isPlatformAdmin == true, ADR-0068 D4). The resolver projects 'platform_admin' into positions from the same grant, so the two agree for every genuine administrator; gate on this key, never on the positions array. | | **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) | diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 06ecd2ac2a5..a53d392b807 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2646,7 +2646,7 @@ }, "source": [ "packages/plugins/plugin-security/src/packaged-permission-set-lock.ts (classifier + both refusals; the SchemaRegistry-not-managed_by decision)", - "packages/plugins/plugin-security/src/permission-set-projection.ts (insert-path lock + duplicate 409; the #4669 row-state carve-out ordered BEFORE the update-path lock)", + "packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.ts (the lock's one enforcement point since the sys_permission_set data door retired with C3 stages 7a and 8, taking its insert-path lock, duplicate 409 and #4669 row-state carve-out with it)", "packages/runtime/src/domains/permission-set-clone.ts (the clone door that replaced the retired object's clone_permission_set action)", "docs/adr/0126-packaged-metadata-customization-model.md §1.1 (the #11513 machinery, named as the regime's first instance), §8 item 3 (permission sets converge later; the landed shape stays valid meanwhile)", "#11513 (lock the base, clone to customize — the 2026-08-24 ruling), #11703 (the three dropped clone facets), #12159 (ruling 2026-08-26: no ledger rows for permission sets pending the convergence card), #12438 (the sweep)" diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 4af023b5427..8ed7733a60e 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -356,9 +356,7 @@ export const MemberDefaultPermissionSet = definePermissionSet({ * may read announcements and file an inquiry (public form intake) — nothing * else. Guest bindings face the STRICTEST lint tier: named objects only (no * wildcard), read-only by default (create case-by-case), never `allowEdit`, - * never VAMA/system permissions. The runtime anchor gate - * (`assertAudienceAnchorBindingGate`) enforces the same rules on the binding - * write itself. + * never VAMA/system permissions. * * Binding is an ADMIN action (Setup → Access Control → bind `guest` → * `showcase_guest_portal`); the app only ships the capability. diff --git a/packages/core/src/security/position-row-definition.ts b/packages/core/src/security/position-row-definition.ts index 2f23f68e0a4..0964e74003d 100644 --- a/packages/core/src/security/position-row-definition.ts +++ b/packages/core/src/security/position-row-definition.ts @@ -12,10 +12,9 @@ * the dual source ADR-0131 D10 retires. * * The function is pure: one row in, one body out — no engine, no I/O. It is - * the mapping the security plugin's row-only position backfill applies today - * (`positionBodyFromRow`, beside its position write-through), held here so the - * ceremony and that plugin read one mapping; the plugin's copy is deleted when - * its boot module is. + * the mapping the security plugin's row-only position backfill applied until + * C3 stage 8 retired that boot module, held here as the one mapping the + * ceremony reads. * * The definition is the four `PositionSchema` keys the row has columns for, in * this key order: diff --git a/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts b/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts index 10b2f347e0a..8bdd486b627 100644 --- a/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts +++ b/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts @@ -65,7 +65,7 @@ * `assertAllowed` allow-list are both DERIVED from the registry (Prime * Directive #8) — no hardcoded copy to align. * • plugin-security's ADR-0094 write-through - * (`permission-set-projection.ts`) redirects data-door edits of + * (`permission-set-projection.ts`, since retired) redirected data-door edits of * permission sets into `saveMetaItem({type:'permission'})`. For * RUNTIME-CREATED sets that write rides `allowRuntimeCreate` and keeps * working. For PACKAGE-OWNED sets it now refuses with 403 — which is @@ -301,11 +301,10 @@ describe('#6483 — the nine ADR-0005 divergences: allowEnvironmentOverlay rolle // two production paths named above rather than assumed: // // • ADR-0094's permission write-through - // (`plugin-security/src/permission-set-projection.ts`) passes NO - // `organizationId` on any of its four `saveMetaItem` call sites — - // it is env-scoped by design ("its update/delete translate into - // env-scope overlay operations"). Unaffected, and this case is its - // accurate pin. + // (`plugin-security/src/permission-set-projection.ts`, retired with + // C3 stage 7a) passed NO `organizationId` on any of its four + // `saveMetaItem` call sites — it was env-scoped by design. + // Unaffected, and this case was its accurate pin. // • ADR-0045's visibility flip (`runtime/src/domains/packages.ts`) // DOES thread the session's active org into // `saveMetaItem({ type: 'app' })`. That path is the open question diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index c31c78cafda..961f14c05c0 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -710,7 +710,7 @@ export const enObjects: NonNullable = { }, positions: { label: "Placement Positions", - help: "sys_position names assigned on acceptance (ADR-0105 D8). Every position's permission sets must be allowlisted by the issuer's adminScope." + help: "Position names assigned on acceptance (ADR-0105 D8). Every position's permission sets must be allowlisted by the issuer's adminScope." } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index e23bb888a9b..496c0cd45a2 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -710,7 +710,7 @@ export const esESObjects: NonNullable = { }, positions: { label: "Puestos de asignación", - help: "Nombres de sys_position asignados al aceptar (ADR-0105 D8). Los conjuntos de permisos de cada puesto deben estar en la lista de permitidos del adminScope de quien emite la invitación." + help: "Nombres de puestos asignados al aceptar (ADR-0105 D8). Los conjuntos de permisos de cada puesto deben estar en la lista de permitidos del adminScope de quien emite la invitación." } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index b08af1a8416..406d6be27b2 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -710,7 +710,7 @@ export const jaJPObjects: NonNullable = { }, positions: { label: "配属先ポジション", - help: "承認時に割り当てられる sys_position 名(ADR-0105 D8)。各ポジションの権限セットは、発行者の adminScope で許可されている必要があります。" + help: "承認時に割り当てられるポジション名(ADR-0105 D8)。各ポジションの権限セットは、発行者の adminScope で許可されている必要があります。" } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index dafbd498dbd..10c6e3ee3c3 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -710,7 +710,7 @@ export const zhCNObjects: NonNullable = { }, positions: { label: "分配岗位", - help: "接受邀请时分配的 sys_position 名称(ADR-0105 D8)。每个岗位的权限集都必须在邀请发起人的 adminScope 允许列表内。" + help: "接受邀请时分配的岗位名称(ADR-0105 D8)。每个岗位的权限集都必须在邀请发起人的 adminScope 允许列表内。" } }, _views: { diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index a3af6685409..5f9d2ecf5a7 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -302,7 +302,7 @@ export const SysInvitation = ObjectSchema.create({ label: 'Placement Positions', required: false, description: - 'sys_position names assigned on acceptance (ADR-0105 D8). Every position\'s permission sets must be allowlisted by the issuer\'s adminScope.', + 'Position names assigned on acceptance (ADR-0105 D8). Every position\'s permission sets must be allowlisted by the issuer\'s adminScope.', }), }, diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index 454884d61ad..9e03a3b8d47 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -403,27 +403,15 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated * that show the refusals are not blanket: a write through the data door by the * organization administrator (superuser wildcard, not a system context). */ -async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, judgeCreate: boolean): Promise> { +async function tenantPositionWrites(engine: ObjectQL): Promise> { const tenant = { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG }; - const own = async (name: string) => { - const rows = (await engine.find('sys_position', { where: { name }, limit: 5, context: { isSystem: true, tenantId: ORG } })) as any[]; - // The organization's own row when it has one (walled), else the organization-less one (`single`). - return rows.find((r) => r.organization_id === ORG) ?? rows.find((r) => r.organization_id == null); - }; - const everyone = await own('everyone'); - const platformAdmin = await own('platform_admin'); - const dangling = await outcome(engine.insert( - 'sys_user_position', { user_id: 'usr_member', position: 'zz_dangling' }, { context: tenant } as any, - )); - const created = await outcome(engine.insert( - 'sys_position', { name: 'zz_tenant_post', label: 'Tenant Post' }, { context: tenant } as any, - )); return { - 'assign a position the security catalog does not hold': dangling, - // Judged wherever the plugin declares the `sys_position` row object (under a - // wall, the walled data-door refusal's: `walled-catalog-data-door.ts`); left - // out once C3 stage 8 retires the object and that door together. - ...(judgeCreate ? { 'control: create a position row': created } : {}), + 'assign a position the security catalog does not hold': await outcome(engine.insert( + 'sys_user_position', { user_id: 'usr_member', position: 'zz_dangling' }, { context: tenant } as any, + )), + 'control: create a position row': await outcome(engine.insert( + 'sys_position', { name: 'zz_tenant_post', label: 'Tenant Post' }, { context: tenant } as any, + )), // [ADR-0131 D3/D4] This harness mounts no metadata door, so the row gets // no definition: a position only a row carries grants nothing, and an // assignment naming it is refused like a dangling name. @@ -433,15 +421,6 @@ async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, jud 'control: assign a stack-declared position (no row needed)': await outcome(engine.insert( 'sys_user_position', { user_id: 'usr_member', position: DECLARED_POSITIONS[0].name }, { context: tenant } as any, )), - // Only an existing database holds a built-in row to refuse (stage 6a). - ...(existingRows - ? { - 'delete the everyone row': await outcome(engine.delete('sys_position', { where: { id: everyone?.id }, context: tenant } as any)), - 'relabel the platform_admin row': await outcome(engine.update( - 'sys_position', { id: platformAdmin?.id, label: 'Repurposed' }, { context: tenant } as any, - )), - } - : {}), }; } @@ -530,12 +509,9 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { expect(await grantsByPrincipal(engine, scenario.posture)).toEqual(grantGoldenFor(scenario)); }); - it('still refuses a tenant a dangling position name, and deleting or relabelling a built-in row', async () => { - // A fresh database holds no built-in row to delete or relabel; the two - // refusals are read where the rows are, on an existing database. + it('still refuses a tenant a dangling position name', async () => { const { engine } = booted.get(name)!; - expect(await tenantPositionWrites(engine, scenario.existingRows, judgesCreate(scenario))) - .toEqual(refusalGoldenFor(scenario)); + expect(await tenantPositionWrites(engine)).toEqual(REFUSAL_GOLDEN); }); } }); @@ -545,10 +521,10 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { * The tenant position writes. A name the security * catalog does not hold, a dangling one or a row with no definition alike, is * the position catalog refusal's envelope (`reference_not_found` at - * `position`); a stack-declared position is assigned with no row; the two - * built-in rows an existing database holds are refused on their `platform` - * provenance. Under a wall the tenant's position create is refused as well, - * while the plugin declares the row object ({@link refusalGoldenFor}). + * `position`); a stack-declared position is assigned with no row. The plugin + * no longer declares the `sys_position` row object (C3 stage 8), so the row + * create runs against the legacy test kit's table in every posture, and no + * catalog gate judges it: the catalog is written through the metadata door. */ const REFUSAL_GOLDEN: Record = { 'assign a position the security catalog does not hold': { @@ -567,49 +543,8 @@ const REFUSAL_GOLDEN: Record = { 'control: assign a stack-declared position (no row needed)': { ok: true, }, - 'delete the everyone row': { - ok: false, - code: 'PERMISSION_DENIED', - status: 403, - }, - 'relabel the platform_admin row': { - ok: false, - code: 'PERMISSION_DENIED', - status: 403, - }, }; -/** Whether the plugin still declares the `sys_position` row object; C3 stage 8 retires it (ADR-0131 D3). */ -const PLUGIN_DECLARES_POSITION_ROWS = securityObjects.some((o) => o.name === 'sys_position'); - -/** Whether a scenario judges the tenant's position create ({@link refusalGoldenFor}). */ -function judgesCreate(scenario: Scenario): boolean { - return scenario.posture === 'single' || PLUGIN_DECLARES_POSITION_ROWS; -} - -/** - * {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing - * database's rows. [ADR-0131 D3] Under a wall the tenant's position CREATE is - * refused by the walled data-door refusal (`walled-catalog-data-door.ts`, also - * pinned by `walled-catalog-data-door.test.ts` and by the data-door case of - * `wall-catalog-refusal.dogfood.test.ts`): the catalog is the platform - * operator's, and a tenant may only assign it. That holds while the plugin - * declares the `sys_position` row object; C3 stage 8 retires the object and that - * door together, and the create is then left out. The assignment cases do not - * move, and the built-in row refusals keep their `403 PERMISSION_DENIED`. - */ -function refusalGoldenFor(scenario: Scenario): Record { - const { 'control: create a position row': _create, ...withoutCreate } = REFUSAL_GOLDEN; - const golden: Record = scenario.posture === 'single' - ? REFUSAL_GOLDEN - : PLUGIN_DECLARES_POSITION_ROWS - ? { ...REFUSAL_GOLDEN, 'control: create a position row': { ok: false, code: 'PERMISSION_DENIED', status: 403 } } - : withoutCreate; - if (scenario.existingRows) return golden; - const { 'delete the everyone row': _d, 'relabel the platform_admin row': _r, ...fresh } = golden; - return fresh; -} - /** * The rows the retired seeders wrote — the six built-ins (`platform`) and the * stack's declared `field_rep` — as an existing database holds them: one diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts index ea1fd91b215..66186011ef7 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts @@ -10,18 +10,14 @@ * unique per organization only, so in a single-database multi-org posture * (ADR-0105 D1 `group` / `isolated`) two organizations' holdings of the same * name are the same string. Two gate reads selected holdings by that name under - * a bare `{ isSystem: true }` context with no organization condition: + * a bare `{ isSystem: true }` context with no organization condition. The one + * that remains is `activeHoldings` — does the delegator CURRENTLY and + * DIRECTLY hold the position (ADR-0091 D3 rule 4)? A holding stamped for + * ANOTHER organization answered yes, so a user self-delegated a position in an + * organization where they held nothing. (The other, the binding blast radius, + * retired with the `sys_position_permission_set` binding rows, ADR-0131 D3.) * - * - `activeHoldings` — does the delegator CURRENTLY and DIRECTLY hold the - * position (ADR-0091 D3 rule 4)? A holding stamped for ANOTHER - * organization answered yes, so a user self-delegated a position in an - * organization where they held nothing. - * - `assignmentAnchorsOfPosition` — whose capability does re-binding the - * position re-compose (the ADR-0090 D12 blast radius)? Another - * organization's same-named assignments entered it, so a delegate was - * refused over holders their binding never reaches. - * - * ## The rule both reads now follow — the runtime's own + * ## The rule the read now follows — the runtime's own * * `resolveAuthzContext` (step 4, `@objectstack/core`) keeps a holding when its * `organization_id` is empty OR equals the active organization, and drops it @@ -34,7 +30,6 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -import type { PermissionSet } from '@objectstack/spec/security'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; @@ -57,7 +52,6 @@ const OBJECTS = [ object('sys_business_unit', ['name', 'parent_business_unit_id']), object('sys_position', ['name', 'delegatable']), object('sys_permission_set', ['name', 'admin_scope']), - object('sys_position_permission_set', ['position_id', 'permission_set_id']), object('sys_user_position', [ 'user_id', 'position', 'business_unit_id', 'granted_by', 'delegated_from', 'valid_until', 'reason', ]), @@ -65,24 +59,6 @@ const OBJECTS = [ object('sys_user', ['primary_business_unit_id']), ]; -/** A binding delegate's scope: the unit NAMED `sales`, may bind `sales_user` only. */ -const BIND_SCOPE = { - businessUnit: 'sales', - includeSubtree: true, - manageAssignments: false, - manageBindings: true, - authorEnvironmentSets: false, - assignablePermissionSets: ['sales_user'], -}; - -const BINDER_SETS = [ - { - name: 'sales_binder', - objects: { sys_position_permission_set: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, - adminScope: BIND_SCOPE, - }, -] as unknown as PermissionSet[]; - const engines: ObjectQL[] = []; afterEach(async () => { while (engines.length) { @@ -164,7 +140,7 @@ function gateOn(engine: ObjectQL) { }); return new DelegatedAdminGate({ ql: engine, - resolveSets: async (context: any) => (context?.principal === 'binder' ? BINDER_SETS : []), + resolveSets: async () => [], }); } @@ -190,15 +166,6 @@ const selfDelegate = (gate: DelegatedAdminGate, organizationId: string | undefin : { principal: 'holder', userId: USER }, }); -/** A binding delegate in `organizationId` binds `sales_user` to the position row `positionId`. */ -const bind = (gate: DelegatedAdminGate, organizationId: string, positionId: string, permissionSetId: string) => - gate.assert({ - object: 'sys_position_permission_set', - operation: 'insert', - data: { position_id: positionId, permission_set_id: permissionSetId }, - context: { principal: 'binder', userId: 'usr_binder', tenantId: organizationId }, - }); - /** A gate refusal: the ADR-0112 envelope, then the first sentence that names why. */ async function expectRefused(p: Promise, why: RegExp) { const err: any = await p.then(() => null, (e) => e); @@ -285,71 +252,3 @@ describe('DelegatedAdminGate — rule 4 counts only holdings the caller\'s organ await expect(selfDelegate(gateOn(engine), undefined)).resolves.toBeUndefined(); }); }); - -// ── assignmentAnchorsOfPosition — the binding blast radius ──────────────── - -describe('DelegatedAdminGate — a binding\'s blast radius is the holders the bound position reaches', () => { - it('org B\'s same-named assignments do not refuse org A\'s binding (outside-subtree anchor)', async () => { - const engine = await bootEngine(); - const b = await seedOrganization(engine, ORG_B, '0'); - const a = await seedOrganization(engine, ORG_A, 'a'); - await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]); - await hold(engine, ORG_B, [{ id: 'up_b', user_id: 'usr_b1', business_unit_id: b.salesEast }]); - - await expect(bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser)).resolves.toBeUndefined(); - }); - - it('org B\'s same-named assignments do not push org A\'s binding over the blast-radius cap', async () => { - const engine = await bootEngine(); - const b = await seedOrganization(engine, ORG_B, '0'); - const a = await seedOrganization(engine, ORG_A, 'a'); - await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]); - await hold(engine, ORG_B, Array.from({ length: 501 }, (_, i) => ({ - id: `up_b_${String(i).padStart(3, '0')}`, - user_id: `usr_b_${i}`, - business_unit_id: b.salesEast, - }))); - - await expect(bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser)).resolves.toBeUndefined(); - }); - - it('an organization-less holding IS in org A\'s blast radius — the runtime grants it org A\'s binding', async () => { - const engine = await bootEngine(); - await seedOrganization(engine, ORG_B, '0'); - const a = await seedOrganization(engine, ORG_A, 'a'); - await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]); - await hold(engine, undefined, [{ id: 'up_none', user_id: 'usr_x' }]); - - await expectRefused( - bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser), - /position 'field_lead' has 1 unanchored assignment\(s\)/, - ); - }); - - it('the radius follows the BOUND row\'s organization — org B\'s holders still guard org B\'s row', async () => { - const engine = await bootEngine(); - const b = await seedOrganization(engine, ORG_B, '0'); - const a = await seedOrganization(engine, ORG_A, 'a'); - await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]); - await hold(engine, ORG_B, [{ id: 'up_b', user_id: 'usr_b1', business_unit_id: b.salesEast }]); - - await expectRefused( - bind(gateOn(engine), ORG_A, b.fieldLead, a.salesUser), - /position 'field_lead' is held in business unit 'bu_0_sales_east', outside the delegated subtree/, - ); - }); - - it('control (dark) — org A\'s own holder outside the subtree still refuses', async () => { - const engine = await bootEngine(); - const a = await seedOrganization(engine, ORG_A, 'a'); - await engine.insert('sys_business_unit', [ - { id: 'bu_a_ops', name: 'ops', parent_business_unit_id: null }, - ], { context: sysCtx(ORG_A) } as any); - await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: 'bu_a_ops' }]); - - await expectRefused( - bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser), - /position 'field_lead' is held in business unit 'bu_a_ops', outside the delegated subtree/, - ); - }); -}); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index 3cec75ea246..52985acddcc 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -283,172 +283,6 @@ describe('DelegatedAdminGate — assignments (sys_user_position)', () => { }); }); -describe('DelegatedAdminGate — bindings (sys_position_permission_set)', () => { - it('delegate binds an allowlisted set to a position held only inside the subtree', async () => { - await expect(h.gate.assert({ - object: 'sys_position_permission_set', operation: 'insert', - data: { position_id: 'pos_sales', permission_set_id: 'ps_sales' }, - context: h.ctxOf('delegate'), - })).resolves.toBeUndefined(); - }); - - it('denies binding a non-allowlisted set', async () => { - await expect(h.gate.assert({ - object: 'sys_position_permission_set', operation: 'insert', - data: { position_id: 'pos_sales', permission_set_id: 'ps_fin' }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/not in the scope's allowlist/); - }); - - it('denies re-composing a position held outside the subtree (blast radius)', async () => { - h.tables.sys_user_position.push({ id: 'a_w', user_id: 'u_west_1', position: 'sales_rep', business_unit_id: 'bu_west' }); - await expect(h.gate.assert({ - object: 'sys_position_permission_set', operation: 'insert', - data: { position_id: 'pos_sales', permission_set_id: 'ps_sales' }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/outside the delegated subtree/); - }); - - it('audience-anchor bindings are tenant-level only for delegates', async () => { - await expect(h.gate.assert({ - object: 'sys_position_permission_set', operation: 'insert', - data: { position_id: 'pos_everyone', permission_set_id: 'ps_sales' }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/tenant-level only/); - }); -}); - -describe('DelegatedAdminGate — direct grants (sys_user_permission_set)', () => { - it('delegate grants an allowlisted set to a user inside the subtree; granted_by stamped', async () => { - const row: any = { user_id: 'u_east_1', permission_set: 'sales_user' }; - await expect(h.gate.assert({ - object: 'sys_user_permission_set', operation: 'insert', data: row, context: h.ctxOf('delegate'), - })).resolves.toBeUndefined(); - expect(row.granted_by).toBe('u_delegate'); - }); - - it('denies grants to users outside the subtree', async () => { - await expect(h.gate.assert({ - object: 'sys_user_permission_set', operation: 'insert', - data: { user_id: 'u_west_1', permission_set: 'sales_user' }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/outside the delegated subtree/); - }); - - it('denies non-allowlisted sets — including to the delegate themselves', async () => { - await expect(h.gate.assert({ - object: 'sys_user_permission_set', operation: 'insert', - data: { user_id: 'u_delegate', permission_set: 'finance_admin' }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/not in the scope's allowlist/); - }); - - it('granting a set that carries an adminScope requires STRICT containment (equal scope refused)', async () => { - // sub_admin carries the delegate's own EXACT scope — lateral propagation banned. - await expect(h.gate.assert({ - object: 'sys_user_permission_set', operation: 'insert', - data: { user_id: 'u_east_1', permission_set: 'sub_admin' }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/not strictly contained/); - }); - - // [ADR-0131 D4] A stored grant's pre-image is judged by the set it NAMES - // (`permission_set`), read from the catalog by that name; an insert, or an - // update that re-points the id, is judged by the id it writes, as before. - describe('[ADR-0131 D4] the pre-image is judged by the name the grant holds', () => { - const storedGrant = (row: Record) => { - for (const set of h.tables.sys_permission_set) set.organization_id ??= null; - h.tables.sys_user_permission_set = [{ id: 'g1', user_id: 'u_east_1', ...row }]; - }; - const remove = () => h.gate.assert({ - object: 'sys_user_permission_set', operation: 'delete', - options: { where: { id: 'g1' } }, context: h.ctxOf('delegate'), - }); - - it('a grant naming an allowlisted set may be revoked by the delegate — whatever its id says', async () => { - storedGrant({ permission_set_id: 'ps_fin', permission_set: 'sales_user' }); - await expect(remove()).resolves.toBeUndefined(); - }); - - it('a grant naming a set outside the allowlist is refused — whatever its id says', async () => { - storedGrant({ permission_set_id: 'ps_sales', permission_set: 'finance_admin' }); - await expect(remove()).rejects.toThrow(/'finance_admin' is not in the scope's allowlist/); - }); - - it('a grant that names NO set is refused: the delegate cannot be shown to hold authority over it', async () => { - storedGrant({ permission_set_id: 'ps_sales', permission_set: null }); - await expect(remove()).rejects.toThrow(/names no permission set/); - }); - - it('a grant naming a set the security catalog does not hold is refused', async () => { - storedGrant({ permission_set_id: 'ps_sales', permission_set: 'sales_user_retired' }); - await expect(remove()).rejects.toThrow(/is not in the security catalog/); - }); - - it('an update keeping the set is judged by the stored name; one re-pointing the id by the new id', async () => { - storedGrant({ permission_set_id: 'ps_sales', permission_set: 'sales_user' }); - await expect(h.gate.assert({ - object: 'sys_user_permission_set', operation: 'update', - data: { id: 'g1', reason: 'renewed' }, context: h.ctxOf('delegate'), - })).resolves.toBeUndefined(); - await expect(h.gate.assert({ - object: 'sys_user_permission_set', operation: 'update', - data: { id: 'g1', permission_set: 'finance_admin' }, context: h.ctxOf('delegate'), - })).rejects.toThrow(/'finance_admin' is not in the scope's allowlist/); - }); - }); -}); - -describe('DelegatedAdminGate — env-set authoring (sys_permission_set)', () => { - it('delegate with authorEnvironmentSets may insert an inert env set', async () => { - await expect(h.gate.assert({ - object: 'sys_permission_set', operation: 'insert', - data: { name: 'east_helper', object_permissions: '{}' }, - context: h.ctxOf('delegate'), - })).resolves.toBeUndefined(); - }); - - it('authoring a set that mints a NARROWER adminScope is allowed (strict containment)', async () => { - await expect(h.gate.assert({ - object: 'sys_permission_set', operation: 'insert', - data: { - name: 'east_sales_admin', - admin_scope: JSON.stringify({ - businessUnit: 'east_sales', - manageAssignments: true, - assignablePermissionSets: ['sales_user'], - }), - }, - context: h.ctxOf('delegate'), - })).resolves.toBeUndefined(); - }); - - it('authoring a scope equal to or broader than your own is denied', async () => { - await expect(h.gate.assert({ - object: 'sys_permission_set', operation: 'insert', - data: { name: 'clone_of_mine', admin_scope: JSON.stringify(EAST_SCOPE) }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/strictly contains it/); - - await expect(h.gate.assert({ - object: 'sys_permission_set', operation: 'insert', - data: { - name: 'hq_takeover', - admin_scope: JSON.stringify({ ...EAST_SCOPE, businessUnit: 'hq' }), - }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/strictly contains it/); - }); - - it('delegates cannot set the tenant-wide isDefault suggestion', async () => { - await expect(h.gate.assert({ - object: 'sys_permission_set', operation: 'insert', - data: { name: 'east_default', isDefault: true }, - context: h.ctxOf('delegate'), - })).rejects.toThrow(/tenant-level/); - }); -}); - // ── [ADR-0091 D3] Self-service delegation of duty ────────────────────────── const T0 = Date.parse('2026-07-01T00:00:00Z'); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 45b91996163..bacab6fdcc3 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -14,16 +14,19 @@ * its explicit per-table `allowEdit:false` overrides — unchanged). * - DELEGATES (non-tenant-admins holding ≥1 `adminScope`) may write ONLY * what one of their scopes fully approves: the right kind of action - * (manageAssignments / manageBindings / authorEnvironmentSets), inside - * their BU subtree, handing out only allowlisted sets, with STRICT - * containment whenever the touched set itself carries an adminScope. + * (manageAssignments), inside their BU subtree, handing out only + * allowlisted sets, with STRICT containment whenever the granted set itself + * carries an adminScope. The rows it governs are the two assignment tables + * (`sys_user_position`, `sys_user_permission_set`); the binding and set + * authoring rows retired with the catalog row objects (ADR-0131 D3), so a + * position's sets and a set's definition are written through the metadata + * door alone. * - EVERYONE ELSE is denied — holding plain CRUD on `sys_user_position` * no longer makes you a permission administrator (fail closed, D12). * - * The `everyone` / `guest` audience anchors stay tenant-level only: no - * delegated scope can touch their bindings, and DIRECT position assignments - * to an anchor are rejected for every caller (anchors are implicit — a - * stored assignment row is a modeling error). + * DIRECT position assignments to the `everyone` / `guest` audience anchors + * are rejected for every caller (anchors are implicit — a stored assignment + * row is a modeling error). * * `sys_member` (organization membership) is likewise tenant-level only: no * `adminScope` axis expresses membership, and an admin-grade membership row is @@ -63,8 +66,6 @@ const SYSTEM_CTX = { isSystem: true } as const; const ANCHOR_CANDIDATE_LIMIT = 50; /** Max BU-tree depth walked when expanding a scope subtree (safety bound). */ const MAX_TREE_DEPTH = 32; -/** Max existing assignments examined for a binding blast-radius check. */ -const BLAST_RADIUS_CAP = 500; /** [ADR-0091 D3] Default self-delegation ceiling: 30 days. A "temporary" * grant that can be rolled forever is a permanent grant with extra steps. */ const DEFAULT_DELEGATION_CEILING_MS = 30 * 24 * 60 * 60 * 1000; @@ -130,9 +131,7 @@ function toEpochMs(value: unknown): number | undefined { const GOVERNED_OBJECTS = new Set([ 'sys_user_position', - 'sys_position_permission_set', 'sys_user_permission_set', - 'sys_permission_set', // [#3697 follow-up] Organization MEMBERSHIP is an authority surface too: a // row whose `role` is admin-grade is auto-elevated to `organization_admin` // by `auto-org-admin-grant.ts`, whose wildcard `modifyAllRecords` IS @@ -399,10 +398,6 @@ export class DelegatedAdminGate { return this.assertAssignmentWrite(opCtx, held); case 'sys_user_permission_set': return this.assertDirectGrantWrite(opCtx, held); - case 'sys_position_permission_set': - return this.assertBindingWrite(opCtx, held); - case 'sys_permission_set': - return this.assertSetAuthoring(opCtx, held, organizationId); } } @@ -840,111 +835,6 @@ export class DelegatedAdminGate { } } - // ── sys_position_permission_set: position ↔ set bindings ──────────── - - private async assertBindingWrite(opCtx: any, held: HeldScope[]): Promise { - const targets = await this.materializeTargets(opCtx, 'sys_position_permission_set'); - for (const t of targets) { - const row = t.next ?? t.prev ?? {}; - const position = await this.positionById(row.position_id); - const positionName = position.name; - if (ANCHOR_POSITIONS.has(positionName)) { - throw new PermissionDeniedError( - `[Security] Access denied: bindings of the '${positionName}' audience anchor are ` + - `tenant-level only — no delegated scope can touch them (ADR-0090 D12).`, - { operation: opCtx.operation, object: opCtx.object, position: positionName }, - ); - } - const setRow = await this.loadSetRowById(row.permission_set_id); - const setName = String(setRow?.name ?? row.permission_set_id ?? ''); - const radius = positionName - ? await this.assignmentAnchorsOfPosition(positionName, position.organizationId) - : { anchors: new Set(), overCap: false, unanchored: 0 }; - - const failure = this.firstApprovalFailure(held, (s) => { - if (!s.scope.manageBindings) return 'the scope does not grant manageBindings'; - if (!s.scope.assignablePermissionSets.includes(setName)) { - return `permission set '${setName}' is not in the scope's allowlist`; - } - // Blast radius: re-composing a position re-composes EVERY holder's - // capability. A delegate may only do that when all current holders - // sit inside their subtree (fail closed on unanchored/over-cap). - if (radius.overCap) return `position '${positionName}' has more than ${BLAST_RADIUS_CAP} assignments — only a tenant admin may re-compose it`; - if (radius.unanchored > 0) return `position '${positionName}' has ${radius.unanchored} unanchored assignment(s) (no business_unit_id) — only a tenant admin may re-compose it`; - for (const bu of radius.anchors) { - if (!s.subtree.has(bu)) return `position '${positionName}' is held in business unit '${bu}', outside the delegated subtree`; - } - if (setRow) { - const contained = this.assertScopeGrantContainment(setRow, held, true); - if (contained) return contained; - } - return null; - }); - if (failure) { - throw new PermissionDeniedError( - `[Security] Access denied: delegated '${opCtx.operation}' on sys_position_permission_set rejected — ${failure}.`, - { operation: opCtx.operation, object: opCtx.object, position: positionName, permissionSet: setName }, - ); - } - } - } - - // ── sys_permission_set: environment-set authoring ──────────────────── - - private async assertSetAuthoring(opCtx: any, held: HeldScope[], organizationId?: string): Promise { - // Package-managed rows were already rejected by the two-doors gate; what - // reaches here is environment-owned authoring. - const targets = await this.materializeTargets(opCtx, 'sys_permission_set'); - for (const t of targets) { - const payload = t.next ?? {}; - const existing = t.prev ?? null; - const setName = String(payload.name ?? existing?.name ?? ''); - const authoredScope: AdminScope | undefined = - parseMaybeJson(payload.admin_scope ?? payload.adminScope) ?? undefined; - - const failure = this.firstApprovalFailure(held, (s) => { - if (!s.scope.authorEnvironmentSets) return 'the scope does not grant authorEnvironmentSets'; - // Mutating/deleting an existing env set is only in-domain when the - // delegate distributes it (allowlist); fresh inserts are inert until - // distributed, so they pass this check. - if (existing && !s.scope.assignablePermissionSets.includes(setName)) { - return `environment set '${setName}' is outside the scope's allowlist — only its tenant-level owner may change it`; - } - return null; - }); - if (failure) { - throw new PermissionDeniedError( - `[Security] Access denied: delegated '${opCtx.operation}' on sys_permission_set rejected — ${failure}.`, - { operation: opCtx.operation, object: opCtx.object, permissionSet: setName }, - ); - } - - // A delegate may suggest nothing tenant-wide: isDefault is the D5 - // install-suggestion to bind to `everyone` — tenant-level only. - if (t.next && (t.next.isDefault === true || t.next.is_default === true)) { - throw new PermissionDeniedError( - `[Security] Access denied: 'isDefault' (the everyone-binding suggestion) is tenant-level ` + - `only — a delegated administrator cannot set it (ADR-0090 D12).`, - { operation: opCtx.operation, object: opCtx.object, permissionSet: setName }, - ); - } - - // Authoring a set that CARRIES an adminScope = minting administration: - // requires a held scope that STRICTLY contains the minted one. - if (authoredScope) { - const containment = await this.checkStrictContainment(authoredScope, held, organizationId); - if (containment) { - throw new PermissionDeniedError( - `[Security] Access denied: the authored adminScope is not strictly contained by your ` + - `own — ${containment} (ADR-0090 D12: granting an admin scope requires holding a scope ` + - `that strictly contains it).`, - { operation: opCtx.operation, object: opCtx.object, permissionSet: setName }, - ); - } - } - } - } - // ── Shared helpers ──────────────────────────────────────────────────── /** First scope that fully approves wins; otherwise the FIRST failure reason @@ -1018,43 +908,6 @@ export class DelegatedAdminGate { return !(equalRights && equalTree && equalAllow); } - /** Authoring-path strict containment with resolved subtrees. Returns a - * failure description, or null when some held scope strictly contains. */ - private async checkStrictContainment( - minted: AdminScope, - held: HeldScope[], - organizationId?: string, - ): Promise { - const mintedSubtree = await this.resolveSubtree( - minted.businessUnit, - minted.includeSubtree !== false, - organizationId, - ); - if (mintedSubtree.size === 0) return `its business unit '${minted.businessUnit}' does not resolve`; - for (const s of held) { - let treeContained = true; - for (const bu of mintedSubtree) if (!s.subtree.has(bu)) { treeContained = false; break; } - if (!treeContained) continue; - if ((minted.manageAssignments === true) && !s.scope.manageAssignments) continue; - if ((minted.manageBindings === true) && !s.scope.manageBindings) continue; - if ((minted.authorEnvironmentSets === true) && !s.scope.authorEnvironmentSets) continue; - const mintedAllow = minted.assignablePermissionSets ?? []; - if (!mintedAllow.every((n) => s.scope.assignablePermissionSets.includes(n))) continue; - // Strictness on the resolved tuple. - const equalTree = mintedSubtree.size === s.subtree.size; - const equalRights = - (minted.manageAssignments === true) === (s.scope.manageAssignments === true) && - (minted.manageBindings === true) === (s.scope.manageBindings === true) && - (minted.authorEnvironmentSets === true) === (s.scope.authorEnvironmentSets === true); - const equalAllow = - mintedAllow.length === s.scope.assignablePermissionSets.length && - mintedAllow.every((n) => s.scope.assignablePermissionSets.includes(n)); - if (equalTree && equalRights && equalAllow) continue; // identical — not STRICT - return null; - } - return 'no held scope covers its subtree, rights and allowlist with room to spare'; - } - /** Resolve every adminScope carried by the actor's resolved sets, inside the * caller's own organization ({@link callerOrganizationId}). */ private async resolveHeldScopes( @@ -1311,81 +1164,6 @@ export class DelegatedAdminGate { } } - private async loadSetRowById(id: unknown): Promise { - if (id == null || !this.deps.ql?.find) return null; - try { - const rows = await this.deps.ql.find('sys_permission_set', { where: { id }, limit: 1, context: SYSTEM_CTX }); - return Array.isArray(rows) && rows[0] ? rows[0] : null; - } catch { - return null; - } - } - - /** A `sys_position` row by id: its name, and the organization that owns it - * (undefined = an organization-less row). */ - private async positionById(id: unknown): Promise<{ name: string; organizationId: string | undefined }> { - const none = { name: '', organizationId: undefined }; - if (id == null || !this.deps.ql?.find) return none; - try { - const rows = await this.deps.ql.find('sys_position', { where: { id }, limit: 1, context: SYSTEM_CTX }); - const row = Array.isArray(rows) && rows[0] ? (rows[0] as any) : null; - if (!row) return none; - return { name: String(row.name ?? ''), organizationId: rowOrganizationId(row) || undefined }; - } catch { - return none; - } - } - - /** - * BU anchors of every assignment a re-binding of this position row reaches - * (blast radius). - * - * An assignment names the position by NAME, and a binding hangs off one - * `sys_position` ROW, which the runtime reaches only from the organization - * that owns it. So the holders a binding re-composes are the ones whose - * holding takes effect in the bound row's organization - * ({@link holdingTakesEffectIn}) — that organization's own holdings plus the - * organization-less ones — never another organization's same-named - * assignments, which would otherwise refuse the write as outside the - * subtree or push it over {@link BLAST_RADIUS_CAP}. Keyed on the bound - * row's organization rather than the caller's, so a binding written against - * another organization's row is still judged by THAT organization's holders. - * An organization-less row reaches every organization: no holding is dropped. - * - * The organization rides the read's context, so the driver's tenant scope - * (`organization_id = :org OR organization_id IS NULL`, the governed spelling - * of exactly this rule) applies BEFORE the cap — another organization's rows - * can neither fill the page nor trip {@link BLAST_RADIUS_CAP}. The same rule - * is re-applied to what comes back, for a driver that does not scope. - */ - private async assignmentAnchorsOfPosition( - positionName: string, - positionOrganizationId?: string, - ): Promise<{ anchors: Set; overCap: boolean; unanchored: number }> { - const anchors = new Set(); - let unanchored = 0; - const ql = this.deps.ql; - if (!ql?.find) return { anchors, overCap: false, unanchored }; - let rows: any[] = []; - try { - rows = await ql.find('sys_user_position', { - where: { position: positionName }, - limit: BLAST_RADIUS_CAP + 1, - context: organizationScopedCtx(positionOrganizationId), - }); - } catch { - rows = []; - } - const list = Array.isArray(rows) ? rows : []; - if (list.length > BLAST_RADIUS_CAP) return { anchors, overCap: true, unanchored }; - for (const r of list.filter((row) => holdingTakesEffectIn(row, positionOrganizationId))) { - const bu = (r as any)?.business_unit_id; - if (bu == null || bu === '') unanchored += 1; - else anchors.add(String(bu)); - } - return { anchors, overCap: false, unanchored }; - } - /** Target user's BU memberships (sys_business_unit_member ∪ primary projection). */ private async businessUnitsOfUser(userId: string): Promise> { const out = new Set(); diff --git a/packages/plugins/plugin-security/src/errors.test.ts b/packages/plugins/plugin-security/src/errors.test.ts index 2f5acfb507b..e5cdf185cf1 100644 --- a/packages/plugins/plugin-security/src/errors.test.ts +++ b/packages/plugins/plugin-security/src/errors.test.ts @@ -41,7 +41,6 @@ const FLOOR = [ 'MasterReferenceMissingError', 'MaskedValueWriteError', 'ExplainObjectNotFoundError', - 'PermissionSetNameConflictError', ]; /** diff --git a/packages/plugins/plugin-security/src/errors.ts b/packages/plugins/plugin-security/src/errors.ts index eaf10992655..06a09b29805 100644 --- a/packages/plugins/plugin-security/src/errors.ts +++ b/packages/plugins/plugin-security/src/errors.ts @@ -334,89 +334,6 @@ export class ExplainObjectNotFoundError extends Error { } } -/** - * The ADR-0112 code {@link PermissionSetNameConflictError} stamps — see that - * class for why this collision is `UNIQUE_VIOLATION` and why the value is a - * named constant rather than a class-field literal. - */ -export const PERMISSION_SET_NAME_CONFLICT_CODE = 'UNIQUE_VIOLATION'; - -/** The HTTP status {@link PermissionSetNameConflictError} declares. */ -export const PERMISSION_SET_NAME_CONFLICT_STATUS = 409; - -/** - * [commit 8f6d83147] The data door's duplicate-name refusal on `sys_permission_set`: - * a set with this machine name already exists in the caller's organization, so - * the insert is refused. - * - * ## Why this is a CLASS and not a bare `Error` with `.status = 409` - * - * It was the bare form until now, and the bare form has no `code`. The flat - * `{ error, code }` responder in `packages/rest` puts a thrown `code` on the - * wire and invents nothing when the producer declared none, so the refusal - * reached the client as prose alone — against ADR-0112's 2026-08-17 amendment - * (#9232), under which the flat door carries the closed member too. Measured - * before the fix: `409 {"error":"[Security] permission set 'showcase_manager' - * already exists","object":"sys_permission_set"}`, with no `code` key at all, - * while an unauthenticated write on the same resource answered - * `401 UNAUTHENTICATED` — so the absence was this producer's, never the door's. - * A dialog that has to branch on the refusal was pushed to string-matching. - * - * ## Why `UNIQUE_VIOLATION` and not a newly minted code - * - * It is the wire identity this platform ALREADY answers for this exact - * condition on this exact column. `sys_permission_set` declares - * `{ fields: ['name'], unique: 'organization' }`, and a collision that reaches - * the storage layer comes back as `409 UNIQUE_VIOLATION` — the reading - * recorded on that index's own comment (#8554) is `org_yi 409 - * UNIQUE_VIOLATION`. This middleware refuses the same collision one layer - * earlier, so a second spelling here would make ONE condition answer two - * envelopes depending only on whether the projection's pre-check or the index - * caught it — the drift `@objectstack/rest` and `@objectstack/driver-memory` - * already registered the SAME code to avoid ("the wire identity is - * deliberately the SAME"). #5240's one-condition-one-wording, on the code axis. - * - * ⛔ Not `RESOURCE_CONFLICT` (the standard member 409 derives from): that is - * what the door would supply for a producer that named no condition, and it - * would be the second spelling described above. - * - * ## Why BOTH `status` and `statusCode` - * - * The same reason every class above records: the doors read different property - * names (see "Why each carries BOTH `status` and `statusCode`" above), and this - * throws on the DATA path, which reaches them all. - * - * The message is byte-identical to the bare `Error`'s — the wording was never - * the defect, and the flat door's 4xx arm ships it verbatim. - * - * ## Why the code is a NAMED CONSTANT and not a bare class-field literal - * - * Same spelling `@objectstack/driver-memory` uses for its own registration of - * this code ("via the package's exported `UNIQUE_VIOLATION_CODE` / - * `UNIQUE_VIOLATION_STATUS`"), and the reason is mechanical rather than - * stylistic: `check:error-code-provenance` recognises `objlit`, `assign` and - * `*_CODE` `constdef` stamp sites and is blind to class fields by its own - * declared bounds. Written as a class-field literal this package would have - * become an unlisted EMITTER of a registered code with every gate in the repo - * green — the exact invisibility the ledger header names ("no admission rule - * checks WHO emits, so an unlisted emitter is invisible to every gate the repo - * has", three hand sweeps, #7504 / #13254 / #13353). The constant puts this - * emitter inside the gate's field of view, so the provenance row under - * `@objectstack/plugin-security` is enforced and not merely intended. - */ -export class PermissionSetNameConflictError extends Error { - readonly code = PERMISSION_SET_NAME_CONFLICT_CODE; - readonly status = PERMISSION_SET_NAME_CONFLICT_STATUS; - readonly statusCode = PERMISSION_SET_NAME_CONFLICT_STATUS; - /** The permission-set machine name that was already taken. */ - readonly setName: string; - constructor(setName: string) { - super(`[Security] permission set '${setName}' already exists`); - this.name = 'PermissionSetNameConflictError'; - this.setName = setName; - } -} - export function isPermissionDeniedError(e: unknown): e is PermissionDeniedError { if (!e || typeof e !== 'object') return false; const anyE = e as any; diff --git a/packages/plugins/plugin-security/src/object-posture-gate.ts b/packages/plugins/plugin-security/src/object-posture-gate.ts index 80115de02a4..e59c5acde7b 100644 --- a/packages/plugins/plugin-security/src/object-posture-gate.ts +++ b/packages/plugins/plugin-security/src/object-posture-gate.ts @@ -138,8 +138,7 @@ export function objectPostureGate(ctx: ObjectPostureGateContext): void { } /** - * Wire the gate onto the protocol. Feature-detected like - * `registerPermissionSetProjection` — a protocol that predates + * Wire the gate onto the protocol. Feature-detected — a protocol that predates * `registerAuthoringGate` (older embeddings, unit-test stubs) simply keeps * the legacy behavior (CLI lint remains the only guard there). Returns * `true` when wired. diff --git a/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts index 366998cec83..c61d0bdc52c 100644 --- a/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts @@ -116,7 +116,7 @@ export const SysAudienceBindingSuggestion = ObjectSchema.create({ // `sys_permission_set.name` is ever widened, the pin beside this file is // where this bound is re-derived rather than rediscovered on MySQL. maxLength: 100, - description: 'Name of the suggested permission set (resolved against sys_permission_set at confirm time).', + description: 'Name of the suggested permission set (resolved in the security catalog at confirm time).', }), anchor: Field.select({ diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index ab2b0c0380a..7d2cb6169a6 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -41,7 +41,7 @@ export const SysUserPosition = ObjectSchema.create({ // default is full CRUD, so no `userActions` block is needed — the affordance is // a declaration only; the DelegatedAdminGate is the authz. managedBy: 'system-data', - description: 'Assigns a position (sys_position.name) to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).', + description: 'Assigns a position, by name, to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).', // [ADR-0079] The record title is `display_title`, a text formula over the // same two columns `titleFormat` names. With no pointer declared, the // registry's designate-only pass stamped `nameField: 'id'` (the first @@ -89,7 +89,7 @@ export const SysUserPosition = ObjectSchema.create({ // positions the security catalog serves and stores the chosen NAME. // Falls back to a text input where the widget is not registered. widget: 'position-ref', - description: 'Position machine name (references sys_position.name).', + description: 'Position machine name, resolved in the security catalog.', }), business_unit_id: Field.lookup('sys_business_unit', { diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.ts index a6de3ffce58..63a7f37f968 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.ts @@ -7,8 +7,9 @@ * * `packaged-permission-set-lock.ts` refuses a write that targets a * package-declared permission set. Until this file existed it had exactly one - * enforcement point: the `sys_permission_set` DATA door - * (`createPermissionSetWriteThrough`, `permission-set-projection.ts`). The + * enforcement point: the `sys_permission_set` DATA door's write-through + * (retired with that object in C3 stages 7a and 8; this gate is now the lock's + * only enforcement point). The * metadata protocol's pre-persistence authoring-gate seam (ADR-0094 addendum; * `registerAuthoringGate`) carried an `'object'` registration only — so a * metadata-door write targeting the same package-declared set reached @@ -20,7 +21,7 @@ * * - ⭐ ONE SPELLING (Prime Directive #8). The gate calls * {@link assertPermissionSetNotPackageDeclared} — the same assertion the - * data door calls, consulting the same {@link classifyPackagedPermissionSet} + * retired data door called, consulting the same {@link classifyPackagedPermissionSet} * classifier and throwing the same error classes * (`PackagedPermissionSetLockedError`, and the fail-closed * `PackagedPermissionSetProvenanceUnknownError` when no provenance source @@ -85,10 +86,9 @@ export function registerPackagedPermissionSetLockGate(protocol: any, ql: any): b probe = { status: 'failed', reason: String((e as Error)?.message ?? e) }; } } - // `'update'` on purpose: a metadata-door save that targets a - // package-declared name is an override of the shipped base whether or not - // an overlay row exists yet, so the remedy the refusal teaches is the - // clone path, not "choose a different name". + // A metadata-door save that targets a package-declared name is an + // override of the shipped base whether or not an overlay row exists yet, + // so the remedy the refusal teaches is the clone path. assertPermissionSetNotPackageDeclared(ctx.name, ql, 'update', probe); }); return true; diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts index b433e436bbf..82ed5b0d377 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts @@ -32,8 +32,8 @@ * by the code written to stop silent forks. * * ⛔ So the answer is NOT taken from a name-keyed page over - * `sys_permission_set`. The batched existence oracle - * (`seed-name-lookup.ts`'s `buildExistingByName`) capped its UNSCOPED page at + * `sys_permission_set`. The batched existence oracle the catalog seeders read + * (`buildExistingByName`, retired with them in C3 stage 7a) capped its UNSCOPED page at * `limit: names.length`, which truncates the moment one name can carry more * than one row — and a truncated page read as `absent`. Commit e1d773eb7 has since * repaired that: the page budget is measured (one row more than it will hold is @@ -89,11 +89,10 @@ import { isTenantAuthored } from '@objectstack/metadata-core'; /** * ⛔ This module imports NOTHING from the rest of the plugin, and that is - * structural rather than stylistic: `permission-set-projection.ts` imports - * THIS module (its write door is the lock's only enforcement point), so any - * import back the other way — directly, or through - * `bootstrap-declared-permissions.ts`, which itself imports the projection - * module — would close a cycle around a write door. + * structural rather than stylistic: the write doors import THIS module (the + * metadata door's gate, `packaged-permission-set-lock-gate.ts`, and the + * catalog echo, `security-catalog-echo.ts`), so any import back the other way + * could close a cycle around a write door. * * ⚠️ In particular it does not reuse {@link readDeclared}, and that is a * deliberate strictness difference rather than a second spelling: `readDeclared` @@ -105,11 +104,11 @@ import { isTenantAuthored } from '@objectstack/metadata-core'; */ /** - * The marker `permission-set-projection.ts` stamps on bodies it syncs into the - * metadata manager's in-memory registry, so its own copy of an overlay body can - * never masquerade as a shipped artifact. + * The marker the catalog echo (`security-catalog-echo.ts`) stamps on bodies it + * syncs into the metadata manager's in-memory registry, so its own copy of an + * overlay body can never masquerade as a shipped artifact. * - * It lives HERE, and the projection module imports it from here, so the + * It lives HERE, and the echo imports it from here, so the * constant has exactly one home — a lock that decides "packaged" partly by the * absence of this marker must not be reading a second, drifting spelling of it. */ @@ -118,8 +117,8 @@ export const ENV_PROJECTION_MARKER = '_envProjection'; /** * The `_packageId` value the metadata layer stamps on a RUNTIME SHADOW — an * item hydrated into the registry from a `sys_metadata` overlay row rather - * than shipped as an artifact. `readDeclaredBody` in the projection module - * excludes it for the same reason this does: a shadow is the overlay, not the + * than shipped as an artifact. The retired projection module's + * `readDeclaredBody` excluded it for the same reason this does: a shadow is the overlay, not the * declaration it shadows, and reading it as "declared" would lock a set that * no package ships. */ @@ -191,8 +190,7 @@ function declaredPackageIdOf(item: any, name: string): string | null { * one source this repo already calls "package-declared". In-memory, so it * cannot truncate; * 2. the layered read's `code` layer, for kernels that expose no readable - * SchemaRegistry (minimal embeddings; the same fallback - * `projectPermissionMutation` already uses). One name, one envelope — no + * SchemaRegistry (minimal embeddings). One name, one envelope — no * page and no cap here either. */ export function classifyPackagedPermissionSet( @@ -284,15 +282,11 @@ export function classifyPackagedPermissionSet( * English, like every platform refusal: there is no localization path for a * thrown `userMessage`, and these texts do not invent one. */ -const LOCKED_UPDATE_USER_MESSAGE = +const LOCKED_USER_MESSAGE = `This permission set is provided by an installed package and can't be edited here. Clone it instead ` + `with the Clone action, then edit the clone. The clone is your organization's own permission set, ` + `and package upgrades keep reaching the original.`; -const LOCKED_INSERT_USER_MESSAGE = - `This name belongs to a permission set provided by an installed package. Choose a different name for ` - + `your permission set, or clone the packaged one with the Clone action and edit the clone.`; - const PROVENANCE_UNKNOWN_USER_MESSAGE = `This permission set can't be saved right now, because the system couldn't confirm whether an installed ` + `package provides it. Try again in a moment. To customize a permission set that a package provides, ` @@ -321,10 +315,16 @@ const PROVENANCE_UNKNOWN_USER_MESSAGE = * `status` AND `statusCode`: the two transports read different property names * (`mapDataError` passes a domain error through on `.status`; the runtime * dispatcher's `errorFromThrown` reads `.status` then falls back to - * `.statusCode`), and this throws on the DATA path, which reaches both. + * `.statusCode`), and a door's refusal can reach either. + * + * `userMessage` carries the same guidance for the end user — see the note on + * the texts above. * - * `userMessage` carries the same guidance for the end user, per operation — - * see the note on the texts above. + * One remedy, the clone: the one door that throws it (the metadata door's + * authoring gate) judges an override of the shipped base. The "choose a + * different name" variant the retired data door's create raised is gone with + * that door (C3 stage 8). `operation` stays in the published signature and + * no longer selects the text. */ export class PackagedPermissionSetLockedError extends Error { readonly code = 'NOT_OVERRIDABLE'; @@ -332,20 +332,17 @@ export class PackagedPermissionSetLockedError extends Error { readonly statusCode = 403; /** The guidance addressed to the end user; `message` stays the diagnostic. */ readonly userMessage: string; - constructor(name: string, packageId: string, operation: 'insert' | 'update') { + constructor(name: string, packageId: string, _operation: 'insert' | 'update') { super( `[Security] Permission set '${name}' is declared by package '${packageId}' and is locked in this ` + `environment — editing it here would silently fork it from the package, and the fork would win over ` + `every future upgrade with no signal. ` - + (operation === 'insert' - ? `Choose a different name for your set, or clone '${name}' (the "Clone" action on the permission ` - + `set) and edit the clone.` - : `Clone it instead (the "Clone" action on the permission set, or POST ` - + `/api/v1/security/permission-sets/${name}/clone with a new name) and edit the clone — the clone is your organization's own set, and upgrades keep ` - + `flowing to '${name}' untouched.`), + + `Clone it instead (the "Clone" action on the permission set, or POST ` + + `/api/v1/security/permission-sets/${name}/clone with a new name) and edit the clone — the clone is your organization's own set, and upgrades keep ` + + `flowing to '${name}' untouched.`, ); this.name = 'PackagedPermissionSetLockedError'; - this.userMessage = operation === 'insert' ? LOCKED_INSERT_USER_MESSAGE : LOCKED_UPDATE_USER_MESSAGE; + this.userMessage = LOCKED_USER_MESSAGE; } } diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts index deca1cefeeb..efcfa2a1b5f 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts @@ -142,8 +142,8 @@ describe('pin 4 — the detection reading NAMES the forked sets', () => { }); it('⭐ finds the CONFOUNDED row the card measured — managed_by wrong AND an overlay present', async () => { - // The field-reported shape. `customized` is forced false here by - // `upsertEnvPermissionSet`'s `existing.managed_by === 'package'` gate, so a + // The field-reported shape. `customized` was forced false here by the + // retired projector's `existing.managed_by === 'package'` gate, so a // reading that consulted the column would report zero forks on precisely // the environment that had one — for two weeks, measured. const a = declaredSet(); @@ -180,11 +180,8 @@ describe('pin 4 — the detection reading NAMES the forked sets', () => { }); it('an ORG-scoped overlay row is out of scope, the same as everywhere else in this family', async () => { - // `reconcilePermissionSetProjection` and `permission-set-drift.ts` both - // consider env-wide overlays only (#10103 residue, deliberately out of - // scope). A reading that answered a different question from the reconciler - // it is reporting on would send an operator to a row the reconciler never - // touches. + // Env-wide overlays only (#10103 residue, deliberately out of scope), the + // boundary the retired reconciler and drift diagnostic drew. const a = declaredSet(); const ql = makeQl([a]); ql.permRows.push(rowFor(a)); diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts index f5229fbd0de..993aa5f936e 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts @@ -32,7 +32,7 @@ * * ## ⛔ It does not consult `customized`, and that is measured, not stylistic * - * `upsertEnvPermissionSet` computes the flag as + * The retired projector (`upsertEnvPermissionSet`, C3 stage 7a) computed the flag as * `existing.managed_by === 'package' ? !!customized : false`. On the exact * field-reported shape — a genuinely package-declared set whose row's * `managed_by` predates provenance tracking (`permission-set-drift.ts`'s @@ -49,10 +49,8 @@ * ## Scope, stated rather than assumed * * - ENV-WIDE overlays only (`sys_metadata.organization_id IS NULL`), the same - * boundary `reconcilePermissionSetProjection` and `permission-set-drift.ts` - * draw (#10103 residue, deliberately out of scope). A reading that answered - * a different question from the reconciler it reports on would send an - * operator to a row the reconciler never touches; + * boundary the retired reconciler (`reconcilePermissionSetProjection`) and + * drift diagnostic drew (#10103 residue, deliberately out of scope); * - "package-declared" is decided by {@link classifyPackagedPermissionSet} — * the engine SchemaRegistry, the same source the write-door lock uses, so * the reading and the lock can never disagree about which sets are locked; @@ -84,11 +82,9 @@ async function tryFind(ql: any, object: string, where: any, limit: number): Prom } /** - * Page cap for the `sys_metadata` overlay sweep — the same value - * `reconcilePermissionSetProjection` and `computePermissionSetDriftDiagnostics` - * use for the identical read. Shared deliberately: three readings of the same - * rows answering under three different caps would disagree about the same - * environment. + * Page cap for the `sys_metadata` overlay sweep — the value the retired + * reconciler and drift diagnostic used for the identical read, kept so this + * reading answers under the cap it always has. */ export const OVERLAY_PAGE_LIMIT = 1000; diff --git a/packages/plugins/plugin-security/src/permission-set-name-collision.ts b/packages/plugins/plugin-security/src/permission-set-name-collision.ts index 10d20d61bcb..c83d662226d 100644 --- a/packages/plugins/plugin-security/src/permission-set-name-collision.ts +++ b/packages/plugins/plugin-security/src/permission-set-name-collision.ts @@ -56,10 +56,8 @@ /** * The minimal sink this module reports through. * - * Structurally satisfied by both `ProjectionLogger` and the catalog's - * `SeedLogger` — declared here rather than imported so this module stays a leaf - * and `permission-set-projection.ts` can name the diagnostic type without a - * cycle. `warn` is non-optional for the #9754 reason its two siblings give: a + * Structurally satisfied by the catalog's `SeedLogger` — declared here rather + * than imported so this module stays a leaf. `warn` is non-optional for the #9754 reason its two siblings give: a * fallback channel that may itself be absent is not a fallback. */ export interface CollisionReportSink { diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index d008fbeab95..6476c519dbc 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -2360,586 +2360,6 @@ describe('SecurityPlugin', () => { await expect(h.run(opCtx)).resolves.toBeDefined(); }); }); - - // ── ADR-0086 P2 (块2) — two-doors data-layer write gate ──────────────── - // A `managed_by:'package'` sys_permission_set row is owned by the package - // door; the admin data-plane write path must refuse to mutate it, even for a - // superuser (modifyAllRecords). System/boot writes carry isSystem and never - // reach the gate. - describe('two-doors write gate (sys_permission_set managed_by:package)', () => { - // Superuser set — proves the gate is NOT grant-gated (blocks even - // modifyAllRecords) and carries no RLS so the pre-image check is a no-op. - const adminSet: PermissionSet = { - name: 'admin_full_access', label: 'Admin', - objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true } }, - } as any; - const adminCtx = { userId: 'admin1', tenantId: 'org-1', positions: [], permissions: ['admin_full_access'] }; - - const runGate = async (opCtx: any, findOneImpl?: (q: any) => any) => { - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' }); - const harness = makeMiddlewareCtx({ - permissionSets: [adminSet], - objectFields: ['id', 'name', 'managed_by', 'package_id'], - ...(findOneImpl ? { findOneImpl } : {}), - }); - await plugin.init(harness.ctx); - await plugin.start(harness.ctx); - return harness.run(opCtx); - }; - - it('PASSES an admin update of a package-managed set at THIS gate (ADR-0094: the refusal is the write-through producer\'s, not this gate\'s)', async () => { - // update/delete on a package row are not refused at this gate — the - // ADR-0094 write-through downstream translates them into a metadata - // write, and that producer decides. Since ADR-0094 D5-R (commit - // ee58392e1) the answer for a CODE-DECLARED set is 403 NOT_OVERRIDABLE, - // so "the write-through turns it into an env overlay" is no longer why - // this passes; it passes because the gate's job is forging provenance, - // not overridability. The refusal is covered in - // permission-set-projection.test.ts. - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', - data: { id: 'ps_pkg', label: 'customize' }, options: { where: { id: 'ps_pkg' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'ps_pkg', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm' })), - ).resolves.toBeDefined(); - }); - - it('still DENIES lifecycle ops with no overlay translation (purge) on a package-managed set', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'purge', - options: { where: { id: 'ps_pkg' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'ps_pkg', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS an admin update of an env-authored set (managed_by:user)', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', - data: { id: 'ps_env', label: 'renamed' }, options: { where: { id: 'ps_env' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'ps_env', name: 'my_custom', managed_by: 'user' })), - ).resolves.toBeDefined(); - }); - - it('DENIES an admin-door insert that forges package provenance', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', - data: { name: 'forged', managed_by: 'package', package_id: 'com.example.crm' }, - context: adminCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES a bulk/ARRAY insert that forges package provenance on any element', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', - data: [ - { name: 'ok_custom' }, - { name: 'forged', managed_by: 'package', package_id: 'com.example.crm' }, - ], - context: adminCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES an update that RE-BADGES an env row as package-managed (update-to-forge)', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', - data: { id: 'ps_env', managed_by: 'package', package_id: 'com.example.crm' }, - options: { where: { id: 'ps_env' } }, - context: adminCtx, - }; - // Even though the EXISTING row is env-owned, the payload forges provenance. - await expect( - runGate(opCtx, () => ({ id: 'ps_env', name: 'my_custom', managed_by: 'user' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES even a principal-less lifecycle write to a package row (gate is before the fall-open)', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'purge', - options: { where: { id: 'ps_pkg' } }, - context: {}, // no roles, no permissions, no userId, not isSystem - }; - await expect( - runGate(opCtx, () => ({ id: 'ps_pkg', managed_by: 'package', package_id: 'com.example.crm' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS a normal admin-door insert (no forged provenance)', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', - data: { name: 'my_custom', label: 'Custom' }, - context: adminCtx, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - - it('DENIES a filter LIFECYCLE write whose filter matches a package-managed row', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'purge', - options: { where: { active: true } }, // no single id → filter path - context: adminCtx, - }; - await expect( - // the gate probes for a package row within the filter → found → deny - runGate(opCtx, () => ({ id: 'ps_pkg', managed_by: 'package' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS a filter update (bulk edits route through the write-through downstream)', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', - data: { label: 'bulk-rename' }, options: { where: { managed_by: 'user' } }, - context: adminCtx, - }; - await expect(runGate(opCtx, () => null)).resolves.toBeDefined(); - }); - - it('lets system/boot writes through (isSystem bypass) even on a package row', async () => { - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', - data: { id: 'ps_pkg' }, options: { where: { id: 'ps_pkg' } }, - context: { isSystem: true }, - }; - await expect( - runGate(opCtx, () => ({ id: 'ps_pkg', managed_by: 'package', package_id: 'com.example.crm' })), - ).resolves.toBeDefined(); - }); - }); - - // ── ADR-0066 / #2918 — built-in row-write guardrail (sys_position / sys_capability) ── - // Platform/application-managed asset rows are not the admin's to delete or - // rewrite. Unlike sys_permission_set these objects have no ADR-0094 overlay - // write-through, so the refusal must hold for update/delete at this gate. - // Matrix: non-admin/admin × managed/admin-authored × delete/update. - describe('system-row write gate (sys_position / sys_capability provenance)', () => { - const adminSet: PermissionSet = { - name: 'admin_full_access', label: 'Admin', - objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true } }, - } as any; - const adminCtx = { userId: 'admin1', tenantId: 'org-1', positions: [], permissions: ['admin_full_access'] }; - - const runGate = async (opCtx: any, findOneImpl?: (q: any) => any) => { - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' }); - const harness = makeMiddlewareCtx({ - permissionSets: [adminSet], - objectFields: ['id', 'name', 'label', 'managed_by'], - ...(findOneImpl ? { findOneImpl } : {}), - }); - await plugin.init(harness.ctx); - await plugin.start(harness.ctx); - return harness.run(opCtx); - }; - - it('DENIES deleting a platform-managed position (sys_position managed_by:platform)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { id: 'pos_admin' } }, context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_admin', name: 'platform_admin', managed_by: 'platform' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES updating a package-declared position (sys_position managed_by:package)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'update', - data: { id: 'pos_sales', label: 'renamed' }, options: { where: { id: 'pos_sales' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_sales', name: 'sales_rep', managed_by: 'package' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - // [#2926 ①] Regression: the A4 rename (system→platform, config→package) once - // disarmed this gate because the provenance map only knew the legacy values. - // Rows the boot normalizer has not healed yet must STAY protected too. - it('KEEPS denying legacy-vocab managed positions (managed_by:system, pre-normalizer rows)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { id: 'pos_legacy' } }, context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_legacy', name: 'everyone', managed_by: 'system' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('KEEPS denying legacy-vocab managed positions (managed_by:config, pre-normalizer rows)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'update', - data: { id: 'pos_legacy2', label: 'renamed' }, options: { where: { id: 'pos_legacy2' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_legacy2', name: 'sales_rep', managed_by: 'config' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES deleting a platform-owned capability (sys_capability managed_by:platform)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'delete', - options: { where: { id: 'cap_plat' } }, context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_plat', name: 'manage_users', managed_by: 'platform' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES updating a package-owned capability (sys_capability managed_by:package)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_pkg', label: 'renamed' }, options: { where: { id: 'cap_pkg' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_pkg', name: 'crm.export', managed_by: 'package' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS deleting an admin-authored position (sys_position managed_by:admin)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { id: 'pos_user' } }, context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_user', name: 'my_team', managed_by: 'admin' })), - ).resolves.toBeDefined(); - }); - - it('ALLOWS deleting a legacy admin-authored position (managed_by:user, pre-normalizer)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { id: 'pos_user_legacy' } }, context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_user_legacy', name: 'my_team', managed_by: 'user' })), - ).resolves.toBeDefined(); - }); - - it('ALLOWS updating an admin-authored position with NO managed_by (tenant-created)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'update', - data: { id: 'pos_user', label: 'renamed' }, options: { where: { id: 'pos_user' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_user', name: 'my_team', managed_by: null })), - ).resolves.toBeDefined(); - }); - - it('ALLOWS updating an admin-authored capability (sys_capability managed_by:admin)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_admin', label: 'renamed' }, options: { where: { id: 'cap_admin' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_admin', name: 'my_cap', managed_by: 'admin' })), - ).resolves.toBeDefined(); - }); - - it('DENIES an admin-door insert that forges platform provenance (sys_position managed_by:platform)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'insert', - data: { name: 'forged', managed_by: 'platform' }, context: adminCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES an admin-door insert that forges LEGACY platform provenance (managed_by:system)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'insert', - data: { name: 'forged_legacy', managed_by: 'system' }, context: adminCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES a bulk/ARRAY insert that forges package provenance on any element (sys_capability)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: [ - { name: 'ok_admin_cap' }, - { name: 'forged', managed_by: 'package' }, - ], - context: adminCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('DENIES an update that RE-BADGES an admin row as package-managed (update-to-forge)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_admin', managed_by: 'package' }, options: { where: { id: 'cap_admin' } }, - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_admin', name: 'my_cap', managed_by: 'admin' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS a normal admin-door capability insert (no forged provenance)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'my_cap', label: 'Custom', managed_by: 'admin' }, - context: adminCtx, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - - it('DENIES a filter DELETE whose filter matches a platform/package-managed row', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { active: false } }, // no single id → filter path - context: adminCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_admin', managed_by: 'platform' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('ALLOWS a filter delete that matches only admin-authored rows (probe finds none)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { managed_by: 'admin' } }, - context: adminCtx, - }; - // The managed-row probe finds nothing → the write proceeds. - await expect(runGate(opCtx, () => null)).resolves.toBeDefined(); - }); - - it('DENIES even a principal-less delete of a managed row (gate is before the fall-open)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'delete', - options: { where: { id: 'pos_admin' } }, - context: {}, // no roles, no permissions, no userId, not isSystem - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_admin', name: 'platform_admin', managed_by: 'platform' })), - ).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - }); - - it('lets system/boot writes through (isSystem bypass) even on a platform-managed position', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'update', - data: { id: 'pos_admin', label: 'reseed' }, options: { where: { id: 'pos_admin' } }, - context: { isSystem: true }, - }; - await expect( - runGate(opCtx, () => ({ id: 'pos_admin', name: 'platform_admin', managed_by: 'platform' })), - ).resolves.toBeDefined(); - }); - }); - - // ── #8552 / ADR-0066 D1 — curated capability-name refusal (create + rename) ── - // A `sys_capability` name in PLATFORM_CAPABILITY_NAMES is the platform's: - // Setup may neither CREATE a row with it nor RENAME a row TO it — refused at - // this write door with an error naming the colliding curated name, never - // answered 200. Existing colliding rows stay the operator's (ruling option - // 1): a payload repeating a row's own curated name is not a rename and - // passes, and renaming AWAY stays open. The load-bearing half of this suite - // is the POSITIVE controls — a refusal too broad passes every "it refuses" - // assertion, so non-curated authoring is pinned open in BOTH deployment - // shapes (org-scoped, and the community NULL-bucket shape with no tenant). - describe('curated capability-name write gate (#8552, sys_capability create/rename)', () => { - const adminSet: PermissionSet = { - name: 'admin_full_access', label: 'Admin', - objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true } }, - } as any; - // Enterprise / org-scoped shape: the caller carries an active organization. - const orgCtx = { userId: 'admin1', tenantId: 'org-1', positions: [], permissions: ['admin_full_access'] }; - // Community NULL-bucket shape: no `@objectstack/organizations` stamper, no - // tenantId — every Setup-authored row lands in the NULL-organization - // bucket. This is the default community deployment, not an edge case. - const communityCtx = { userId: 'admin1', positions: [], permissions: ['admin_full_access'] }; - - const runGate = async (opCtx: any, findOneImpl?: (q: any) => any) => { - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' }); - const harness = makeMiddlewareCtx({ - permissionSets: [adminSet], - objectFields: ['id', 'name', 'label', 'managed_by', 'active'], - ...(findOneImpl ? { findOneImpl } : {}), - }); - await plugin.init(harness.ctx); - await plugin.start(harness.ctx); - return harness.run(opCtx); - }; - - // The refusal envelope, asserted whole (code AND status, plus the located - // name) — `rejects.toThrow()` alone would stay green on any refusal from - // any gate, which is exactly the vacuity this suite must not have. - const curatedRefusal = (name: string) => ({ - name: 'PermissionDeniedError', - code: 'PERMISSION_DENIED', - statusCode: 403, - message: expect.stringContaining(`'${name}' is a platform-curated capability name`), - }); - - it('DENIES creating a capability with a curated name (org-scoped shape), naming the collision', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'manage_users', label: 'Mine now' }, context: orgCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - it('DENIES creating a capability with a curated name (community NULL-bucket shape)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'setup.write', label: 'Setup Write' }, context: communityCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject(curatedRefusal('setup.write')); - }); - - it('DENIES an ARRAY insert when ANY element claims a curated name', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: [{ name: 'my_cap_a' }, { name: 'manage_sharing' }], - context: orgCtx, - }; - await expect(runGate(opCtx)).rejects.toMatchObject(curatedRefusal('manage_sharing')); - }); - - it('DENIES RENAMING an admin-authored capability TO a curated name (by-id update)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_admin', name: 'manage_users' }, options: { where: { id: 'cap_admin' } }, - context: orgCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_admin', name: 'my_cap', managed_by: 'admin' })), - ).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - it('DENIES a rename-to-curated even for a principal-less context (gate is before the fall-open)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_admin', name: 'manage_users' }, options: { where: { id: 'cap_admin' } }, - context: {}, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_admin', name: 'my_cap', managed_by: 'admin' })), - ).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - it('FAILS CLOSED when the renamed row cannot be read back (absent pre-image refuses)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_gone', name: 'manage_users' }, options: { where: { id: 'cap_gone' } }, - context: orgCtx, - }; - // Every findOne answers null: no pre-image exists to prove the payload - // repeats the row's own name, so the write is refused. - await expect(runGate(opCtx, () => null)).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - it('DENIES a FILTER update stamping a curated name onto differently-named rows', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { name: 'manage_users', active: false }, options: { where: { active: true } }, - context: orgCtx, - }; - // The $ne probe (and only it) finds a row NOT already named - // manage_users inside the write's own filter; the system-row gate's - // managed-row probe finds nothing, so the refusal measured here is this - // gate's, not a neighbour's. - const impl = (q: any) => { - const and = q?.where?.$and; - if (Array.isArray(and) && and.some((c: any) => c && typeof c === 'object' && c.name && typeof c.name === 'object' && '$ne' in c.name)) { - return { id: 'cap_other', name: 'my_cap', managed_by: 'admin' }; - } - return null; - }; - await expect(runGate(opCtx, impl)).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - it('DENIES a WHOLE-TABLE update stamping a curated name (no filter to scope the rename)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { name: 'manage_users' }, context: orgCtx, - }; - await expect(runGate(opCtx, () => null)).rejects.toMatchObject(curatedRefusal('manage_users')); - }); - - // ── positive controls — the load-bearing half ── - - it('ALLOWS creating a NON-curated capability (org-scoped shape)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'org_export_reports', label: 'Export Reports', managed_by: 'admin' }, - context: orgCtx, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - - it('ALLOWS creating a NON-curated capability (community NULL-bucket shape)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'org_export_reports', label: 'Export Reports', managed_by: 'admin' }, - context: communityCtx, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - - it('ALLOWS a full-record update of an EXISTING colliding admin row that repeats its own name (not a rename)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_collide', name: 'manage_users', label: 'Renamed label', active: false }, - options: { where: { id: 'cap_collide' } }, - context: communityCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_collide', name: 'manage_users', managed_by: 'admin' })), - ).resolves.toBeDefined(); - }); - - it('ALLOWS renaming an EXISTING colliding admin row AWAY from the curated name (the operator remediation)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { id: 'cap_collide', name: 'org_manage_users' }, - options: { where: { id: 'cap_collide' } }, - context: communityCtx, - }; - await expect( - runGate(opCtx, () => ({ id: 'cap_collide', name: 'manage_users', managed_by: 'admin' })), - ).resolves.toBeDefined(); - }); - - it('ALLOWS a FILTER update stamping a curated name when it provably renames nothing (probe finds no other-named row)', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'update', - data: { name: 'manage_users', active: true }, options: { where: { name: 'manage_users' } }, - context: communityCtx, - }; - await expect(runGate(opCtx, () => null)).resolves.toBeDefined(); - }); - - it('lets system/boot writes through (isSystem bypass) — the curated seeder itself is unaffected', async () => { - const opCtx: any = { - object: 'sys_capability', operation: 'insert', - data: { name: 'manage_users', label: 'Manage Users', managed_by: 'platform' }, - context: { isSystem: true }, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - - it('does NOT reach sys_position — a position may share a curated capability STRING (scope control)', async () => { - const opCtx: any = { - object: 'sys_position', operation: 'insert', - data: { name: 'manage_users', label: 'A position, not a capability' }, - context: orgCtx, - }; - await expect(runGate(opCtx)).resolves.toBeDefined(); - }); - }); }); // --------------------------------------------------------------------------- describe('PermissionEvaluator', () => { @@ -4694,41 +4114,4 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 expect(bindings).toEqual([]); } }); - - // ── The engine-side write gate (`sys_position_permission_set` insert) ── - // - // Same three cases at the door an ADMIN comes through. It must agree with the - // boot binding above and with `confirmAudienceBindingSuggestion`'s early - // rendition — all three now read one source, so a set that binds at boot - // cannot be refused when an admin re-binds it by hand. - const anchorInsert = { - object: 'sys_position_permission_set', - operation: 'insert', - data: { position_id: 'pos_everyone', permission_set_id: 'ps_app' }, - }; - - it('write gate: admits a binding whose token the stack declares', async () => { - const { plugin } = await boot('crm.export_pipeline', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); - await expect((plugin as any).assertAudienceAnchorBindingGate(anchorInsert)).resolves.toBeUndefined(); - }); - - it('write gate: still refuses an undeclared token, with the ADR-0112 envelope', async () => { - const { plugin } = await boot('crm.settle_ledger', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); - const err = await (plugin as any) - .assertAudienceAnchorBindingGate(anchorInsert) - .then(() => null, (e: any) => e); - expect(err).toBeTruthy(); - expect(err.code).toBe('PERMISSION_DENIED'); - expect(err.statusCode).toBe(403); - expect(err.message).toContain('system permissions'); - }); - - it('write gate: still refuses a platform capability of that name', async () => { - const { plugin } = await boot('manage_users', [{ name: 'manage_users', label: 'Not Yours' }]); - const err = await (plugin as any) - .assertAudienceAnchorBindingGate(anchorInsert) - .then(() => null, (e: any) => e); - expect(err?.code).toBe('PERMISSION_DENIED'); - expect(err?.statusCode).toBe(403); - }); }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b042c660f9c..b5a1d8096bd 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4,8 +4,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { declaredHttpStatus } from '@objectstack/types'; import { Plugin, PluginContext, POSTURE_LADDER, buildEffectiveObjectPermissions, recordNotFoundError, withoutOperationPrivateKeys } from '@objectstack/core'; import type { EffectiveObjectPermission, PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; -import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; -import type { AnchorBindingContext } from '@objectstack/spec/security'; +import { describeHighPrivilegeBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; // [#8220] The read-scope provenance mark: this middleware is one of the two // merge boundaries that stamp it (see the RLS injection below). @@ -108,7 +107,6 @@ import { type ObjectAccessScope, type TenancyPosture, } from '@objectstack/spec/security'; -import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; import { RLS_MEMBERSHIP_RESOLVER_SERVICE, RESERVED_RLS_MEMBERSHIP_KEYS, @@ -583,24 +581,6 @@ interface CbpRelation { omissionRefusedByValidation: boolean; } -/** - * [ADR-0066 / #2918] Provenance spec for the platform/application asset objects - * whose managed rows are write-protected by {@link SecurityPlugin.assertSystemRowWriteGate}. - * - * Both objects share the unified A4 (#2920) `managed_by` vocabulary — a row - * authored by the platform or an application package is not the admin's to - * delete or rewrite: - * • `platform` / `package` are managed; `admin`/∅ (tenant-authored) rows are - * the admin's. - * • sys_position additionally keeps its LEGACY values `system` (→ platform) - * and `config` (→ package) in the managed map: no boot pass heals stored - * rows to the canonical vocabulary any more (ADR-0131 retired it with the - * catalog seeders), so a row still carrying a legacy value keeps its - * protection here. Dropping the legacy keys is what silently disarmed this - * gate for sys_position after the A4 rename (#2926 ①). - * The map value for each managed `managed_by` is the human owner label used in - * the (business-message-only) deny text. - */ /** * [#7281] The scope `checkAuthoredRowWrite`'s probe read runs under. * @@ -794,28 +774,6 @@ function isTenantWallDenial(filter: Record | null | undefined): return filter === RLS_DENY_FILTER; } -const SYSTEM_ROW_PROVENANCE: Record< - string, - { noun: string; pluralNoun: string; managed: Record } -> = { - sys_position: { - noun: 'position', - pluralNoun: 'positions', - managed: { - platform: 'the platform', - package: 'an application package', - // Legacy pre-A4 values — keep guarded until every store is normalized. - system: 'the platform', - config: 'an application package', - }, - }, - sys_capability: { - noun: 'capability', - pluralNoun: 'capabilities', - managed: { platform: 'the platform', package: 'an application package' }, - }, -}; - /** Normalize a raw object `requiredPermissions` (string[] | per-op map) into buckets. */ function normalizeRequiredPermissions(raw: unknown): NormalizedRequiredPermissions { if (Array.isArray(raw)) { @@ -2474,59 +2432,6 @@ export class SecurityPlugin implements Plugin { ); } - // [ADR-0086 P2 — 块2, evolved by ADR-0094] Two-doors write gate. A - // permission set stamped `managed_by:'package'` is owned by the PACKAGE - // door: its BASELINE is authored in the package and lands via publish - // (块1). The admin door must never FORGE that provenance (insert or - // update), and the lifecycle ops with no overlay translation - // (transfer/restore/purge) stay refused on package rows. Ordinary - // `update`/`delete` on a package row are handled downstream by the - // ADR-0094 write-through, which TRANSLATES them into a metadata write. - // Whether that write is ACCEPTED is ADR-0005's call, not this gate's, - // and since ADR-0094 D5-R (commit ee58392e1 rolled `permission` back to - // `allowEnvironmentOverlay: false`) a CODE-DECLARED set is refused there with - // 403 `NOT_OVERRIDABLE` — the 2026-07-14 "customize / reset via an env - // overlay" direction this comment used to state is RETIRED. - // Placed BEFORE the principal-less refusal and the CRUD check so the - // forging boundary holds even for a principal-less context and a - // superuser with modifyAllRecords. System/boot writes carry `isSystem` - // and already short-circuited the whole middleware above. - await this.assertPackageManagedWriteGate(opCtx); - - // [ADR-0066 / #2918] Built-in row-write guardrail for the platform/app - // ASSET objects sys_position / sys_capability. Like the package gate - // above, an unconditional data-layer boundary: a row authored by the - // platform or an application package (provenance recorded in - // `managed_by`) may not be deleted or rewritten through the admin door. - // Unlike sys_permission_set there is NO ADR-0094 overlay write-through - // for these objects, so the refusal must hold for update/delete here - // rather than deferring to a downstream translation. Runs BEFORE the - // principal-less refusal and the CRUD check so the boundary holds even - // for a principal-less context and a superuser with modifyAllRecords. - // System/boot writes carry `isSystem` and already short-circuited above, - // so the seeder and package publish are unaffected. - await this.assertSystemRowWriteGate(opCtx); - - // [#8552 / ADR-0066 D1] Curated-name authoring refusal for - // sys_capability. A name in PLATFORM_CAPABILITY_NAMES is a capability - // the PLATFORM defines; a Setup-authored row claiming one is a - // declaration the platform cannot honour, so it is refused here — - // create and rename-to both — rather than answered 200 and then - // declined by the seeder on every boot. Runs AFTER the provenance gate - // above (a platform/package row's rename is already refused there on - // provenance) and, like it, BEFORE the principal-less refusal and - // the CRUD check. System/boot writes carry `isSystem` and - // short-circuited above, so the curated seeder itself is unaffected. - await this.assertCuratedCapabilityNameGate(opCtx); - - // [ADR-0090 D5/D9] Audience-anchor binding guard — like the package - // gate above, an unconditional data-layer boundary: a permission set - // carrying high-privilege bits must never be bound to the `everyone` - // or `guest` positions, no matter who asks. (Boot/system writes carry - // `isSystem` and short-circuited above; the dev-mode default binding - // is validated at seed time by the same predicate.) - await this.assertAudienceAnchorBindingGate(opCtx); - // [ADR-0103] Engine-owned write guard. Fail-closed on a user-context // generic write to a `system`/`append-only` object whose resolved // affordances forbid the verb (the engine-owned default). Keyed off @@ -6794,463 +6699,6 @@ export class SecurityPlugin implements Plugin { * multi-row predicate and returns `null` (multi-row writes route through the * `*Many` paths, out of scope for the by-id pre-image check). */ - /** - * [ADR-0086 P2 — 块2, evolved by ADR-0094] Two-doors data-layer gate for - * `sys_permission_set`. - * - * A row with `managed_by:'package'` is owned by the package door (its - * baseline is authored in the package, materialized on publish). This gate - * refuses (a) FORGING that provenance through the admin door — insert or - * update, single object or array — and (b) the lifecycle ops with no - * overlay translation (`transfer`/`restore`/`purge`) on package rows. - * Ordinary `update`/`delete` pass through: the ADR-0094 write-through - * downstream translates them into env-scope overlay operations (customize / - * reset), and re-asserts the refusal itself when the kernel has no metadata - * overlay layer. Fails CLOSED and never depends on the caller's grants, so - * a platform admin with `modifyAllRecords` cannot forge provenance either. - * System/boot writes never reach here (the middleware short-circuits on - * `isSystem`), so system writes are unaffected. - */ - /** - * [ADR-0090 D5/D9] Reject binding a HIGH-PRIVILEGE permission set to an - * audience anchor (`everyone` / `guest`). The anchors are implicit for - * whole principal classes, so a dangerous binding here is an instant - * tenant-wide (or anonymous-wide) grant — the one shape the model must - * make unrepresentable rather than merely discouraged. - */ - private async assertAudienceAnchorBindingGate(opCtx: any): Promise { - if (opCtx?.object !== 'sys_position_permission_set') return; - if (!['insert', 'update'].includes(opCtx.operation)) return; - const rows = Array.isArray(opCtx.data) - ? opCtx.data - : (opCtx.data && typeof opCtx.data === 'object' ? [opCtx.data] : []); - if (rows.length === 0) return; - - const ql = this.ql; - // [#18535, ADR-0090 D5] The stack's declared `capabilities:`, read at most - // ONCE per gate pass and only once an anchor row is actually in play — the - // common write on this table names an ordinary position and must not pay - // for a read it does not use. Same source as the boot binding and as - // `confirmAudienceBindingSuggestion`, which is this gate's friendly early - // rendition: a second source there would let a confirm pass its own check - // and then be refused by the insert it performs. - let anchorContext: AnchorBindingContext | undefined; - let anchorContextLoaded = false; - const declaredCapabilityContext = async (): Promise => { - if (!anchorContextLoaded) { - anchorContext = await readDeclaredCapabilityContext(ql, this.metadata); - anchorContextLoaded = true; - } - return anchorContext; - }; - for (const row of rows) { - const positionId = (row as any)?.position_id; - if (!positionId || !ql?.find) continue; - let positionName = ''; - try { - const posRows = await ql.find('sys_position', { where: { id: positionId }, limit: 1, context: { isSystem: true } }); - positionName = String((Array.isArray(posRows) && posRows[0] ? (posRows[0] as any).name : '') ?? ''); - } catch { positionName = ''; } - if (positionName !== 'everyone' && positionName !== 'guest') continue; - - // Resolve the target set definition (bootstrap sets by name, else the - // sys_permission_set row itself carries the authored definition). - const setId = (row as any)?.permission_set_id; - let setName = ''; - let setDef: any = null; - try { - const setRows = await ql.find('sys_permission_set', { where: { id: setId }, limit: 1, context: { isSystem: true } }); - const sr: any = Array.isArray(setRows) && setRows[0] ? setRows[0] : null; - if (sr) { - setName = String(sr.name ?? ''); - setDef = sr; - } - } catch { /* fall through to bootstrap lookup below */ } - const boot = this.bootstrapPermissionSets.find((p) => p.name === setName); - // [ADR-0090 D9] Anchor-tier predicate: `guest` faces the strictest tier - // (additionally no edit bit — read-only by default, create is the single - // case-by-case write); `everyone` uses the high-privilege predicate. - // [#18535] `guest` faces the strictest tier and the predicate DROPS the - // context for it (D5's app-token excusal is the `everyone` tier's alone), - // so the same call serves both anchors. - const offending = describeAnchorForbiddenBits( - boot ?? setDef, - positionName as 'everyone' | 'guest', - await declaredCapabilityContext(), - ); - if (offending) { - throw new PermissionDeniedError( - `[Security] Access denied: permission set '${setName || setId}' cannot be bound to the '${positionName}' audience anchor — it carries ${offending} (ADR-0090 D5/D9). ` + - `Audience anchors accept low-privilege sets only; grant powerful sets through ordinary positions instead.`, - { operation: opCtx.operation, object: opCtx.object, position: positionName, permissionSet: setName || setId }, - ); - } - } - } - - private async assertPackageManagedWriteGate(opCtx: any): Promise { - if (opCtx?.object !== 'sys_permission_set') return; - const op = opCtx.operation; - if (!['insert', 'update', 'delete', 'transfer', 'restore', 'purge'].includes(op)) return; - - // (a) Reject any admin-door PAYLOAD that CLAIMS package provenance - // (`managed_by:'package'`), on insert OR update, single object OR array - // (`engine.insert`/`update` both accept `T | T[]` and route arrays - // through this same middleware). Only the package publish path — which - // carries `isSystem` and short-circuited the whole middleware above — - // may stamp package provenance. This also closes update-to-forge: an - // env row cannot be re-badged package-managed through the admin door. - const payloadRows = Array.isArray(opCtx.data) - ? opCtx.data - : (opCtx.data && typeof opCtx.data === 'object' ? [opCtx.data] : []); - if (payloadRows.some((r: unknown) => r && typeof r === 'object' && (r as Record).managed_by === 'package')) { - throw new PermissionDeniedError( - `[Security] Access denied: cannot set 'managed_by:package' on a permission set through the admin door — ` + - `package permission sets are authored in their package and land via publish (ADR-0086 two-doors).`, - { operation: op, object: opCtx.object }, - ); - } - if (op === 'insert') return; // no existing row to protect - - // [ADR-0094 D5-R] `update`/`delete` on a package-managed row are not - // refused HERE: the write-through middleware (which runs after this gate - // + the delegated-admin gate + the CRUD checks) translates them into a - // metadata write, and the refusal is LEFT TO THAT PRODUCER. Since commit - // ee58392e1 rolled `permission` back to `allowEnvironmentOverlay: false`, a - // CODE-DECLARED (artifact-backed) set is refused there with 403 - // `NOT_OVERRIDABLE`; a `sys_metadata`-backed set rides - // `allowRuntimeCreate` and still lands. The 2026-07-14 "customize / reset - // via the standard ADR-0005 layering" direction this comment used to cite - // is RETIRED. The lifecycle ops below have no metadata translation, so the - // package-row protection stays for them. - if (op === 'update' || op === 'delete') return; - - if (!this.ql) return; - - const targetId = this.extractSingleId(opCtx); - if (targetId == null) { - // Multi-row / filter write with no single id. Deny ONLY if a package-owned - // row actually falls within the write's own filter — so a bulk edit that - // targets only env-authored rows still succeeds (no over-broad block). A - // whole-table write (no filter) matches every package row, so it is denied. - const writeWhere = opCtx?.options?.where; - const packageWhere = writeWhere && typeof writeWhere === 'object' - ? { $and: [writeWhere, { managed_by: 'package' }] } - : { managed_by: 'package' }; - // [#7505] No `.catch(() => null)`: a fault here is not "no package row - // matched". Swallowed, it stood this guard down for the duration of a - // store outage and let the admin-door write through — the same collapse - // `readRowById` carried on the by-id branch just below, and this gate - // must not answer one way for a single id and the opposite for a filter. - // The fault propagates, so the write is refused and the caller sees the - // outage. - const hitsPackageRow = await this.ql - .findOne('sys_permission_set', { where: packageWhere, context: { isSystem: true } }); - if (hitsPackageRow) { - throw new PermissionDeniedError( - `[Security] Access denied: this '${op}' on 'sys_permission_set' targets one or more package-managed ` + - `rows — change those by editing their package and re-publishing, not through the admin door ` + - `(ADR-0086 two-doors separation).`, - { operation: op, object: opCtx.object }, - ); - } - return; - } - - const existing = await this.readRowById('sys_permission_set', targetId, { isSystem: true }); - if (existing && (existing as Record).managed_by === 'package') { - const row = existing as Record; - throw new PermissionDeniedError( - `[Security] Access denied: '${String(row.name ?? targetId)}' is a package-managed permission set ` + - `(managed_by:'package') — change it by editing its package and re-publishing, not through the ` + - `admin door (ADR-0086 two-doors separation).`, - { - operation: op, - object: opCtx.object, - recordId: targetId, - packageId: (row.package_id as string | null) ?? null, - }, - ); - } - } - - /** - * [ADR-0066 / #2918] Built-in row-write guardrail for the platform/application - * ASSET objects `sys_position` and `sys_capability`. - * - * ADR-0066's asset-ownership model splits authoring from assignment: WHAT a - * position or capability *is* is defined by the platform or an application - * package developer; a customer admin only decides WHO it is assigned to (via - * the RBAC link tables, which are governed separately by the delegated-admin - * gate). The `managed_by` provenance column on each object already records - * that ownership, but until now nothing ENFORCED it at the data layer — an - * admin could delete or rewrite a platform/package-managed row and silently - * break that app's authorization baseline (ADR-0049: a provenance attribute - * that exists but is never enforced is exactly the gap to close). - * - * This gate is an unconditional data-layer boundary, mirroring the - * `sys_permission_set` two-doors gate above: - * (a) The admin door may never FORGE managed provenance — stamping - * `managed_by` to a platform/package value on insert OR update (single - * object OR array) is refused; only the platform seeder / package - * publish path (which carries `isSystem` and short-circuited the whole - * middleware above) may author it. This also closes update-to-forge. - * (b) delete / update / transfer / restore / purge on a row whose EXISTING - * `managed_by` is platform/package-owned are refused — unlike - * `sys_permission_set`, these objects have NO ADR-0094 overlay - * write-through, so the mutation would otherwise go straight to the - * driver. - * (c) admin-authored rows (`managed_by` user/∅/admin) are untouched — the - * admin fully owns those (incl. a delegate's rows in their own subtree). - * (d) [#22360] ONE carve-out from (b): an `update` of a PACKAGE-provided - * `sys_position` row whose patch touches only ROW STATE passes — see - * {@link SecurityPlugin.PACKAGE_POSITION_ROW_STATE_COLUMNS}. Platform - * rows and `sys_capability` get no carve-out. - * Fails CLOSED and never depends on the caller's grants, so a superuser with - * modifyAllRecords cannot delete a platform position either. - */ - private async assertSystemRowWriteGate(opCtx: any): Promise { - const spec = SYSTEM_ROW_PROVENANCE[opCtx?.object as string]; - if (!spec) return; - const op = opCtx.operation; - if (!['insert', 'update', 'delete', 'transfer', 'restore', 'purge'].includes(op)) return; - - const managedValues = Object.keys(spec.managed); - - // (a) Reject any admin-door PAYLOAD that CLAIMS platform/package provenance, - // on insert OR update, single object OR array. Only the seeder / publish - // path (which carries `isSystem` and short-circuited above) may stamp it. - const payloadRows = Array.isArray(opCtx.data) - ? opCtx.data - : (opCtx.data && typeof opCtx.data === 'object' ? [opCtx.data] : []); - if ( - payloadRows.some( - (r: unknown) => - r && - typeof r === 'object' && - managedValues.includes(String((r as Record).managed_by ?? '')), - ) - ) { - throw new PermissionDeniedError( - `[Security] Access denied: cannot stamp a platform/package 'managed_by' value on a ${spec.noun} ` + - `through the admin door — ${spec.pluralNoun} provided by the platform or an application package are ` + - `authored there and land via seeding/publish, not through Setup (ADR-0066 asset ownership).`, - { operation: op, object: opCtx.object }, - ); - } - if (op === 'insert') return; // no existing row to protect - - if (!this.ql) return; - - // (d) Whether this write is the row-state-only update the carve-out admits. - const rowStateOnly = SecurityPlugin.isPackagePositionRowStatePatch(opCtx); - - const targetId = this.extractSingleId(opCtx); - if (targetId == null) { - // Multi-row / filter write with no single id. Deny ONLY if a managed row - // actually falls within the write's own filter — so a bulk edit that - // targets only admin-authored rows still succeeds (no over-broad block). A - // whole-table write (no filter) matches every managed row, so it is denied. - // - // (d) A row-state-only patch asks only about the rows the carve-out does - // not cover: package positions in the filter no longer refuse it, a - // platform position in the filter still does. - const guardedValues = rowStateOnly - ? managedValues.filter((v) => !SecurityPlugin.PACKAGE_POSITION_PROVENANCE.has(v)) - : managedValues; - const writeWhere = opCtx?.options?.where; - const managedWhere = - writeWhere && typeof writeWhere === 'object' - ? { $and: [writeWhere, { managed_by: { $in: guardedValues } }] } - : { managed_by: { $in: guardedValues } }; - // [#7505] No `.catch(() => null)` — see the sibling gate: a swallowed - // fault made this asset guard answer "nothing managed matched" and admit - // the write. Fail-closed: propagate. - const hitsManagedRow = await this.ql - .findOne(opCtx.object, { where: managedWhere, context: { isSystem: true } }); - if (hitsManagedRow) { - throw new PermissionDeniedError( - `[Security] Access denied: this '${op}' on '${opCtx.object}' targets one or more ${spec.pluralNoun} ` + - `provided by the platform or an application package — those cannot be deleted or modified through ` + - `the admin door (ADR-0066 asset ownership).`, - { operation: op, object: opCtx.object }, - ); - } - return; - } - - const existing = await this.readRowById(opCtx.object, targetId, { isSystem: true }); - const existingManagedBy = existing - ? String((existing as Record).managed_by ?? '') - : ''; - const ownerLabel = spec.managed[existingManagedBy]; - // (d) The carve-out: a row-state-only patch of a package-provided position. - if (existing && rowStateOnly && SecurityPlugin.PACKAGE_POSITION_PROVENANCE.has(existingManagedBy)) return; - if (existing && ownerLabel) { - const row = existing as Record; - const source = ownerLabel === 'the platform' ? 'platform definition' : 'application package'; - throw new PermissionDeniedError( - `[Security] Access denied: '${String(row.name ?? row.label ?? targetId)}' is a ${spec.noun} provided ` + - `by ${ownerLabel} — it cannot be deleted or modified through the admin door. Change it by editing ` + - `its ${source} and re-publishing (ADR-0066 asset ownership).`, - { operation: op, object: opCtx.object, recordId: targetId, managedBy: existingManagedBy }, - ); - } - } - - /** - * [#22360] The ROW-STATE columns of `sys_position`: the one named allowlist - * the carve-out (d) of {@link SecurityPlugin.assertSystemRowWriteGate} admits - * on a package-provided position. - * - * A package position row is locked the way a packaged permission set is. - * `permission-set-projection.ts`'s write door (#4669) passes a bare - * `{ active }` patch on a packaged set straight through: switching a packaged - * set off is not a customization of it. Under #15196's Q2 = A, - * `sys_position.active` stays row-authoritative, so switching a package's - * position off — or making it the default for new users — is the operator's - * lever on the row, not an edit of the package's definition. Neither column - * is a `PositionSchema` key. - * - * Everything else stays refused, with the gate's own code and message: - * a definition column (`name`, `label`, `description`, `delegatable`), any - * column not named here, `delete`, and `transfer` / `restore` / `purge`. - * `delegatable` is deliberately NOT row state: it is declared on the position - * definition, and the triage ruling locks it ahead of #15196's S8b reader - * switch. ⛔ Widening this set is a ruling, not an edit. - */ - private static readonly PACKAGE_POSITION_ROW_STATE_COLUMNS: ReadonlySet = new Set(['active', 'is_default']); - - /** - * [#22360] The `sys_position` provenance values carve-out (d) applies to: - * `package`, and `config`, its legacy pre-A4 spelling, which - * `SYSTEM_ROW_PROVENANCE` guards as the same owner ("an application - * package") — the two vocabularies are kept in lockstep there, so a legacy - * row the boot normalizer has not healed yet answers as its canonical twin. - * ⛔ Never `platform` / `system`: switching off a built-in such as - * `everyone` is not an operator lever. - */ - private static readonly PACKAGE_POSITION_PROVENANCE: ReadonlySet = new Set(['package', 'config']); - - /** - * [#22360] Is this the write carve-out (d) admits: an `update` of - * `sys_position` whose patch is ONE object naming at least one row-state - * column and nothing else? `id` is the row's identifier (the single-id - * branch reads it from the payload), not a column the patch changes. An - * array payload is never admitted here: its rows are judged by the gate - * exactly as before. - */ - private static isPackagePositionRowStatePatch(opCtx: any): boolean { - if (opCtx?.object !== 'sys_position' || opCtx?.operation !== 'update') return false; - const patch = opCtx.data; - if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return false; - const columns = Object.keys(patch).filter((k) => k !== 'id'); - return columns.length > 0 - && columns.every((k) => SecurityPlugin.PACKAGE_POSITION_ROW_STATE_COLUMNS.has(k)); - } - - /** - * [#8552 / ADR-0066 D1] Curated-name authoring refusal for `sys_capability`. - * - * A name in `PLATFORM_CAPABILITY_NAMES` is a capability the PLATFORM - * defines: grants (`systemPermissions`) and requirements - * (`requiredPermissions`) resolve it by name, and its definition is served - * by the registry (`builtin-capabilities.ts`, ADR-0131 D3), not by a row. A - * Setup-authored row claiming such a name is a declaration the platform - * cannot honour — it can never BE the curated capability (provenance - * forging is refused by the gate above). Maintainer ruling on - * #8552 (2026-08-13): refuse the collision AT THE WRITE DOOR — create and - * rename both — naming the colliding curated name; never answer 200. - * - * What is refused, and exactly that: - * - `insert` whose payload names a curated capability (every row of an - * array payload); - * - `update` that RENAMES a row TO a curated name. A payload repeating - * the row's OWN existing curated name is not a rename and passes: the - * ruling deliberately leaves pre-existing colliding rows to the - * operator, so the operator's remediation on one (edit the label, - * deactivate, rename AWAY) must stay open, and a full-record PATCH from - * Setup echoes `name` unchanged. The pre-image is read under a system - * context to establish that fact; per the #7505 ruling the read fails - * CLOSED — an engine fault propagates, and an absent pre-image refuses, - * because a write claiming a curated name for a row that cannot be - * shown to already hold it is exactly the state this gate prevents. - * - a FILTER update stamping a curated name is allowed only when the - * filter provably matches nothing but rows already holding that name - * (`$ne` probe, the sibling gate's bulk-check shape); no filter at all - * is a whole-table stamp and is refused. - * - * Deliberately NOT covered: `restore` of a soft-deleted row that already - * held a curated name re-establishes a pre-existing collision rather than - * minting a new one — that residue is the operator's (option 1 of the - * ruling), not this gate's. Creating NON-curated names stays open — the - * legitimate half of both measured flows (community NULL-bucket `admin` - * rows, package declarations), and the seeders themselves carry `isSystem` - * and short-circuit the middleware entirely. - */ - private async assertCuratedCapabilityNameGate(opCtx: any): Promise { - if (opCtx?.object !== 'sys_capability') return; - const op = opCtx.operation; - if (op !== 'insert' && op !== 'update') return; - - const payloadRows: unknown[] = Array.isArray(opCtx.data) - ? opCtx.data - : opCtx.data && typeof opCtx.data === 'object' - ? [opCtx.data] - : []; - - const curatedNameOf = (r: unknown): string | null => { - if (!r || typeof r !== 'object') return null; - const name = (r as Record).name; - return typeof name === 'string' && PLATFORM_CAPABILITY_NAMES.has(name) ? name : null; - }; - - const refuse = (name: string): never => { - throw new PermissionDeniedError( - `[Security] Access denied: '${name}' is a platform-curated capability name — a sys_capability ` + - `row cannot be created with it or renamed to it through the admin door. The platform defines ` + - `this capability and seeds its own row for it; grants and requiredPermissions already resolve ` + - `the name. A curated name is refused at authoring so that no admin-authored row can collide ` + - `with the row the platform seeds for it. Choose a different capability name (ADR-0066 asset ` + - `ownership).`, - { operation: op, object: opCtx.object, name, curated: true }, - ); - }; - - if (op === 'insert') { - for (const r of payloadRows) { - const name = curatedNameOf(r); - if (name != null) refuse(name); - } - return; - } - - // update — refuse a rename TO a curated name. - const isScalarId = (v: unknown): v is string | number | bigint => - v !== null && (typeof v === 'string' || typeof v === 'number' || typeof v === 'bigint'); - for (const r of payloadRows) { - const name = curatedNameOf(r); - if (name == null) continue; - const ownId = (r as Record).id; - const rowId = isScalarId(ownId) ? ownId : this.extractSingleId(opCtx); - if (rowId != null) { - if (!this.ql) refuse(name); - // [#7505] Fail-closed: a probe fault propagates; `null` means absent. - const existing = await this.readRowById('sys_capability', rowId, { isSystem: true }); - if (existing && String((existing as Record).name ?? '') === name) continue; - refuse(name); - } - const writeWhere = opCtx?.options?.where; - if (!writeWhere || typeof writeWhere !== 'object' || !this.ql) refuse(name); - // [#7505] No swallow here either — a probe fault propagates (fail closed). - const renamesOtherRow = await this.ql.findOne('sys_capability', { - where: { $and: [writeWhere, { name: { $ne: name } }] }, - context: { isSystem: true }, - }); - if (renamesOtherRow) refuse(name); - } - } - /** * [commit 6feac910b] Does this object declare that its access — writes included — derives * from a master record (ADR-0055 `controlled_by_parent`)? @@ -7455,11 +6903,10 @@ export class SecurityPlugin implements Plugin { * - `assertControlledByParentWrite` answered `404 RECORD_NOT_FOUND` — an * SDK treats that as TERMINAL (drop the id, do not retry) when the * truthful answer was a transient outage it should have backed off on; - * - the two admin-door provenance gates - * (`assertPackageManagedWriteGate`, `assertSystemRowWriteGate`) read - * `null` as "this row is not package/platform-managed" and let the write - * THROUGH — fail-OPEN, the whole guard silently stood down for the - * duration of a store fault. + * - the two admin-door provenance gates on the catalog row objects + * (retired with those objects, ADR-0131 D3) read `null` as "this row is + * not package/platform-managed" and let the write THROUGH — fail-OPEN, + * the whole guard silently stood down for the duration of a store fault. * * Maintainer ruling of 2026-08-11 on #7505: FAIL-CLOSED. An engine fault * PROPAGATES out of this probe instead of being flattened, so the write is diff --git a/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts b/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts index e4653c0fc2f..a0772ee0a81 100644 --- a/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts +++ b/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts @@ -16,7 +16,9 @@ // • the two admin-door PROVENANCE gates read `null` as "this row is not // package/platform-managed" and let the write THROUGH. That is fail-OPEN: // for the duration of a store fault, ADR-0086's two-doors boundary and -// ADR-0066's asset-ownership boundary both silently stood down; +// ADR-0066's asset-ownership boundary both silently stood down (both +// gates retired with the catalog row objects, ADR-0131 D3, and their legs +// here with them); // • `getCallerPreImage`'s owner-echo consumer caught the throw and answered // `403 changing record ownership` — fail-closed, but with a sentence that // accuses the caller of something they did not do and an envelope an SDK @@ -102,22 +104,6 @@ const ADMIN_SET: PermissionSet = { }, } as unknown as PermissionSet; -/** - * `ADMIN_SET` plus the tenant-admin wildcard. Needed only for - * `sys_permission_set`: the ADR-0090 D12 delegated-admin gate runs a few lines - * AFTER the two-doors gate and refuses RBAC-table writes from a principal with - * mere CRUD grants, so without this the "admitted" half of a steady-state pair - * would be measuring that gate instead of this one. It carries - * `modifyAllRecords`, which is exactly why the two-doors boundary is placed - * ahead of the CRUD check — a superuser does not get to forge provenance - * either. - */ -const TENANT_ADMIN_SET: PermissionSet = { - name: 'tenant_admin', - label: 'Tenant Admin', - objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true } }, -} as unknown as PermissionSet; - interface BootOptions { rows?: Record; /** `findOne` on this object throws a datasource outage. */ @@ -230,226 +216,6 @@ function expectPropagatedOutage(err: any): void { // --------------------------------------------------------------------------- -describe('[#7505] assertPackageManagedWriteGate — the two-doors boundary under a store fault', () => { - const rows = () => ({ - sys_permission_set: [ - { id: 'ps_pkg', name: 'Package Set', managed_by: 'package' }, - { id: 'ps_env', name: 'Env Set', managed_by: 'admin' }, - ], - }); - // `update` / `delete` defer to the ADR-0094 write-through, so the gate's own - // row protection is reachable on the lifecycle verbs only. Of those, - // `transfer` is the one a caller can still be GRANTED since #12497 - // (`restore`/`purge` lost their bits and their evaluator rows, so the - // object-level gate downstream refuses them unconditionally and an - // "admitted" leg could never resolve) — the two-doors boundary this suite - // measures is verb-agnostic, so the probe verb moved from `purge` to - // `transfer` with nothing else changing. - const lifecycleWrite = (where: Row) => ({ object: 'sys_permission_set', operation: 'transfer', options: { where } }); - // See TENANT_ADMIN_SET: this table's writes pass a second, later gate. - const bootPkg = (o: BootOptions = {}) => boot({ sets: [TENANT_ADMIN_SET], ...o }); - - it('STEADY STATE: a package-managed row is refused, an admin-authored row is admitted', async () => { - const h = await bootPkg({ rows: rows() }); - const denied = await refusalOf(h.write(lifecycleWrite({ id: 'ps_pkg' }))); - expect(denied.code).toBe('PERMISSION_DENIED'); - expect(denied.message).toContain('package-managed permission set'); - await expect(h.write(lifecycleWrite({ id: 'ps_env' }))).resolves.toBe('admitted'); - }); - - it('FAULT (by id): the outage propagates — it does NOT read as "no package row here"', async () => { - // The measured fail-open. Before the fix this resolved: the probe threw, - // `readRowById` answered `null`, the gate concluded the row was not - // package-managed, and the purge went through — against the very row the - // steady-state case above proves is protected. - const h = await bootPkg({ rows: rows(), faultOn: 'sys_permission_set' }); - expectPropagatedOutage(await refusalOf(h.write(lifecycleWrite({ id: 'ps_pkg' })))); - }); - - it('FAULT (bulk filter): the same answer — one gate cannot answer two ways for one outage', async () => { - // The sibling branch, one `if` above: a filter write with no single id asks - // the same question of the same store with `.findOne`, and used to swallow - // the same fault with `.catch(() => null)`. - const h = await bootPkg({ rows: rows(), faultOn: 'sys_permission_set' }); - expectPropagatedOutage(await refusalOf(h.write(lifecycleWrite({ name: 'Package Set' })))); - }); - - it('STEADY STATE (bulk filter): a filter that hits no package row is still admitted', async () => { - // Fail-closed must not become "refuse everything": the bulk branch exists - // precisely so a bulk edit that touches only env-authored rows succeeds. - const h = await bootPkg({ rows: rows() }); - await expect(h.write(lifecycleWrite({ managed_by: 'admin' }))).resolves.toBe('admitted'); - }); -}); - -describe('[#7505] assertSystemRowWriteGate — ADR-0066 asset ownership under a store fault', () => { - const rows = () => ({ - sys_position: [ - { id: 'pos_platform', name: 'CEO', managed_by: 'platform' }, - { id: 'pos_admin', name: 'Regional Lead', managed_by: 'admin' }, - ], - }); - const del = (where: Row) => ({ object: 'sys_position', operation: 'delete', options: { where } }); - - it('STEADY STATE: a platform-provided position is refused, an admin-authored one is admitted', async () => { - const h = await boot({ rows: rows() }); - const denied = await refusalOf(h.write(del({ id: 'pos_platform' }))); - expect(denied.code).toBe('PERMISSION_DENIED'); - expect(denied.message).toContain('provided by the platform'); - await expect(h.write(del({ id: 'pos_admin' }))).resolves.toBe('admitted'); - }); - - it('FAULT (by id): the outage propagates instead of admitting the delete', async () => { - const h = await boot({ rows: rows(), faultOn: 'sys_position' }); - expectPropagatedOutage(await refusalOf(h.write(del({ id: 'pos_platform' })))); - }); - - it('FAULT (bulk filter): the outage propagates there too', async () => { - const h = await boot({ rows: rows(), faultOn: 'sys_position' }); - expectPropagatedOutage(await refusalOf(h.write(del({ name: 'CEO' })))); - }); -}); - -/** - * [#22360] The same gate, in steady state, on a PACKAGE-provided `sys_position` - * row: its definition is locked, its row state is not. - * - * The declared-position seeder stamps `managed_by: 'package'` on the row of a - * position a code package holds, so this gate now protects that row. A package - * position row is locked the way a packaged permission set is - * (`permission-set-projection.ts`, #4669): a patch touching ONLY row state - * (`active`, `is_default`) passes, because switching a package's position off, - * or making it the default for new users, is not an edit of its definition. - * Everything else stays refused with the gate's own code: a definition column - * (`name`, `label`, `description`, `delegatable`), any other column, a delete - * and the other lifecycle verbs. Platform (built-in) rows and `sys_capability` - * get no carve-out. Housed here, beside the #7505 cases of the same gate, so the - * suite's one store double serves both (no second double to ledger). - */ -describe('[#22360] assertSystemRowWriteGate — a package position row: row state switches, the definition stays locked', () => { - const rows = () => ({ - sys_position: [ - { id: 'pos_pkg_a', name: 'field_rep', label: 'Field Rep', managed_by: 'package', active: true }, - { id: 'pos_pkg_b', name: 'auditor', label: 'Auditor', managed_by: 'package', active: true }, - { id: 'pos_legacy', name: 'legacy_lead', label: 'Legacy Lead', managed_by: 'config', active: true }, - { id: 'pos_platform', name: 'everyone', label: 'Everyone', managed_by: 'platform', active: true }, - { id: 'pos_admin', name: 'regional_lead', label: 'Regional Lead', managed_by: 'admin', active: true }, - ], - sys_capability: [ - { id: 'cap_pkg', name: 'pkg_cap', label: 'Package Capability', managed_by: 'package', active: true }, - ], - }); - const update = (object: string, data: unknown, where: Row = {}) => ({ object, operation: 'update', data, options: { where } }); - const byId = (id: string, patch: Row) => update('sys_position', { id, ...patch }, { id }); - const PKG_NAMES = { name: { $in: ['field_rep', 'auditor'] } }; - /** The refusal's code and status — the gate's existing envelope, unchanged by the carve-out. */ - const refused = async (run: Promise) => { - const err = await refusalOf(run); - return { code: err.code, status: err.status ?? err.statusCode, message: String(err.message) }; - }; - const REFUSED = { code: 'PERMISSION_DENIED', status: 403 }; - - it('a row-state-only patch passes: deactivate, activate, Set as Default, both together', async () => { - const h = await boot({ rows: rows() }); - await expect(h.write(byId('pos_pkg_a', { active: false }))).resolves.toBe('admitted'); - await expect(h.write(byId('pos_pkg_a', { active: true }))).resolves.toBe('admitted'); - await expect(h.write(byId('pos_pkg_a', { is_default: true }))).resolves.toBe('admitted'); - await expect(h.write(byId('pos_pkg_a', { active: false, is_default: false }))).resolves.toBe('admitted'); - // The id carried only by the filter, as the engine's by-id update spells it. - await expect(h.write(update('sys_position', { active: false }, { id: 'pos_pkg_a' }))).resolves.toBe('admitted'); - }); - - it('a definition column, any other column, or a mixed patch is refused with the gate\'s code', async () => { - const h = await boot({ rows: rows() }); - for (const patch of [ - { label: 'Field Rep (edited)' }, - { description: 'edited' }, - { delegatable: true }, - { name: 'field_rep_two' }, - { active: false, label: 'Field Rep (edited)' }, - { is_default: true, delegatable: true }, - { active: false, notes: 'not a row-state column' }, - ]) { - const r = await refused(h.write(byId('pos_pkg_a', patch))); - expect(r, JSON.stringify(patch)).toMatchObject(REFUSED); - expect(r.message).toContain('provided by an application package'); - } - }); - - it('an empty patch admits nothing new: it is refused as before', async () => { - const h = await boot({ rows: rows() }); - expect(await refused(h.write(byId('pos_pkg_a', {})))).toMatchObject(REFUSED); - }); - - it('delete and the other lifecycle verbs stay refused', async () => { - const h = await boot({ rows: rows() }); - for (const operation of ['delete', 'transfer', 'restore', 'purge']) { - const r = await refused(h.write({ object: 'sys_position', operation, options: { where: { id: 'pos_pkg_a' } } })); - expect(r, operation).toMatchObject(REFUSED); - } - }); - - it('the legacy `config` spelling of package provenance answers as its canonical twin', async () => { - const h = await boot({ rows: rows() }); - await expect(h.write(byId('pos_legacy', { active: false }))).resolves.toBe('admitted'); - expect(await refused(h.write(byId('pos_legacy', { label: 'edited' })))).toMatchObject(REFUSED); - }); - - it('control: a built-in\'s bare { active } patch stays refused', async () => { - const h = await boot({ rows: rows() }); - const r = await refused(h.write(byId('pos_platform', { active: false }))); - expect(r).toMatchObject(REFUSED); - expect(r.message).toContain('provided by the platform'); - expect(await refused(h.write(byId('pos_platform', { is_default: true })))).toMatchObject(REFUSED); - }); - - it('control: sys_capability gets no carve-out', async () => { - const h = await boot({ rows: rows() }); - expect(await refused(h.write(update('sys_capability', { id: 'cap_pkg', active: false }, { id: 'cap_pkg' })))) - .toMatchObject(REFUSED); - }); - - it('control: an administrator-authored position stays fully editable', async () => { - const h = await boot({ rows: rows() }); - await expect(h.write(byId('pos_admin', { label: 'Regional Lead (edited)', delegatable: true }))).resolves.toBe('admitted'); - }); - - it('control: forging provenance in a row-state patch is still refused (a)', async () => { - const h = await boot({ rows: rows() }); - const r = await refused(h.write(byId('pos_admin', { active: false, managed_by: 'package' }))); - expect(r).toMatchObject(REFUSED); - expect(r.message).toContain('cannot stamp'); - }); - - it('filtered: a row-state-only patch over package rows passes', async () => { - const h = await boot({ rows: rows() }); - await expect(h.write(update('sys_position', { active: false }, PKG_NAMES))).resolves.toBe('admitted'); - await expect(h.write(update('sys_position', { is_default: false }, PKG_NAMES))).resolves.toBe('admitted'); - }); - - it('filtered: a label patch over a package row is refused', async () => { - const h = await boot({ rows: rows() }); - const r = await refused(h.write(update('sys_position', { label: 'Renamed' }, PKG_NAMES))); - expect(r).toMatchObject(REFUSED); - expect(r.message).toContain('provided by the platform or an application package'); - }); - - it('filtered: a row-state-only patch whose filter also reaches a built-in is refused', async () => { - const h = await boot({ rows: rows() }); - expect(await refused(h.write(update('sys_position', { active: false }, { name: { $in: ['field_rep', 'everyone'] } })))) - .toMatchObject(REFUSED); - // A whole-table write matches every built-in. - expect(await refused(h.write({ object: 'sys_position', operation: 'update', data: { active: false }, options: {} }))) - .toMatchObject(REFUSED); - }); - - it('control (filtered): a delete over package rows stays refused', async () => { - const h = await boot({ rows: rows() }); - expect(await refused(h.write({ object: 'sys_position', operation: 'delete', options: { where: PKG_NAMES } }))) - .toMatchObject(REFUSED); - }); -}); - describe('[#7505] getCallerPreImage — the owner-anchor echo under a store fault', () => { const rows = () => ({ crm_task: [ diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index d7314fdec3e..834fe5e27cb 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -84,7 +84,7 @@ export const enObjects: NonNullable = { sys_user_position: { label: "User Position", pluralLabel: "User Positions", - description: "Assigns a position (sys_position.name) to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).", + description: "Assigns a position, by name, to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).", fields: { id: { label: "Assignment ID", @@ -100,7 +100,7 @@ export const enObjects: NonNullable = { }, position: { label: "Position", - help: "Position machine name (references sys_position.name)." + help: "Position machine name, resolved in the security catalog." }, business_unit_id: { label: "Business Unit", diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 1434a33c7e5..daadd6f3961 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -84,7 +84,7 @@ export const esESObjects: NonNullable = { sys_user_position: { label: "Puesto de usuario", pluralLabel: "Puestos de usuario", - description: "Asigna un puesto (sys_position.name) a un usuario. Gestionado por la plataforma (ADR-0057 D4, ADR-0090 D3).", + description: "Asigna un puesto, por nombre, a un usuario. Gestionado por la plataforma (ADR-0057 D4, ADR-0090 D3).", fields: { id: { label: "ID de asignación", @@ -100,7 +100,7 @@ export const esESObjects: NonNullable = { }, position: { label: "Puesto", - help: "Nombre de máquina del puesto (referencia a sys_position.name)." + help: "Nombre de máquina del puesto, resuelto en el catálogo de seguridad." }, business_unit_id: { label: "Unidad de negocio", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index fc0b17d2c6b..1ed611dbadd 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -84,7 +84,7 @@ export const jaJPObjects: NonNullable = { sys_user_position: { label: "ユーザーポジション", pluralLabel: "ユーザーポジション", - description: "ポジション(sys_position.name)をユーザーに割り当てます。プラットフォーム管理(ADR-0057 D4、ADR-0090 D3)。", + description: "ポジションを名前でユーザーに割り当てます。プラットフォーム管理(ADR-0057 D4、ADR-0090 D3)。", fields: { id: { label: "割り当て ID", @@ -100,7 +100,7 @@ export const jaJPObjects: NonNullable = { }, position: { label: "ポジション", - help: "ポジションのマシン名(sys_position.name を参照)。" + help: "ポジションのマシン名(セキュリティカタログで解決)。" }, business_unit_id: { label: "ビジネスユニット", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index 1488d2650b4..a8e4c79921b 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -84,7 +84,7 @@ export const zhCNObjects: NonNullable = { sys_user_position: { label: "用户岗位", pluralLabel: "用户岗位", - description: "将岗位(sys_position.name)分配给用户。由平台管理(ADR-0057 D4、ADR-0090 D3)。", + description: "按名称将岗位分配给用户。由平台管理(ADR-0057 D4、ADR-0090 D3)。", fields: { id: { label: "分配 ID", @@ -100,7 +100,7 @@ export const zhCNObjects: NonNullable = { }, position: { label: "岗位", - help: "岗位机器名(引用 sys_position.name)。" + help: "岗位机器名(在安全目录中解析)。" }, business_unit_id: { label: "业务单元", diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.ts deleted file mode 100644 index d10cc2a76ee..00000000000 --- a/packages/plugins/plugin-security/src/walled-catalog-data-door.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0131 D3] The walled catalog refusal at the DATA door: a non-system - * insert, update or delete on `sys_position`, `sys_permission_set` or - * `sys_capability`, under a posture that enforces an organization wall, by a - * caller who is not the platform operator, is refused `403 PERMISSION_DENIED`. - * - * ## Why the data door needs it - * - * ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins - * cannot create or edit catalog items … Creation through Setup or the API is - * refused". Setup writes the catalog through these three objects. Under a wall - * the stock organization administrator is already refused there by its own - * grants (the anti-escalation deny in `objects/default-permission-sets.ts`), - * but any other organization-scoped principal whose sets admit the table was - * not: - * - * - a `sys_permission_set` write is redirected into the environment ledger - * (`createPermissionSetWriteThrough`, ADR-0094 D3) in every posture, so an - * organization-scoped principal created an ENVIRONMENT permission set every - * organization resolves by name — measured on a booted walled stack; - * - a `sys_position` write landed as a row of the writer's organization, a - * position with no registry home (the `single`-only write-through stands - * down under a wall) — measured in this package's harness. - * - * The verdict and its sentence are the metadata door's own - * (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`), so the data - * door and the `/meta` doors refuse the same callers with the same words. - * - * ## Where it runs - * - * Registered AFTER the security middleware, so it runs INSIDE it: a caller who - * may not write the table at all keeps its CRUD refusal and learns nothing - * here. Registered BEFORE the permission-set write-through, so a refused write - * is never translated into a metadata save. - * - * ## What it does not judge - * - * - **Assignments.** `sys_user_position` and `sys_user_permission_set` rows - * are organization rows in every posture (ADR-0131 D3); an organization's - * administrators keep assigning the catalog to their own members. - * - **System writes** (`isSystem`): the seeders, the package door, migrations. - * - **The platform operator** (the ADR-0095 `PLATFORM_ADMIN` rung on the - * context). A hand-built context that carries no rung is not the operator. - * - **`single`**: inert; the organization is the environment. - */ - -import { walledCatalogWriteVerdict } from '@objectstack/metadata-core'; -import type { TenancyPosture } from '@objectstack/spec/security'; -import { PermissionDeniedError } from './errors.js'; - -/** The catalog objects this guard judges, and the metadata type each one writes. */ -export const WALLED_CATALOG_OBJECT_TYPES: Readonly> = Object.freeze({ - sys_position: 'position', - sys_permission_set: 'permission', - sys_capability: 'capability', -}); - -/** The engine operations that change a catalog row. */ -const WRITE_OPERATIONS: ReadonlySet = new Set(['insert', 'update', 'delete']); - -export interface WalledCatalogDataDoorDeps { - /** The deployment's effective tenancy posture, as the security plugin resolved it. */ - getPosture: () => TenancyPosture | undefined; -} - -/** The engine middleware. Registered once per catalog object (see {@link WALLED_CATALOG_OBJECT_TYPES}). */ -export function createWalledCatalogDataDoorRefusal( - deps: WalledCatalogDataDoorDeps, -): (opCtx: any, next: () => Promise) => Promise { - return async (opCtx: any, next: () => Promise): Promise => { - const object = opCtx?.object; - const type = typeof object === 'string' ? WALLED_CATALOG_OBJECT_TYPES[object] : undefined; - if (!type || !WRITE_OPERATIONS.has(opCtx?.operation)) return next(); - const context = opCtx?.context ?? {}; - const verdict = walledCatalogWriteVerdict({ - type, - tenancyPosture: deps.getPosture(), - isSystem: context.isSystem === true, - callerPosture: context.posture, - }); - if (!verdict.allowed) { - throw new PermissionDeniedError(verdict.message, { operation: opCtx.operation, object }); - } - return next(); - }; -} diff --git a/packages/plugins/plugin-security/src/write-refusals.ts b/packages/plugins/plugin-security/src/write-refusals.ts index 2430dfda0e6..b9017d24f04 100644 --- a/packages/plugins/plugin-security/src/write-refusals.ts +++ b/packages/plugins/plugin-security/src/write-refusals.ts @@ -93,8 +93,7 @@ export function logSeedDurabilityFailure( meta?: Record, ): void { // No single cause: this line summarises N refusals, so the cause slot is - // `undefined` and the detail travels in meta — the same shape the sibling - // reconcile summary in `permission-set-projection.ts` uses. + // `undefined` and the detail travels in meta. if (logger?.error) logger.error(message, undefined, meta); else logger?.warn?.(message, meta); } diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 3b86f89cc43..66b0adb1244 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -238,12 +238,11 @@ export const SysSharingRule = ObjectSchema.create({ }), // ── Provenance (#2909 P0 — record-authoritative seed-not-clobber) ── - // Unified A4 (#2920) tri-state, shared verbatim with sys_position / - // sys_capability / sys_permission_set. Both columns are `readonly`: + // Unified A4 (#2920) tri-state, the vocabulary the retired security + // catalog rows shared. Both columns are `readonly`: // the engine strips them from non-system payloads (forge/clear-proof), // while the seeder and the provenance stamp hook write with isSystem. - // NOTE deliberately NOT in SYSTEM_ROW_PROVENANCE (no write gate): - // sharing rules are a first-class admin authoring/tuning surface — + // NOTE deliberately no provenance write gate: sharing rules are a first-class admin authoring/tuning surface — // admins may edit or deactivate package rules; the seeder simply stops // overwriting rows once `customized` is stamped (ADR-0094 addendum). managed_by: Field.select({ diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts b/packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts index c4ce3200947..863d66bedc0 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts @@ -13,8 +13,8 @@ * Why a data hook (and not a write gate or the REST layer): * - admins edit rules through several doors (Setup UI generic data door, * scripts, console) — an engine hook covers them all; - * - unlike sys_position/sys_capability there is deliberately NO - * SYSTEM_ROW_PROVENANCE write gate here: sharing rules are a first-class + * - there is deliberately NO provenance write gate here (the retired + * security catalog rows had one): sharing rules are a first-class * admin authoring surface, so edits are allowed — they just have to be * remembered; * - both provenance columns are `readonly`, and the engine's readonly strip diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 373a846df7c..2f3b54934ac 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1264,32 +1264,6 @@ export const ERROR_CODE_LEDGER = { 'NOT_OVERRIDABLE', 'SUGGESTION_NOT_FOUND', 'SUGGESTION_STATE', // suggestion exists but is not in a confirmable/dismissable state - // [commit 8f6d83147] The data door's duplicate-name refusal on `sys_permission_set` - // — `PermissionSetNameConflictError` (`errors.ts`), thrown by the - // ADR-0094 D3 write-through middleware's insert leg - // (`permission-set-projection.ts`) when a set with that machine name - // already exists. `code` / `status` via the package's exported - // `PERMISSION_SET_NAME_CONFLICT_CODE` / `PERMISSION_SET_NAME_CONFLICT_STATUS`. - // - // THIRD EMITTER of a code `@objectstack/rest` (SQL conflict) and - // `@objectstack/driver-memory` (in-memory uniqueness refusal) already - // register, and the wire identity is deliberately the SAME for the reason - // their rows give: this object declares `{ fields: ['name'], unique: - // 'organization' }`, so the very same collision answers `409 - // UNIQUE_VIOLATION` when the index catches it instead of this pre-check - // (the reading recorded on that index, #8554). A second spelling here - // would make one condition answer two envelopes depending only on which - // layer got there first. Per this file's header, a code emitted by - // several packages is listed once per emitting package — provenance, not - // identity; the union, its casing and every other package's rows are - // unchanged. - // - // Wire-reachable by the test the "Retiring a code" section applies - // (#8035), measured live on `examples/app-showcase` over a cookie session: - // `POST /api/v1/data/sys_permission_set {"name":""}` - // answers `409` with this code on the flat `{ error, code }` responder - // (`mapDataError`'s declared-status 4xx arm, `thrownCodeFields`). - 'UNIQUE_VIOLATION', ], '@objectstack/plugin-webhooks': [ // [#13353] The redeliver endpoint's malformed-body refusal — the plugin diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index ef0dc2a2b94..566248caa3c 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -794,7 +794,7 @@ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ }).optional().describe( 'Outcome of the awaited ADR-0094 mutation projector — the post-persist step ' + 'that materializes this metadata into its derived data-plane read model ' - + '(e.g. `permission` → `sys_permission_set`). Present ONLY when a projector ' + + '(e.g. `datasource` → its live connection pool). Present ONLY when a projector ' + 'is registered for this metadata type, which is why it is optional: its ' + 'absence means "no projector ran", never "the projection failed". ' + 'Best-effort by design — a projector failure is reported here and logged, ' @@ -951,7 +951,8 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ * - `seedApplied` — only when the published type is `seed`. Publishing a seed * is what makes its rows live, so the row materialization rides along. * - `materializeApplied` — only when an ADR-0086 P2 publish materializer is - * registered for the type (e.g. `permission` → `sys_permission_set`). + * registered for the type (e.g. `flow`, whose draft credentials become the + * live ones). * - `projectionApplied` — only when an ADR-0094 mutation projector is * registered for the type. Same field, same meaning, as on * {@link SaveMetaItemResponseSchema}: both write doors run the projector. @@ -1023,8 +1024,8 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ error: z.string().optional().describe('Materializer failure message, present only when `success` is false.'), }).optional().describe( 'Outcome of the ADR-0086 P2 publish-time materializer — the step that ' - + 'projects the published body into its data-plane row (e.g. `permission` ' - + '→ `sys_permission_set`, under the owning package). Present ONLY when a ' + + 'applies the published body to its data-plane state (e.g. `flow`, whose ' + + 'draft credentials become the live ones). Present ONLY when a ' + 'materializer is registered for this metadata type, which is why it is ' + 'optional: its absence means "no materializer ran", never "it failed". ' + 'Best-effort, same contract as `seedApplied`.', @@ -1282,7 +1283,7 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ ), }).optional().describe( 'ADR-0086 P2 — aggregate result of publish-time materializers across ' - + 'the batch (e.g. `permission` → `sys_permission_set`), including ' + + 'the batch (e.g. `flow`, whose draft credentials become the live ones), including ' + 'side-effect failures surfaced by the per-item effects loop. Present ' + 'ONLY when at least one published item had a registered materializer ' + 'or a side-effect failure. Best-effort, same contract as ' diff --git a/packages/spec/src/identity/eval-user.zod.ts b/packages/spec/src/identity/eval-user.zod.ts index f0ae93559bd..1d89c3c3e21 100644 --- a/packages/spec/src/identity/eval-user.zod.ts +++ b/packages/spec/src/identity/eval-user.zod.ts @@ -21,7 +21,7 @@ import type { PermissionSet } from '../security/permission.zod'; * ## Which axis `positions` is — and which it is not (#15136) * * It is the **security** axis: the built-in identity names plus the - * `sys_position` names the principal actually holds through `sys_user_position` + * position names the principal actually holds through `sys_user_position` * (ADR-0057 D4) and their active membership, plus the `everyone` anchor * (ADR-0090 D5) — exactly the set `/auth/me/permissions` reports and * `resolveUserAuthzGrants` resolves. Every surface derives it from that one @@ -192,7 +192,7 @@ export const ORGANIZATION_ADMIN_GRANTS: readonly string[] = [ ORGANIZATION_ADMIN_NO_BYPASS, ] as const; -/** Human-readable metadata for the built-in identity names (seeded into `sys_position`; AI grounding). */ +/** Human-readable metadata for the built-in identity names (declared as positions in the security catalog; AI grounding). */ export const BUILTIN_IDENTITY_METADATA: Record = { [BUILTIN_IDENTITY_PLATFORM_ADMIN]: { label: 'Platform Admin', description: 'Platform operator (SaaS admin). NOT a tenant user role.' }, [BUILTIN_IDENTITY_ORG_OWNER]: { label: 'Organization Owner', description: 'Organization owner within a tenant.' }, @@ -225,7 +225,7 @@ export const EvalUserSchema = lazySchema(() => * position names. The SECURITY axis (#15136) — never the better-auth * `sys_user.role` scalar, which stays published as `user.role`. */ - positions: z.array(z.string()).default([]).describe('Canonical position/identity names the user holds — built-in identity names plus sys_position assignments, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role'), + positions: z.array(z.string()).default([]).describe('Canonical position/identity names the user holds — built-in identity names plus the positions assigned through sys_user_position, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role'), /** * The `PLATFORM_ADMIN` standing of ADR-0095 D3: true when the platform * resolves the user, per request, to the `PLATFORM_ADMIN` posture rung — diff --git a/packages/spec/src/identity/position.zod.ts b/packages/spec/src/identity/position.zod.ts index 6a75395d2b9..2448679f5d1 100644 --- a/packages/spec/src/identity/position.zod.ts +++ b/packages/spec/src/identity/position.zod.ts @@ -61,8 +61,8 @@ export const PositionSchema = lazySchema(() => strictObject( permissions: '`permissions` is not a Position field — there are no direct position-level ' + 'permission strings anywhere on the platform: capability reaches a position ' + - 'ONLY through the permission sets it names in `permissionSets`. The `sys_position` ' + - 'row column of the same name — a "JSON-serialized array of permission ' + + 'ONLY through the permission sets it names in `permissionSets`. The column of the same ' + + 'name on the retired `sys_position` row — a "JSON-serialized array of permission ' + 'strings" textarea no producer ever wrote and no runtime path ever read — ' + 'was retired under ADR-0049 enforce-or-remove. Delete the key; to ' + 'grant capability, name permission sets in `permissionSets` instead.', @@ -96,8 +96,8 @@ export const PositionSchema = lazySchema(() => strictObject( * This key replaced the `sys_position_permission_set` junction as the * position → set binding: the authorization resolver reads this list and no * junction row. A name that resolves to no permission set confers nothing. - * A set the catalog row deactivates confers nothing either: the `active` - * flag stays on the row until it gains a definition home. + * A set deactivated in the activation ledger (`sys_metadata_activation`) + * confers nothing either. * * The built-in `everyone` anchor (ADR-0090 D5) is declared by the platform * with the deployment's baseline sets; a package does not name it here, it diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index b6f2a5ab24a..5a652a7c247 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -1163,8 +1163,8 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // rows for either type in-repo at rollback. // // Blast radius, measured while landing commit ee58392e1: plugin-security's ADR-0094 - // write-through (`permission-set-projection.ts`) routes data-door edits of - // permission sets into `saveMetaItem`. Runtime-created sets — including + // write-through (`permission-set-projection.ts`, retired with C3 stage 7a) + // routed data-door edits of permission sets into `saveMetaItem`. Runtime-created sets — including // package-bound rows MATERIALIZED through the metadata door, whose // provenance is `sys_metadata`, not an artifact — ride `allowRuntimeCreate` // (still `true`) and keep working; a data-door edit of a CODE-DECLARED diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 79b64774401..0885738f9d7 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -820,9 +820,8 @@ export const PermissionSetSchema = lazySchema(() => strictObject( label: z.string().optional().describe('Display label'), /** - * Human-readable description, surfaced on `sys_permission_set` (list columns - * + form) and the Setup projection (`permission-set-projection.ts` reads it - * from the authored set). The built-in default sets have always authored it — + * Human-readable description, shown in Setup and read from the authored set + * through the security catalog. The built-in default sets have always authored it — * but until #4001 the schema could not represent it, so it was silently * stripped at parse (the ADR-0078 §3 inverse-drift class); the `.strict()` * gate surfaced the gap and it is now declared. diff --git a/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json b/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json deleted file mode 100644 index 0f79c307ca6..00000000000 --- a/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "file": "packages/plugins/plugin-security/src/walled-catalog-data-door.ts", - "adrs": [ - "ADR-0131" - ], - "invariant": "Under a walled posture (`group` / `isolated`) the security catalog — positions, permission sets, capabilities — is environment-level and every organization resolves it by name, so a data-door create, update or delete of a catalog row is the platform operator's alone (ADR-0131 D3: tenants are refused creation and may only assign). Assignment rows are never judged here; `single` is inert." -} diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index 62abfc5e3a2..e92845fec29 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -153,7 +153,7 @@ * them: the BATCH SUMMARY of the failures the catch just counted. * * packages/plugins/plugin-email/src/outbox-sweep.ts "N stranded sys_email row(s) could NOT be delivered" - * packages/plugins/plugin-security/src/permission-set-projection.ts "N FAILED backfill(s)" + * packages/plugins/plugin-security/src/permission-set-projection.ts "N FAILED backfill(s)" (since retired) * * The repair made the split WORSE, not better: against an `error`-less sink the * per-row line now lands at `warn` while the total stays silent, so the detail @@ -412,10 +412,6 @@ const DURABILITY_CRITICAL_CALLEES = new Map([ 'persistGrantNameBackfillRecord', "The one-time grant-name backfill (ADR-0131 D4) named the grants it could, but its `sys_migration` verdict row was never written. Every log line reads clean and the names it wrote stand, while the record that makes the pass ONE-TIME is absent, so every later boot scans the whole grant table again and re-reports the grants it cannot name.", ], - [ - 'persistPositionBackfillRecord', - "The one-time row-only position backfill (ADR-0131 D3) gave the positions it could their environment definitions, but its `sys_migration` verdict row was never written. Every log line reads clean and the definitions it wrote stand, while the record that makes the pass ONE-TIME is absent, so every later boot scans the whole position table again and re-reports the positions it cannot give a definition.", - ], [ 'runWideningAlters', "The widening ALTER never ran — the MySQL column keeps its legacy zero-precision type (`TIMESTAMP` for a `Field.datetime`, `TIME` for a `Field.time`) while the object stays registered and served, so every subsequent write silently drops the milliseconds the canonical storage form promises are always present: a `TIMESTAMP` truncates them, and a `TIME(0)` ROUNDS a fractional literal, changing the wall clock it was asked to store. Reads come back looking clean because the value that was stored is the value that is returned, and nothing else reports the column is still un-widened (#9609).", diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index 2f9468962f2..22619cb53f5 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -277,8 +277,9 @@ const GATE_VOCABULARY_IDENT = 'DURABILITY_CRITICAL_CALLEES'; * (`packages/spec/src/contracts/data-driver.ts`). * - `objectql` -- the ObjectQL-level write surface the seeders use. * - `seed-wrapper` -- the `catch { return null; }` helpers this family IS - * (`permission-set-projection.ts` and its per-file - * copies) that the gate does NOT declare. `tryInsert` + * (the per-file copies of the retired + * `permission-set-projection.ts` one) that the gate + * does NOT declare. `tryInsert` * and `tryUpdate` sat here until the ruling's last step * handed them over (PR #15458); they are * `gate-vocabulary` now, because that is what the tree @@ -331,7 +332,6 @@ const WRITE_SHAPED_CALLEES = new Map([ ['persistSeedTenancyReceiptRow', 'gate-vocabulary'], ['persistLedgerDecisionRow', 'gate-vocabulary'], ['persistGrantNameBackfillRecord', 'gate-vocabulary'], - ['persistPositionBackfillRecord', 'gate-vocabulary'], ['recordLog', 'gate-vocabulary'], ['runWideningAlters', 'gate-vocabulary'], ['applyConfigPatch', 'gate-vocabulary'], From b9c20819e163a06e1a7ab5280df183b589b608f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 05:54:19 +0000 Subject: [PATCH 06/12] docs: correct the remaining texts that named the modules 7a deletes The deleted projection's names in the core row-definition doc comment, a plugin-auth test comment, the plugin-security test tsconfig's measurement note and two platform-checklist clauses. Claude-Session: https://claude.ai/code/session_01KppP2MoD91F3pXiXXrAmcF Co-Authored-By: Claude --- docs/qa/platform-checklist/areas/access-security.json | 4 ++-- .../core/src/security/permission-set-row-definition.ts | 10 +++++----- .../plugins/plugin-auth/src/last-admin-guard.test.ts | 6 +++--- packages/plugins/plugin-security/tsconfig.test.json | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index a53d392b807..3c9b438fbd8 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2599,7 +2599,7 @@ "evidence": "the PATCH trace with message quoted + the before/after reads" }, { - "clause": "the active flip is ROW STATE, not a definition write: a bare {active} PATCH passes the lock (the #4669 carve-out runs BEFORE the lock — permission-set-projection.ts) and lands on the driver, and enforcement follows it — M's granted verb is refused while active=false and restored on re-activation, with M's assignment row intact throughout", + "clause": "the active flip is ROW STATE, not a definition write: a bare {active} PATCH passes the lock (the #4669 carve-out ran BEFORE the lock in permission-set-projection.ts, retired with C3 stages 7a and 8) and lands on the driver, and enforcement follows it — M's granted verb is refused while active=false and restored on re-activation, with M's assignment row intact throughout", "oracle": "api", "verify": "deactivate 2xx (a 403 here is the lock swallowing the carve-out — a FAIL that breaks the shipped Deactivate action); M's probe verb >=400 while inactive, 2xx after reactivation; sys_user_permission_set row for M present at every step", "evidence": "the lifecycle traces + M's before/during/after probes + the assignment-row reads" @@ -2630,7 +2630,7 @@ } ], "negative": [ - "a 403 on the BARE {active} PATCH is a FAIL even though it looks like the lock working — the row-state carve-out is load-bearing for the shipped Deactivate/Activate actions (permission-set-projection.ts says so in as many words)", + "a 403 on the BARE {active} PATCH is a FAIL even though it looks like the lock working — the row-state carve-out is load-bearing for the shipped Deactivate/Activate actions (the retired permission-set-projection.ts said so in as many words)", "a clone missing system_permissions, row_level_security or tab_permissions is the exact #11703 silent-grant-loss regression this item guards — diff every facet, not just the two that were never dropped", "the two 403 sentences (packaged-locked vs provenance-unknown) must not be conflated: both are NOT_OVERRIDABLE, and only the message says whether the lock decided or failed closed", "cross-refs, to keep one defect one count: the /meta-door package lock on objects is access-security.readonly-package-locks-studio; the WRITABLE-set edit loop through /meta/permission is access-security.permission-matrix-edit-loop (its fixture note explicitly excludes locked package sets — this item is that excluded case, at the DATA door); grant enforcement itself is access-security.crud-permission-matrix" diff --git a/packages/core/src/security/permission-set-row-definition.ts b/packages/core/src/security/permission-set-row-definition.ts index 987eabe8d00..bd421425cc8 100644 --- a/packages/core/src/security/permission-set-row-definition.ts +++ b/packages/core/src/security/permission-set-row-definition.ts @@ -12,12 +12,12 @@ * the dual source ADR-0131 D10 retires. * * The function is pure: one row in, one body out — no engine, no I/O. It is - * the logic the security plugin's boot backfill applies today - * (`permissionSetBodyFromRow`), held here so the ceremony and that plugin read - * one mapping; the plugin's copy is deleted when its boot module is. + * the logic the security plugin's boot backfill applied + * (`permissionSetBodyFromRow`) until C3 stage 7a deleted it, held here as the + * one mapping the ceremony reads. * - * The mapping is the inverse of the row shape the security plugin projects a - * definition into: the snake_case facet columns, stored as JSON text, become + * The mapping is the inverse of the row shape the security plugin projected a + * definition into until then: the snake_case facet columns, stored as JSON text, become * the camelCase body keys — * * - `name` → `name`; `label` → `label`, falling back to the name when the diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts index ae203cc67b1..0af2d833801 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts @@ -103,9 +103,9 @@ const sysPermissionSet = { id: { name: 'id', type: 'text' as const, primaryKey: true }, name: { name: 'name', type: 'text' as const }, // [#6084] Not read by the guard — that is its job here. `label` is what - // every projection pass and every Setup edit writes - // (`permissionSetRowFields`), so it is the column the "costs no reads" - // pin drives. + // every projection pass and every Setup edit wrote + // (`permissionSetRowFields`, retired with C3 stage 7a), so it is the column + // the "costs no reads" pin drives. label: { name: 'label', type: 'text' as const }, // [#8613 / ADR-0049] The Deactivate switch. Declared `boolean`, so on this // real sqlite database it stores as 0/1 and the guard's flag handling is diff --git a/packages/plugins/plugin-security/tsconfig.test.json b/packages/plugins/plugin-security/tsconfig.test.json index 592765cee44..af17e381139 100644 --- a/packages/plugins/plugin-security/tsconfig.test.json +++ b/packages/plugins/plugin-security/tsconfig.test.json @@ -32,7 +32,7 @@ // the CHECK rather than the code — TS1470 (`import.meta` in a file "which // will build into CommonJS output", `src/audience-anchor-set-claims.pin.test.ts`, // a file vitest runs as ESM every day) and TS2550 (`Array.prototype.at` -// against a `lib` older than es2022, `src/permission-set-projection.test.ts`, +// against a `lib` older than es2022, `src/permission-set-projection.test.ts`, since deleted, // on Node >= 22). Matching vitest is fidelity, not laxity: it is the same // subtraction `packages/spec` and `packages/rest` made, and it removes the // pressure that produced the `__dirname` workaround above. From 4366cc8e6295be8c943e77f2158a5cd650b6645c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 05:58:01 +0000 Subject: [PATCH 07/12] fix(plugin-security): keep the audience-anchor binding gate until its ADR is corrected ADR-0138 anchors its binding-tier row to the gate, so it stays here exactly as on main, with its call site and its three cases; its removal goes with that ADR correction. Claude-Session: https://claude.ai/code/session_01KppP2MoD91F3pXiXXrAmcF Co-Authored-By: Claude --- .../15204-8post-catalog-gates-retired.md | 2 +- .../src/security/permission-sets.ts | 4 +- .../src/security-plugin.test.ts | 37 ++++++++ .../plugin-security/src/security-plugin.ts | 88 ++++++++++++++++++- 4 files changed, 128 insertions(+), 3 deletions(-) diff --git a/.changeset/15204-8post-catalog-gates-retired.md b/.changeset/15204-8post-catalog-gates-retired.md index f07333a93a9..742a7445b27 100644 --- a/.changeset/15204-8post-catalog-gates-retired.md +++ b/.changeset/15204-8post-catalog-gates-retired.md @@ -12,7 +12,7 @@ Clause-②: no Stage 8 retired the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` row objects, so no write to them reaches the engine. This change removes what only guarded those writes. No export of any package's entry changes, and no request that reaches the server is answered differently. - **`@objectstack/plugin-security`.** - - The security middleware's four gates on those objects are gone: the package-provenance gate on `sys_permission_set`, the platform/package row gate on `sys_position` and `sys_capability`, the curated capability-name gate on `sys_capability`, and the audience-anchor binding gate on `sys_position_permission_set`. The delegated-administration gate no longer judges `sys_position_permission_set` bindings or `sys_permission_set` authoring; it still governs `sys_user_position` assignments, `sys_user_permission_set` grants and `sys_member`. The rules those gates carried still hold where the catalog now lives: `everyone` and `guest` are code-declared positions an environment cannot overlay, an `everyone` binding is accepted only through the audience-binding suggestion, which refuses a set the anchor forbids, and a capability cannot be created or overlaid through the metadata door at all. + - Three of the security middleware's gates on those objects are gone: the package-provenance gate on `sys_permission_set`, the platform/package row gate on `sys_position` and `sys_capability`, and the curated capability-name gate on `sys_capability`. The delegated-administration gate no longer judges `sys_position_permission_set` bindings or `sys_permission_set` authoring; it still governs `sys_user_position` assignments, `sys_user_permission_set` grants and `sys_member`. The rules those gates carried still hold where the catalog now lives: `everyone` and `guest` are code-declared positions an environment cannot overlay, and a capability cannot be created or overlaid through the metadata door at all. The audience-anchor binding gate on `sys_position_permission_set` stays for now, unchanged; an accepted ADR still names it, and it leaves with that record's correction. - The walled data-door refusal for those objects is deleted. Stage 8 already left it unwired, and no composition registers the objects it judged; the metadata door's walled refusal is unchanged. - The internal duplicate-name refusal class that only the retired permission-set data door raised is removed. - The packaged-permission-set lock's refusal no longer has a "choose a different name" variant: only the retired data door's create raised it. The metadata door's refusal text is unchanged, and the published signatures keep their `operation` parameter. diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 8ed7733a60e..4af023b5427 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -356,7 +356,9 @@ export const MemberDefaultPermissionSet = definePermissionSet({ * may read announcements and file an inquiry (public form intake) — nothing * else. Guest bindings face the STRICTEST lint tier: named objects only (no * wildcard), read-only by default (create case-by-case), never `allowEdit`, - * never VAMA/system permissions. + * never VAMA/system permissions. The runtime anchor gate + * (`assertAudienceAnchorBindingGate`) enforces the same rules on the binding + * write itself. * * Binding is an ADMIN action (Setup → Access Control → bind `guest` → * `showcase_guest_portal`); the app only ships the capability. diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 6476c519dbc..81e4413ec1a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -4114,4 +4114,41 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 expect(bindings).toEqual([]); } }); + + // ── The engine-side write gate (`sys_position_permission_set` insert) ── + // + // Same three cases at the door an ADMIN comes through. It must agree with the + // boot binding above and with `confirmAudienceBindingSuggestion`'s early + // rendition — all three now read one source, so a set that binds at boot + // cannot be refused when an admin re-binds it by hand. + const anchorInsert = { + object: 'sys_position_permission_set', + operation: 'insert', + data: { position_id: 'pos_everyone', permission_set_id: 'ps_app' }, + }; + + it('write gate: admits a binding whose token the stack declares', async () => { + const { plugin } = await boot('crm.export_pipeline', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); + await expect((plugin as any).assertAudienceAnchorBindingGate(anchorInsert)).resolves.toBeUndefined(); + }); + + it('write gate: still refuses an undeclared token, with the ADR-0112 envelope', async () => { + const { plugin } = await boot('crm.settle_ledger', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); + const err = await (plugin as any) + .assertAudienceAnchorBindingGate(anchorInsert) + .then(() => null, (e: any) => e); + expect(err).toBeTruthy(); + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.statusCode).toBe(403); + expect(err.message).toContain('system permissions'); + }); + + it('write gate: still refuses a platform capability of that name', async () => { + const { plugin } = await boot('manage_users', [{ name: 'manage_users', label: 'Not Yours' }]); + const err = await (plugin as any) + .assertAudienceAnchorBindingGate(anchorInsert) + .then(() => null, (e: any) => e); + expect(err?.code).toBe('PERMISSION_DENIED'); + expect(err?.statusCode).toBe(403); + }); }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b5a1d8096bd..7ade3fdd648 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4,7 +4,8 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { declaredHttpStatus } from '@objectstack/types'; import { Plugin, PluginContext, POSTURE_LADDER, buildEffectiveObjectPermissions, recordNotFoundError, withoutOperationPrivateKeys } from '@objectstack/core'; import type { EffectiveObjectPermission, PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; -import { describeHighPrivilegeBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; +import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; +import type { AnchorBindingContext } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; // [#8220] The read-scope provenance mark: this middleware is one of the two // merge boundaries that stamp it (see the RLS injection below). @@ -2432,6 +2433,14 @@ export class SecurityPlugin implements Plugin { ); } + // [ADR-0090 D5/D9] Audience-anchor binding guard — like the package + // gate above, an unconditional data-layer boundary: a permission set + // carrying high-privilege bits must never be bound to the `everyone` + // or `guest` positions, no matter who asks. (Boot/system writes carry + // `isSystem` and short-circuited above; the dev-mode default binding + // is validated at seed time by the same predicate.) + await this.assertAudienceAnchorBindingGate(opCtx); + // [ADR-0103] Engine-owned write guard. Fail-closed on a user-context // generic write to a `system`/`append-only` object whose resolved // affordances forbid the verb (the engine-owned default). Keyed off @@ -6699,6 +6708,83 @@ export class SecurityPlugin implements Plugin { * multi-row predicate and returns `null` (multi-row writes route through the * `*Many` paths, out of scope for the by-id pre-image check). */ + /** + * [ADR-0090 D5/D9] Reject binding a HIGH-PRIVILEGE permission set to an + * audience anchor (`everyone` / `guest`). The anchors are implicit for + * whole principal classes, so a dangerous binding here is an instant + * tenant-wide (or anonymous-wide) grant — the one shape the model must + * make unrepresentable rather than merely discouraged. + */ + private async assertAudienceAnchorBindingGate(opCtx: any): Promise { + if (opCtx?.object !== 'sys_position_permission_set') return; + if (!['insert', 'update'].includes(opCtx.operation)) return; + const rows = Array.isArray(opCtx.data) + ? opCtx.data + : (opCtx.data && typeof opCtx.data === 'object' ? [opCtx.data] : []); + if (rows.length === 0) return; + + const ql = this.ql; + // [#18535, ADR-0090 D5] The stack's declared `capabilities:`, read at most + // ONCE per gate pass and only once an anchor row is actually in play — the + // common write on this table names an ordinary position and must not pay + // for a read it does not use. Same source as the boot binding and as + // `confirmAudienceBindingSuggestion`, which is this gate's friendly early + // rendition: a second source there would let a confirm pass its own check + // and then be refused by the insert it performs. + let anchorContext: AnchorBindingContext | undefined; + let anchorContextLoaded = false; + const declaredCapabilityContext = async (): Promise => { + if (!anchorContextLoaded) { + anchorContext = await readDeclaredCapabilityContext(ql, this.metadata); + anchorContextLoaded = true; + } + return anchorContext; + }; + for (const row of rows) { + const positionId = (row as any)?.position_id; + if (!positionId || !ql?.find) continue; + let positionName = ''; + try { + const posRows = await ql.find('sys_position', { where: { id: positionId }, limit: 1, context: { isSystem: true } }); + positionName = String((Array.isArray(posRows) && posRows[0] ? (posRows[0] as any).name : '') ?? ''); + } catch { positionName = ''; } + if (positionName !== 'everyone' && positionName !== 'guest') continue; + + // Resolve the target set definition (bootstrap sets by name, else the + // sys_permission_set row itself carries the authored definition). + const setId = (row as any)?.permission_set_id; + let setName = ''; + let setDef: any = null; + try { + const setRows = await ql.find('sys_permission_set', { where: { id: setId }, limit: 1, context: { isSystem: true } }); + const sr: any = Array.isArray(setRows) && setRows[0] ? setRows[0] : null; + if (sr) { + setName = String(sr.name ?? ''); + setDef = sr; + } + } catch { /* fall through to bootstrap lookup below */ } + const boot = this.bootstrapPermissionSets.find((p) => p.name === setName); + // [ADR-0090 D9] Anchor-tier predicate: `guest` faces the strictest tier + // (additionally no edit bit — read-only by default, create is the single + // case-by-case write); `everyone` uses the high-privilege predicate. + // [#18535] `guest` faces the strictest tier and the predicate DROPS the + // context for it (D5's app-token excusal is the `everyone` tier's alone), + // so the same call serves both anchors. + const offending = describeAnchorForbiddenBits( + boot ?? setDef, + positionName as 'everyone' | 'guest', + await declaredCapabilityContext(), + ); + if (offending) { + throw new PermissionDeniedError( + `[Security] Access denied: permission set '${setName || setId}' cannot be bound to the '${positionName}' audience anchor — it carries ${offending} (ADR-0090 D5/D9). ` + + `Audience anchors accept low-privilege sets only; grant powerful sets through ordinary positions instead.`, + { operation: opCtx.operation, object: opCtx.object, position: positionName, permissionSet: setName || setId }, + ); + } + } + } + /** * [commit 6feac910b] Does this object declare that its access — writes included — derives * from a master record (ADR-0055 `controlled_by_parent`)? From 3f06e9d14042d10f015f9ccd8b9ca03af409a4c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 06:25:03 +0000 Subject: [PATCH 08/12] chore: regenerate the spec references over the merged tree Claude-Session: https://claude.ai/code/session_01KppP2MoD91F3pXiXXrAmcF Co-Authored-By: Claude --- content/docs/references/api/protocol.mdx | 125 ++++++++++++++++++++++- 1 file changed, 122 insertions(+), 3 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index bedffc6fd7b..a4b0f7dae08 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1,7 +1,7 @@ --- title: Protocol schema — API Protocol reference navTitle: Protocol -description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 134 more — each property with its type, default and a TypeScript example." +description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 136 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilit ## TypeScript Usage ```typescript -import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; -import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; +import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; +import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -967,6 +967,65 @@ Enable package response | **kind** | `string` | ✅ | What kind of reference this is (e.g. which key carries it). | +--- + +## FlowRegistration + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'registered'` | ✅ | The engine holds a registration under the flow's name. | + +--- + +#### Option 2 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'refused'` | ✅ | The engine refused to register the stored flow and holds nothing under its name: it never runs. | +| **reason** | `{ code?: string; message: string }` | ✅ | What the engine threw, verbatim. | + +### Nested Shape: `FlowRegistration[state='refused'].reason` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | optional | The machine code the engine's refusal carried, verbatim. Absent when the thrown error carried none. | +| **message** | `string` | ✅ | The engine's refusal message, verbatim. Render it as sent; do not parse it. | + +--- + +#### Option 3 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'unregistered'` | ✅ | The engine holds nothing under the flow's name and recorded no refusal for it. | + +--- + + +--- + +## FlowRegistrationRefusal + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | optional | The machine code the engine's refusal carried, verbatim. Absent when the thrown error carried none. | +| **message** | `string` | ✅ | The engine's refusal message, verbatim. Render it as sent; do not parse it. | + + --- ## GetDataRequest @@ -1270,6 +1329,7 @@ Enable package response | **cacheControl** | `{ directives: Enum<'public' \| 'private' \| 'no-cache' \| 'no-store' \| 'must-revalidate' \| 'max-age'>[]; maxAge?: number; staleWhileRevalidate?: number; staleIfError?: number }` | optional | Cache control directives | | **notModified** | `boolean` | optional (default: `false`) | True if resource has not been modified (304 response) | | **version** | `string` | optional | Metadata version identifier | +| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on the flow in `data`, read live on every call and never part of `etag` (see `FlowRegistrationSchema`). Present only for a `flow` read answered by a process that runs an automation service; a read that carries it is never answered `notModified`. Absent otherwise, and absence means no engine judged it, never `registered`. | ### Nested Shape: `GetMetaItemCachedResponse.etag` @@ -1287,6 +1347,25 @@ Enable package response | **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) (unit per HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)) | | **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) (unit per HTTP Cache-Control `stale-if-error` (RFC 5861 §4)) | +### Nested Shape: `GetMetaItemCachedResponse.registration[state='registered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'registered'` | ✅ | The engine holds a registration under the flow's name. | + +### Nested Shape: `GetMetaItemCachedResponse.registration[state='refused']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'refused'` | ✅ | The engine refused to register the stored flow and holds nothing under its name: it never runs. | +| **reason** | `{ code?: string; message: string }` | ✅ | What the engine threw, verbatim. | + +### Nested Shape: `GetMetaItemCachedResponse.registration[state='unregistered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'unregistered'` | ✅ | The engine holds nothing under the flow's name and recorded no refusal for it. | + --- @@ -1375,6 +1454,7 @@ Enable package response | **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. | | **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | +| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | ### Nested Shape: `GetMetaItemResponse.sortability` @@ -1382,6 +1462,25 @@ Enable package response | :--- | :--- | :--- | :--- | | **fields** | `Record` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in `item` and gets no entry for them; for every other caller the served map already excludes them. A name absent from this map (an unknown field, a dotted path, an unprovisioned audit column, a field the caller may not read) has no platform sort behind it and must get no sort affordance. | +### Nested Shape: `GetMetaItemResponse.registration[state='registered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'registered'` | ✅ | The engine holds a registration under the flow's name. | + +### Nested Shape: `GetMetaItemResponse.registration[state='refused']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'refused'` | ✅ | The engine refused to register the stored flow and holds nothing under its name: it never runs. | +| **reason** | `{ code?: string; message: string }` | ✅ | What the engine threw, verbatim. | + +### Nested Shape: `GetMetaItemResponse.registration[state='unregistered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'unregistered'` | ✅ | The engine holds nothing under the flow's name and recorded no refusal for it. | + --- @@ -1406,6 +1505,26 @@ Enable package response | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **items** | `any[]` | ✅ | Array of metadata items | +| **registrations** | `Record` | optional | The automation engine's verdict on each listed flow, keyed by the item's `name`: one entry per listed item that is not a pending draft. Present only on a `flow` list answered by a process that runs an automation service; absent otherwise, and absence means no engine judged them, never `registered`. The verdict is the answering process's engine. Beside the items, never inside one: an item's `status` stays what its author declared. | + +### Nested Shape: `GetMetaItemsResponse.registrations[string][state='registered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'registered'` | ✅ | The engine holds a registration under the flow's name. | + +### Nested Shape: `GetMetaItemsResponse.registrations[string][state='refused']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'refused'` | ✅ | The engine refused to register the stored flow and holds nothing under its name: it never runs. | +| **reason** | `{ code?: string; message: string }` | ✅ | What the engine threw, verbatim. | + +### Nested Shape: `GetMetaItemsResponse.registrations[string][state='unregistered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'unregistered'` | ✅ | The engine holds nothing under the flow's name and recorded no refusal for it. | --- From 44aa1e1b36f798437dbe3e6fccb29fe2de8261f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:10:27 +0000 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20contract-review=20patch=20round=20?= =?UTF-8?q?=E2=80=94=20restore=20the=20direct-grant=20pins,=20correct=20th?= =?UTF-8?q?e=20remaining=20prose,=20restate=20the=20catalog-name=20describ?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores delegated-admin-gate.test.ts's direct-grants describe verbatim (assertDirectGrantWrite is live). Corrects the PermissionSet.description doc comment, the projector, materializer and uninstall comments in the metadata protocol, the lock and ledger comments, and the retired checklist item's title and automated ref. Restates the position and permission-set name describes to ADR-0131 D3: a name is unique within the security catalog the environment serves. Claude-Session: https://claude.ai/code/session_01KppP2MoD91F3pXiXXrAmcF Co-Authored-By: Claude --- .../15204-8post-catalog-gates-retired.md | 6 +- .../areas/access-security.json | 4 +- packages/metadata-protocol/src/protocol.ts | 58 ++++++------- .../src/delegated-admin-gate.test.ts | 84 ++++++++++++++++++- .../src/delegated-admin-gate.ts | 5 +- packages/plugins/plugin-security/src/index.ts | 3 +- .../src/packaged-permission-set-lock.ts | 16 ++-- .../spec/src/api/error-code-ledger.zod.ts | 2 +- packages/spec/src/identity/position.zod.ts | 14 ++-- packages/spec/src/security/permission.zod.ts | 19 ++--- 10 files changed, 143 insertions(+), 68 deletions(-) diff --git a/.changeset/15204-8post-catalog-gates-retired.md b/.changeset/15204-8post-catalog-gates-retired.md index 742a7445b27..9f34c5c6ffc 100644 --- a/.changeset/15204-8post-catalog-gates-retired.md +++ b/.changeset/15204-8post-catalog-gates-retired.md @@ -3,6 +3,7 @@ '@objectstack/platform-objects': patch '@objectstack/spec': patch '@objectstack/core': patch +'@objectstack/metadata-protocol': patch --- fix(plugin-security): the data-door write gates of the retired catalog row objects are removed, the boot report names positions that bind a permission set nobody serves, and the texts that still named the retired objects and modules are corrected (ADR-0131 D3/D4, C3 stage 8-post) @@ -18,5 +19,6 @@ Stage 8 retired the `sys_position`, `sys_permission_set`, `sys_position_permissi - The packaged-permission-set lock's refusal no longer has a "choose a different name" variant: only the retired data door's create raised it. The metadata door's refusal text is unchanged, and the published signatures keep their `operation` parameter. - The boot's catalog reference report lists one more finding, per position: a position definition whose `permissionSets` names a set the security catalog does not resolve, usually because the package that declared the set is uninstalled or disabled. A holder of such a position is granted nothing through that name. The line clears by itself at the next boot after the package is installed again. It is logged at `error` once per boot, like the report's other lines. - **`@objectstack/platform-objects`** and **`@objectstack/plugin-security`** object metadata: the descriptions of `sys_invitation.positions`, `sys_user_position` (object and `position`), and `sys_audience_binding_suggestion`'s permission-set name no longer name the retired objects. The translated texts follow. -- **`@objectstack/spec`**: the `projectionApplied` and `materializeApplied` descriptions on the metadata save, publish and batch-publish responses give a current example (`datasource`, `flow`) instead of `permission` → `sys_permission_set`. `EvalUser.positions` describes the positions assigned through `sys_user_position`. `PermissionSet.description`'s doc comment no longer names the deleted projection. The error-code ledger drops `@objectstack/plugin-security`'s `UNIQUE_VIOLATION` row, which named the removed refusal; `UNIQUE_VIOLATION` stays registered by its other emitters. -- **`@objectstack/core`**: `positionDefinitionFromRow`'s doc comment no longer describes the deleted row-only position backfill as current. +- **`@objectstack/spec`**: the `projectionApplied` and `materializeApplied` descriptions on the metadata save, publish and batch-publish responses give a current example (`datasource`, `flow`) instead of `permission` → `sys_permission_set`. `EvalUser.positions` describes the positions assigned through `sys_user_position`. `PermissionSet.description`'s doc comment no longer names the deleted projection. The `name` describes of `PositionSchema` and `PermissionSetSchema` say a name is unique within the security catalog the environment serves (ADR-0131 D3: there is no organization-level catalog), replacing "unique per organization", which read the retired rows' index. The error-code ledger drops `@objectstack/plugin-security`'s `UNIQUE_VIOLATION` row, which named the removed refusal; `UNIQUE_VIOLATION` stays registered by its other emitters. +- **`@objectstack/core`**: the doc comments of `positionDefinitionFromRow` and `permissionSetDefinitionFromRow` no longer describe the deleted boot backfills as current. +- **`@objectstack/metadata-protocol`**: the doc comments of the publish materializer, mutation projector and uninstall cleanup registries give current examples (`flow`, `datasource`, the grant revocation) instead of `permission` → `sys_permission_set`. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 3c9b438fbd8..b94cdf372b3 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2558,7 +2558,7 @@ }, { "id": "access-security.packaged-permission-set-lifecycle", - "title": "Packaged permission-set lifecycle (#11513) survives ADR-0126 unchanged: locked base at the data door, clone-to-customize with every facet carried, `active` row-state flip — and NONE of it writes a sys_metadata_activation row", + "title": "Packaged permission-set lifecycle (#11513) at the retired sys_permission_set data door: locked base, clone-to-customize with every facet carried, `active` row-state flip — and NONE of it wrote a sys_metadata_activation row", "since": "v16", "status": "retired", "retiredReason": "C3 stage 8 (ADR-0131 D3) retired the sys_permission_set row object: its data door, its Activate / Deactivate / Clone row actions and its `active` column are gone. A packaged set's base stays locked at the metadata door, a copy is made with POST /api/v1/security/permission-sets/:name/clone, and a set is switched off in the activation ledger (sys_metadata_activation).", @@ -2642,7 +2642,7 @@ ], "automated": { "kind": "unit", - "ref": "packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts (provenance classifier, fail-closed verdicts, the clone action's payload pinned facet-by-facet against the params list — pin 6) + packaged-permission-set-restore-leg.test.ts; the live enforcement flip and the no-ledger-row wall stay manual" + "ref": "packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts and packaged-permission-set-lock-gate.test.ts (provenance classifier, fail-closed verdicts, the metadata-door lock that survives the data door's retirement); the data-door legs this item described, the clone action's pin 6 and the restore-leg suite, retired with C3 stages 7a and 8" }, "source": [ "packages/plugins/plugin-security/src/packaged-permission-set-lock.ts (classifier + both refusals; the SchemaRegistry-not-managed_by decision)", diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 16c97de7c53..cf7267703e9 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -6354,11 +6354,10 @@ export class ObjectStackProtocolImplementation implements /** * Publish-time materializers keyed by singular metadata type (ADR-0086 P2). * When a draft of a registered type is published, its body is projected - * into a data-plane representation the admin surface reads — e.g. a - * `permission` set is upserted into `sys_permission_set` with - * `managed_by:'package'`. Domain plugins own the projection (the generic - * protocol layer must not know `sys_permission_set`'s field shape), so they - * register here at init. Best-effort — a materializer failure is surfaced on + * into a data-plane representation — e.g. service-automation's `flow` + * materializer, which promotes the draft's credentials on publish. Domain + * plugins own the projection (the generic protocol layer must not know the + * data-plane shape), so they register here at init. Best-effort — a materializer failure is surfaced on * the publish response, never thrown (publishing metadata always succeeds * independently; the same contract as `seed` apply). */ @@ -6638,8 +6637,8 @@ export class ObjectStackProtocolImplementation implements /** * Register a publish-time materializer for a metadata type (ADR-0086 P2). - * Called by domain plugins at init (e.g. plugin-security registers the - * `permission` → `sys_permission_set` projection). The singular type name is + * Called by domain plugins at init (e.g. service-automation registers the + * `flow` materializer). The singular type name is * used — `permissions` and `permission` both resolve here. One materializer * per type; a second registration replaces the first (idempotent re-init). */ @@ -6651,9 +6650,9 @@ export class ObjectStackProtocolImplementation implements /** * Register a named uninstall-time data-plane cleanup (ADR-0086 D3, #2747). * Called by domain plugins at init — e.g. plugin-security registers the - * cleanup that removes its package-owned `sys_permission_set` rows and - * their bindings when the owning package is uninstalled, so grants are - * revoked everywhere at once (no ghost grants). One cleanup per name; a + * cleanup that revokes every grant naming a permission set the package + * declared when the owning package is uninstalled, so grants are revoked + * everywhere at once (no ghost grants). One cleanup per name; a * second registration replaces the first (idempotent re-init). */ registerUninstallCleanup(name: string, cleanup: UninstallCleanup): void { @@ -6701,8 +6700,8 @@ export class ObjectStackProtocolImplementation implements }); } catch (e: any) { // [#8136] A cleanup is arbitrary plugin code that goes straight - // at the engine (plugin-security deletes `sys_permission_set` - // rows and their bindings), so a driver failure lands here + // at the engine (plugin-security deletes the grant rows that + // name the package's sets), so a driver failure lands here // verbatim — and this outcome rides on the RESPONSE by design, // inside `details`, where no boundary's message withhold can // reach it. Quoted only when the cleanup declared a refusal. @@ -6730,8 +6729,8 @@ export class ObjectStackProtocolImplementation implements /** * 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. + * service-datasource registers the `datasource` projector). Singular or + * plural type names both resolve. */ registerMutationProjector(type: string, projector: MetadataMutationProjector): void { const singular = PLURAL_TO_SINGULAR[type] ?? type; @@ -23442,8 +23441,8 @@ export class ObjectStackProtocolImplementation implements }; /** * Present when a publish-time materializer is registered for this type - * (ADR-0086 P2 — e.g. `permission` → `sys_permission_set`): the result - * of projecting the published body into its data-plane row. Best-effort, + * (ADR-0086 P2 — e.g. `flow`): the result of projecting the published + * body into its data-plane representation. Best-effort, * same contract as `seedApplied` — surfaced, never thrown. */ materializeApplied?: PublishMaterializeResult; @@ -24065,10 +24064,9 @@ export class ObjectStackProtocolImplementation implements out.seedApplied = await this.applySeedBodies([args.body]); } // Publish-time materializer (ADR-0086 P2): project the published body - // into its data-plane row (e.g. `permission` → `sys_permission_set` - // with `managed_by:'package'`). Unlike seeds this needs no batch - // ordering — permission sets carry no cross-item references — so it - // runs on every publish path, package-draft batch included. The + // into its data-plane representation (e.g. `flow`). Unlike seeds this + // needs no batch ordering, so it runs on every publish path, + // package-draft batch included. The // owning `package_id` rides on the draft's binding, so a package-door // set materializes under the right owner. const materializer = this.publishMaterializers.get(args.singularType); @@ -24082,8 +24080,7 @@ export class ObjectStackProtocolImplementation implements }); } catch (e: any) { // [#8333 · P8] A materializer is arbitrary PLUGIN code going - // straight at the engine (plugin-security upserts - // `sys_permission_set`), so a driver failure lands here + // straight at the engine, so a driver failure lands here // verbatim — and this string is copied onto // `materializeApplied.failures[].error` of a **200** publish // response, where no boundary withhold can reach it. @@ -24507,11 +24504,10 @@ export class ObjectStackProtocolImplementation implements seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }; /** * ADR-0086 P2 — aggregate result of publish-time materializers across the - * batch (e.g. `permission` → `sys_permission_set`). Absent when no - * published item had a registered materializer. `failures` names each - * item whose projection did NOT land (e.g. a permission-set name owned by - * the env door or another package) so the caller surfaces it instead of - * reporting a clean publish over a set that never went live. + * batch (e.g. `flow`). Absent when no published item had a registered + * materializer. `failures` names each item whose projection did NOT + * land, so the caller surfaces it instead of reporting a clean publish + * over an item that never went live. */ materializeApplied?: { success: boolean; @@ -25808,8 +25804,8 @@ export class ObjectStackProtocolImplementation implements // [#2747] Data-plane cleanups registered by domain plugins (mirror of // the publish materializers): revoke what the package's metadata - // granted — e.g. plugin-security removes its package-owned - // sys_permission_set rows and their bindings. Best-effort per cleanup; + // granted — e.g. plugin-security revokes every grant naming a set the + // package declared. Best-effort per cleanup; // outcomes ride on the response so a failed revocation (ghost grants — // a security condition) is visible to the caller, never silent. // [#21490] Through the registry's one runner, which install-local's @@ -27792,8 +27788,8 @@ export class ObjectStackProtocolImplementation implements // [ADR-0094] Awaited projection BEFORE the fire-and-forget // listener below — the order `saveMetaItem`'s comment // establishes. A rollback restores the row and the in-memory - // registry to the target version; a derived read-model (e.g. - // `permission` -> `sys_permission_set`) must be just as current + // registry to the target version; a derived read-model (e.g. the + // `datasource` projector's) must be just as current // when this call returns, not only after the next unrelated // write or boot reconciliation (ADR-0094 D4) eventually heals it. // `body` is the restored version's own — the same value the diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index 52985acddcc..ecba9982a9e 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -283,6 +283,87 @@ describe('DelegatedAdminGate — assignments (sys_user_position)', () => { }); }); +describe('DelegatedAdminGate — direct grants (sys_user_permission_set)', () => { + it('delegate grants an allowlisted set to a user inside the subtree; granted_by stamped', async () => { + const row: any = { user_id: 'u_east_1', permission_set: 'sales_user' }; + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'insert', data: row, context: h.ctxOf('delegate'), + })).resolves.toBeUndefined(); + expect(row.granted_by).toBe('u_delegate'); + }); + + it('denies grants to users outside the subtree', async () => { + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'insert', + data: { user_id: 'u_west_1', permission_set: 'sales_user' }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/outside the delegated subtree/); + }); + + it('denies non-allowlisted sets — including to the delegate themselves', async () => { + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'insert', + data: { user_id: 'u_delegate', permission_set: 'finance_admin' }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/not in the scope's allowlist/); + }); + + it('granting a set that carries an adminScope requires STRICT containment (equal scope refused)', async () => { + // sub_admin carries the delegate's own EXACT scope — lateral propagation banned. + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'insert', + data: { user_id: 'u_east_1', permission_set: 'sub_admin' }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/not strictly contained/); + }); + + // [ADR-0131 D4] A stored grant's pre-image is judged by the set it NAMES + // (`permission_set`), read from the catalog by that name; an insert, or an + // update that re-points the id, is judged by the id it writes, as before. + describe('[ADR-0131 D4] the pre-image is judged by the name the grant holds', () => { + const storedGrant = (row: Record) => { + for (const set of h.tables.sys_permission_set) set.organization_id ??= null; + h.tables.sys_user_permission_set = [{ id: 'g1', user_id: 'u_east_1', ...row }]; + }; + const remove = () => h.gate.assert({ + object: 'sys_user_permission_set', operation: 'delete', + options: { where: { id: 'g1' } }, context: h.ctxOf('delegate'), + }); + + it('a grant naming an allowlisted set may be revoked by the delegate — whatever its id says', async () => { + storedGrant({ permission_set_id: 'ps_fin', permission_set: 'sales_user' }); + await expect(remove()).resolves.toBeUndefined(); + }); + + it('a grant naming a set outside the allowlist is refused — whatever its id says', async () => { + storedGrant({ permission_set_id: 'ps_sales', permission_set: 'finance_admin' }); + await expect(remove()).rejects.toThrow(/'finance_admin' is not in the scope's allowlist/); + }); + + it('a grant that names NO set is refused: the delegate cannot be shown to hold authority over it', async () => { + storedGrant({ permission_set_id: 'ps_sales', permission_set: null }); + await expect(remove()).rejects.toThrow(/names no permission set/); + }); + + it('a grant naming a set the security catalog does not hold is refused', async () => { + storedGrant({ permission_set_id: 'ps_sales', permission_set: 'sales_user_retired' }); + await expect(remove()).rejects.toThrow(/is not in the security catalog/); + }); + + it('an update keeping the set is judged by the stored name; one re-pointing the id by the new id', async () => { + storedGrant({ permission_set_id: 'ps_sales', permission_set: 'sales_user' }); + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'update', + data: { id: 'g1', reason: 'renewed' }, context: h.ctxOf('delegate'), + })).resolves.toBeUndefined(); + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'update', + data: { id: 'g1', permission_set: 'finance_admin' }, context: h.ctxOf('delegate'), + })).rejects.toThrow(/'finance_admin' is not in the scope's allowlist/); + }); + }); +}); + // ── [ADR-0091 D3] Self-service delegation of duty ────────────────────────── const T0 = Date.parse('2026-07-01T00:00:00Z'); @@ -938,9 +1019,6 @@ describe('DelegatedAdminGate — granted_by is the writer on every admitted inse it('a governed object with no granted_by column is never given one', async () => { const member: any = { user_id: 'u_x', organization_id: 'org_1', role: 'admin' }; await expect(h.gate.assert({ object: 'sys_member', operation: 'insert', data: member, context: h.ctxOf('tenant_admin') })).resolves.toBeUndefined(); - const binding: any = { position_id: 'pos_sales', permission_set_id: 'ps_sales' }; - await expect(h.gate.assert({ object: 'sys_position_permission_set', operation: 'insert', data: binding, context: h.ctxOf('tenant_admin') })).resolves.toBeUndefined(); expect('granted_by' in member).toBe(false); - expect('granted_by' in binding).toBe(false); }); }); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index bacab6fdcc3..d55ecc39ccd 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -862,9 +862,8 @@ export class DelegatedAdminGate { parseMaybeJson(setRowOrDef.admin_scope ?? setRowOrDef.adminScope) ?? undefined; if (!carried) return null; // NOTE: containment needs resolved subtrees — checked synchronously against - // the pre-resolved held scopes; the carried scope's subtree is resolved in - // checkStrictContainment for the authoring path. For grant paths we compare - // conservatively by definition (name + flags + allowlist). + // the pre-resolved held scopes. Grant paths compare conservatively by + // definition (name + flags + allowlist). for (const s of held) { if (this.definitionContainsStrictly(s.scope, carried)) return null; } diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 9d7ca1fc7fa..5db58b89387 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -122,7 +122,8 @@ export type { } from './packaged-permission-set-lock.js'; // [commit 5619aace3 — maintainer ruling 2026-08-25, option B] The lock's METADATA-door // registration: the pre-persistence authoring-gate seam consults the SAME -// classifier and throws the SAME error classes as the data door above. +// classifier and throws the SAME error classes exported above. Since C3 stage 8 +// retired the `sys_permission_set` data door, it is the lock's one door. export { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; export type { PermissionSetLockGateContext } from './packaged-permission-set-lock-gate.js'; export { diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts index 4607a665685..059dd1c5d3d 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts @@ -34,14 +34,14 @@ * ⛔ So the answer is NOT taken from a name-keyed page over * `sys_permission_set`. The batched existence oracle the catalog seeders read * (`buildExistingByName`, retired with them in C3 stage 7a) capped its UNSCOPED page at - * `limit: names.length`, which truncates the moment one name can carry more - * than one row — and a truncated page read as `absent`. Commit e1d773eb7 has since - * repaired that: the page budget is measured (one row more than it will hold is - * requested, so overflow is DETECTED) and an overflowing page degrades to the - * per-item read instead of answering. ⚠️ That does not make this oracle safe to - * ask HERE, and the reason is worth stating rather than re-deriving: unscoped, - * `buildExistingByName` answers with the FIRST row by id for a name, so on a - * name several organizations hold it can answer with somebody else's row — a + * `limit: names.length`, which truncated the moment one name could carry more + * than one row — and a truncated page read as `absent`. Commit e1d773eb7 + * repaired that: the page budget was measured (one row more than it would hold + * was requested, so overflow was DETECTED) and an overflowing page degraded to + * the per-item read instead of answering. ⚠️ That never made such an oracle + * safe to ask HERE, and the reason is worth stating rather than re-deriving: + * unscoped, it answered with the FIRST row by id for a name, so on a name + * several organizations held it could answer with somebody else's row — a * different wrong answer to the same question. This lock's read must have no * page in it at all. * diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 2f3b54934ac..3dd3b8e8623 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1254,7 +1254,7 @@ export const ERROR_CODE_LEDGER = { // its casing and every other package's rows are unchanged. // `PackagedPermissionSetLockedError` and // `PackagedPermissionSetProvenanceUnknownError` - // (`packaged-permission-set-lock.ts`), 403: a data-path write targets a + // (`packaged-permission-set-lock.ts`), 403: a metadata-door save targets a // package-declared permission set, or one whose provenance cannot be // determined (fail-closed). Deliberately the SAME envelope // `@objectstack/metadata-protocol`'s ADR-0005 tier gate answers for this diff --git a/packages/spec/src/identity/position.zod.ts b/packages/spec/src/identity/position.zod.ts index 2448679f5d1..97b27155cef 100644 --- a/packages/spec/src/identity/position.zod.ts +++ b/packages/spec/src/identity/position.zod.ts @@ -73,14 +73,14 @@ export const PositionSchema = lazySchema(() => strictObject( }, { /** Identity */ - // [#8468] "unique per organization", not bare "unique". This `describe()` is - // the source of the generated reference page's `name` row - // (`content/docs/references/identity/position.mdx`), so the bare wording - // published the installation-wide reading that `sys_position`'s declared - // index accidentally materialized — as if it had been intended. The ruling of - // 2026-08-13 scopes the name per organization; the text now says so. + // [ADR-0131 D3] The catalog is environment-level: there is no + // organization-level catalog, so a name is one item in the security catalog + // the environment serves. This `describe()` is the source of the generated + // reference page's `name` row (`content/docs/references/identity/position.mdx`); + // its earlier "unique per organization" (#8468) read the retired + // `sys_position` row's index. name: SnakeCaseIdentifierSchema.describe( - 'Position name, unique per organization (lowercase snake_case)', + 'Position name, unique within the security catalog the environment serves (lowercase snake_case)', ), label: z.string().describe('Display label (e.g. VP of Sales)'), diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 16368d92184..8a7b5c014ec 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -807,21 +807,20 @@ export const PermissionSetSchema = lazySchema(() => strictObject( 'believed a capability boundary was declared that the runtime never saw.', }, { - // [#8554] "unique per organization", not bare "unique". This `describe()` is - // the SOURCE of the generated reference page - // (`content/docs/references/security/permission.mdx`), so the bare wording had - // already reached authors as published contract — the same route the - // `sys_position` accident took (#8468). `sys_permission_set` is tenant-scoped - // and the declared index is now `unique: 'organization'`. - /** Permission set name, unique per organization */ - name: SnakeCaseIdentifierSchema.describe('Permission set name, unique per organization (lowercase snake_case)'), + // [ADR-0131 D3] The catalog is environment-level: there is no + // organization-level catalog, so a name is one item in the security catalog + // the environment serves. This `describe()` is the SOURCE of the generated + // reference page (`content/docs/references/security/permission.mdx`); its + // earlier "unique per organization" (#8554) read the retired + // `sys_permission_set` row's index. + /** Permission set name, unique within the security catalog the environment serves */ + name: SnakeCaseIdentifierSchema.describe('Permission set name, unique within the security catalog the environment serves (lowercase snake_case)'), /** Display label */ label: z.string().optional().describe('Display label'), /** - * Human-readable description, shown in Setup and read from the authored set - * through the security catalog. The built-in default sets have always authored it — + * Human-readable description of the set. The built-in default sets have always authored it — * but until #4001 the schema could not represent it, so it was silently * stripped at parse (the ADR-0078 §3 inverse-drift class); the `.strict()` * gate surfaced the gap and it is now declared. From c4e9c4f919a2fff50b4473668203b312f8bfa079 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:15:12 +0000 Subject: [PATCH 10/12] chore: regenerate the position and permission references Claude-Session: https://claude.ai/code/session_01KppP2MoD91F3pXiXXrAmcF Co-Authored-By: Claude --- content/docs/references/identity/position.mdx | 2 +- content/docs/references/security/permission.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/identity/position.mdx b/content/docs/references/identity/position.mdx index ff1dc3a4d65..19c45a578ad 100644 --- a/content/docs/references/identity/position.mdx +++ b/content/docs/references/identity/position.mdx @@ -28,7 +28,7 @@ const result = PositionSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Position name, unique per organization (lowercase snake_case) | +| **name** | `string` | ✅ | Position name, unique within the security catalog the environment serves (lowercase snake_case) | | **label** | `string` | ✅ | Display label (e.g. VP of Sales) | | **description** | `string` | optional | | | **permissionSets** | `string[]` | optional | ADR-0131 D3/D4: the permission sets this position distributes, by name (resolved in the environment catalog). | diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index b68bfa9e3ef..729e76591c1 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -114,7 +114,7 @@ const result = AdminScopeSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Permission set name, unique per organization (lowercase snake_case) | +| **name** | `string` | ✅ | Permission set name, unique within the security catalog the environment serves (lowercase snake_case) | | **label** | `string` | optional | Display label | | **description** | `string` | optional | Human-readable description shown in Setup | | **packageId** | `string` | optional | [ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored); a value that disagrees with the package that ships the set, or that it is saved into, is refused by the stack schema (defineStack, os validate, os build), the package registration, and the metadata save, restore and publish doors | From a33c341826543f5a0097ba72d69b4a47c14fb009 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:36:17 +0000 Subject: [PATCH 11/12] docs: correct the prose the delta review named after the merge Drops the changeset's sentence keeping the audience-anchor gate (main removed it), the showcase comment naming that gate, and the remaining present-tense sys_permission_set and per-organization position-name examples in the metadata protocol, the security plugin and the delegated-admin gate. Claude-Session: https://claude.ai/code/session_01KppP2MoD91F3pXiXXrAmcF Co-Authored-By: Claude --- .../15204-8post-catalog-gates-retired.md | 2 +- .../src/security/permission-sets.ts | 4 +- packages/metadata-protocol/src/protocol.ts | 45 ++++++++++--------- .../src/catalog-reference-report.ts | 2 +- ...ed-admin-gate-holding-organization.test.ts | 5 ++- .../src/delegated-admin-gate.ts | 7 +-- .../plugin-security/src/security-plugin.ts | 5 +-- 7 files changed, 36 insertions(+), 34 deletions(-) diff --git a/.changeset/15204-8post-catalog-gates-retired.md b/.changeset/15204-8post-catalog-gates-retired.md index 9f34c5c6ffc..451c8435616 100644 --- a/.changeset/15204-8post-catalog-gates-retired.md +++ b/.changeset/15204-8post-catalog-gates-retired.md @@ -13,7 +13,7 @@ Clause-②: no Stage 8 retired the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` row objects, so no write to them reaches the engine. This change removes what only guarded those writes. No export of any package's entry changes, and no request that reaches the server is answered differently. - **`@objectstack/plugin-security`.** - - Three of the security middleware's gates on those objects are gone: the package-provenance gate on `sys_permission_set`, the platform/package row gate on `sys_position` and `sys_capability`, and the curated capability-name gate on `sys_capability`. The delegated-administration gate no longer judges `sys_position_permission_set` bindings or `sys_permission_set` authoring; it still governs `sys_user_position` assignments, `sys_user_permission_set` grants and `sys_member`. The rules those gates carried still hold where the catalog now lives: `everyone` and `guest` are code-declared positions an environment cannot overlay, and a capability cannot be created or overlaid through the metadata door at all. The audience-anchor binding gate on `sys_position_permission_set` stays for now, unchanged; an accepted ADR still names it, and it leaves with that record's correction. + - Three of the security middleware's gates on those objects are gone: the package-provenance gate on `sys_permission_set`, the platform/package row gate on `sys_position` and `sys_capability`, and the curated capability-name gate on `sys_capability`. The delegated-administration gate no longer judges `sys_position_permission_set` bindings or `sys_permission_set` authoring; it still governs `sys_user_position` assignments, `sys_user_permission_set` grants and `sys_member`. The rules those gates carried still hold where the catalog now lives: `everyone` and `guest` are code-declared positions an environment cannot overlay, and a capability cannot be created or overlaid through the metadata door at all. - The walled data-door refusal for those objects is deleted. Stage 8 already left it unwired, and no composition registers the objects it judged; the metadata door's walled refusal is unchanged. - The internal duplicate-name refusal class that only the retired permission-set data door raised is removed. - The packaged-permission-set lock's refusal no longer has a "choose a different name" variant: only the retired data door's create raised it. The metadata door's refusal text is unchanged, and the published signatures keep their `operation` parameter. diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 4af023b5427..8ed7733a60e 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -356,9 +356,7 @@ export const MemberDefaultPermissionSet = definePermissionSet({ * may read announcements and file an inquiry (public form intake) — nothing * else. Guest bindings face the STRICTEST lint tier: named objects only (no * wildcard), read-only by default (create case-by-case), never `allowEdit`, - * never VAMA/system permissions. The runtime anchor gate - * (`assertAudienceAnchorBindingGate`) enforces the same rules on the binding - * write itself. + * never VAMA/system permissions. * * Binding is an ADMIN action (Setup → Access Control → bind `guest` → * `showcase_guest_portal`); the app only ships the capability. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c3e47a67be0..78968c37532 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -5708,8 +5708,8 @@ export type PublishMaterializer = (args: { /** * Uninstall-time data-plane cleanup (ADR-0086 D3, #2747). The exact mirror of * {@link PublishMaterializer}: domain plugins own data-plane tables the - * protocol layer must not know the shape of (e.g. plugin-security's - * `sys_permission_set` and its binding tables), so they register a named + * protocol layer must not know the shape of (e.g. plugin-security's grant + * table, `sys_user_permission_set`), so they register a named * cleanup here and {@link ObjectStackProtocolImplementation.deletePackage} * invokes every cleanup with the uninstalled package id. Cleanups run * best-effort — a failure is REPORTED on the uninstall response (`cleanups`), @@ -5944,8 +5944,8 @@ export interface MetaItemPublishedEvent { * 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 + * so a data-plane read-model derived from the metadata (e.g. the + * `datasource` projector's) 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 @@ -14606,11 +14606,12 @@ export class ObjectStackProtocolImplementation implements // The same measurement, one outcome over: `success` was still a // LITERAL for every answer that was not the contract's `false`, so a // row that MATCHED and was deliberately NOT removed reported a - // deletion. `sys_permission_set` is the shipped case — a - // package-declared set's delete is an ADR-0005 RESET, the record - // re-projects to the declared body instead of vanishing — and the - // envelope was byte-identical to a real delete, so a UI fired a - // success toast and showed the row again on refresh. + // deletion. `sys_permission_set` was the shipped case until C3 stage + // 8 retired that object — a package-declared set's delete was an + // ADR-0005 RESET, the record re-projected to the declared body instead + // of vanishing — and the envelope was byte-identical to a real + // delete, so a UI fired a success toast and showed the row again on + // refresh. // // `success` is declared "Whether deletion succeeded", and this is the // only key on `DeleteDataResponseSchema` that can carry the @@ -15807,11 +15808,12 @@ export class ObjectStackProtocolImplementation implements // LITERAL for every result that was not the contract's // `false`, so that row was reported as a deletion too. // - // `sys_permission_set` is the shipped shape of it: deleting - // a package-declared set is an ADR-0005 RESET — the overlay - // tombstones and the record re-projects to the declared body - // instead of vanishing — so the row MATCHED, the write ran, - // and the record is still there. On a security-configuration + // `sys_permission_set` was the shipped shape of it until C3 + // stage 8 retired that object: deleting a package-declared + // set was an ADR-0005 RESET — the overlay tombstoned and the + // record re-projected to the declared body instead of + // vanishing — so the row MATCHED, the write ran, and the + // record was still there. On a security-configuration // write this envelope told an operator a permission set was // gone while it was still being enforced. // @@ -16467,11 +16469,12 @@ export class ObjectStackProtocolImplementation implements // every answer that was not the contract's `false`, so that row // was reported as a deletion too. // - // `sys_permission_set` is the shipped case: a package-declared - // set's delete is an ADR-0005 RESET — plugin-security's - // write-through tombstones the overlay and the record - // re-projects to the declared body instead of vanishing — so the - // row MATCHED, the write ran, and the record is still there. The + // `sys_permission_set` was the shipped case until C3 stage 8 + // retired that object: a package-declared set's delete was an + // ADR-0005 RESET — plugin-security's write-through tombstoned the + // overlay and the record re-projected to the declared body + // instead of vanishing — so the row MATCHED, the write ran, and + // the record was still there. The // batch envelope said `success: true` and counted it in // `succeeded`, which on a security-configuration write tells an // operator a permission set is gone while it is still enforced. @@ -22758,8 +22761,8 @@ export class ObjectStackProtocolImplementation implements 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. + // listeners: a derived read-model (e.g. the `datasource` + // projector's) is already consistent when this save returns. const projectionApplied = await this.runMutationProjector({ type: singularTypeForRepo, name: request.name, diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.ts b/packages/plugins/plugin-security/src/catalog-reference-report.ts index 97027983240..9923a24957b 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.ts @@ -30,7 +30,7 @@ * And it lists PER POSITION, since a definition belongs to the environment and * to no organization: * - * 4. **Positions naming no permission set** — position definitions in the + * 4. **Positions naming a permission set the catalog does not resolve** — position definitions in the * catalog whose `permissionSets` name a set the catalog does not resolve. * The authorization resolver reads a position's sets from its definition, * so a holder of the position is granted nothing through such a name. The diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts index 66186011ef7..22493f8a1e3 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts @@ -6,8 +6,9 @@ * * ## The defect this exists to keep closed * - * `sys_user_position.position` is a position NAME, and `sys_position.name` is - * unique per organization only, so in a single-database multi-org posture + * `sys_user_position.position` is a position NAME, the same string in every + * organization that holds it (ADR-0131 D3: one environment catalog), so in a + * single-database multi-org posture * (ADR-0105 D1 `group` / `isolated`) two organizations' holdings of the same * name are the same string. Two gate reads selected holdings by that name under * a bare `{ isSystem: true }` context with no organization condition. The one diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index d55ecc39ccd..141920cea03 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -107,9 +107,10 @@ function callerOrganizationId(context: any): string | undefined { * `organizationId` undefined (a context carrying no organization) drops nothing. * * Every holding the gate reasons about goes through this, because - * `sys_user_position.position` is a position NAME and `sys_position.name` is - * unique per organization only — so under a walled posture another - * organization's holding of a same-named position is the same string. The + * `sys_user_position.position` is a position NAME, the same string in every + * organization that holds it (ADR-0131 D3: one environment catalog) — so under + * a walled posture another organization's holding of the position is the same + * string. The * gate must count exactly the holdings the runtime grants: counting another * organization's lets a user self-delegate a position they do not hold there; * dropping organization-less ones refuses holdings the runtime honours. diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 681719c610a..a877e80a437 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4360,9 +4360,8 @@ export class SecurityPlugin implements Plugin { }); everyoneMutationWired = true; } - // [#2747] When the owning package is uninstalled, revoke its data-plane permission - // rows (package-owned sets + their position/user bindings + the - // package's suggestion rows) and the grants naming the sets it + // [#2747] When the owning package is uninstalled, remove the + // package's suggestion rows and revoke the grants naming the sets it // declared (`args.declared`, captured by the door before it withdrew // the package) so grants die with the package — the "no ghost // grants" clause of ADR-0090 D5. From a338b13ce3566e73580f40a068ab9fad152e2198 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:49:15 +0000 Subject: [PATCH 12/12] fix(plugin-security): remove the docblock fragment the merge resolution left A stray block-comment opener and two leftover closing braces from the retired anchor gate sat after extractSingleId's docblock; the opener swallowed the braces up to the next docblock. Claude-Session: https://claude.ai/code/session_01KppP2MoD91F3pXiXXrAmcF Co-Authored-By: Claude --- packages/plugins/plugin-security/src/security-plugin.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index a877e80a437..a3c88b7d419 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -6750,10 +6750,6 @@ export class SecurityPlugin implements Plugin { * multi-row predicate and returns `null` (multi-row writes route through the * `*Many` paths, out of scope for the by-id pre-image check). */ - /** - } - } - /** * [commit 6feac910b] Does this object declare that its access — writes included — derives * from a master record (ADR-0055 `controlled_by_parent`)?