From 535abd544d3d785a78c6d72a39cc89b8b18f3e69 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 12:24:22 +0000 Subject: [PATCH] feat(security): lock package-declared permission sets at the save door; clone to customize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling 2026-08-24 (verbatim, untranslated): 「同意 第一步(创业阶段,Salesforce 式)」 — step 1: lock the base, clone to customize. A Studio/API save targeting a package-declared permission set is refused at the server, with a message that names the sanctioned path. Previously the data door translated the write and left the refusal to the metadata protocol's ADR-0005 tier gate — which is exactly what the documented OS_METADATA_WRITABLE=permission operator hatch switches off, so with the hatch open there was no refusal at all and the save minted a sys_metadata overlay of a packaged set that boot reconciliation then re-projected on every boot, forever. Provenance is decided from the engine SchemaRegistry — the one source this plugin already calls "package-declared" — never from a name-keyed page over sys_permission_set, whose unscoped cap truncates into a false "absent" (#11518, untouched here). Fail-closed: a read that cannot answer refuses the save. Not narrowed: ordinary org-owned sets, the activate/deactivate column writes, and a managed_by:'package' row with no artifact behind it (ADR-0094 D5-R's surviving allowRuntimeCreate neighbour). Each is pinned as a control. Existing forks get a detection READING at boot — count and names, warned loudly, reaping nothing. It reads sys_metadata directly rather than `customized`, which is forced false on the exact confounded shape the field report measured. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- .changeset/lock-packaged-permission-set.md | 50 ++ packages/plugins/plugin-security/src/index.ts | 26 + .../src/packaged-permission-set-lock.test.ts | 626 ++++++++++++++++++ .../src/packaged-permission-set-lock.ts | 315 +++++++++ ...d-permission-set-overlay-detection.test.ts | 271 ++++++++ ...ckaged-permission-set-overlay-detection.ts | 170 +++++ .../src/permission-set-projection.ts | 141 +++- .../plugin-security/src/security-plugin.ts | 23 + scripts/engine-double-contract.pinned.json | 10 + 9 files changed, 1619 insertions(+), 13 deletions(-) create mode 100644 .changeset/lock-packaged-permission-set.md create mode 100644 packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts create mode 100644 packages/plugins/plugin-security/src/packaged-permission-set-lock.ts create mode 100644 packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts create mode 100644 packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts diff --git a/.changeset/lock-packaged-permission-set.md b/.changeset/lock-packaged-permission-set.md new file mode 100644 index 0000000000..dbc1d9d4ff --- /dev/null +++ b/.changeset/lock-packaged-permission-set.md @@ -0,0 +1,50 @@ +--- +'@objectstack/plugin-security': minor +--- + +Lock package-declared permission sets at the save door; clone to customize (#11513) + +Maintainer ruling of 2026-08-24, recorded verbatim and untranslated: +「同意 第一步(创业阶段,Salesforce 式)」 — step 1 of the mainstream-platform +comparison: lock the base, clone to customize. + +A Studio/API save that targets a **package-declared** permission set is now +**refused at the server**, with a message that names the sanctioned path — clone +it and edit the clone. Previously the data door translated the write into a +metadata write and left the refusal entirely to the metadata protocol's ADR-0005 +tier gate. That gate is exactly what the documented +`OS_METADATA_WRITABLE=permission` operator hatch switches off, so on a +deployment running with the hatch there was no refusal at all: the save minted a +`sys_metadata` overlay of a packaged set, and boot reconciliation re-projected +that overlay onto the record on every boot, unconditionally, forever — the set +froze at the fork and every future package upgrade of it was ignored, silently. + +**Clone-to-customize** is the sanctioned path and is unchanged: the clone is an +ordinary org-owned set (`managed_by: 'admin'`, no `package_id`, so no upgrade +linkage), and upgrades keep flowing to the package-declared base untouched. + +**Existing forks** get a **detection reading** at boot — count *and names*, +warned loudly, saying outright that nothing was reaped. It reads `sys_metadata` +directly rather than the `customized` column, which is forced `false` on the +exact confounded shape the field report measured (a genuinely package-declared +set whose row's `managed_by` predates provenance tracking). Nothing is reaped, +merged or migrated: disposition of an existing fork is a follow-up reading for +the maintainer, and the per-set remedy remains the explicit, audited +"Discard Overlay" action a human invokes. + +Behaviour deliberately NOT narrowed: + +- an **ordinary org-owned** set is still fully editable (pinned as a control — + a lock that refuses everything would satisfy the refusal pin perfectly); +- the **activate / deactivate** actions still write their column: a bare + `{ active }` patch is row state, not a customization of the definition; +- a `managed_by: 'package'` row with **no artifact behind it** — published + through the metadata door (ADR-0070) and materialized by the ADR-0086 P2 + path — keeps editing in place. That is ADR-0094 D5-R's surviving + `allowRuntimeCreate` neighbour, and `managed_by` is measurably not the + artifact-provenance fact. Provenance is read from the engine SchemaRegistry, + the one source this plugin already calls "package-declared". + +Provenance is **fail-closed**: a read that cannot answer refuses the save rather +than accepting it, and the read is not a name-keyed page over +`sys_permission_set`, so it cannot be truncated into a false "not packaged". diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 2e3764dcfc..7c95e00bdf 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -116,3 +116,29 @@ export type { PermissionSetOverlayDiscardDeps, PermissionSetOverlayDiscardResult, } from './permission-set-overlay-discard.js'; +// [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce 式)」] +// Lock the base, clone to customize: the write-door provenance rule and its +// refusals, plus the DETECTION READING for overlays that already exist. +// ⛔ The reading reaps nothing — disposition of existing forks is a follow-up +// reading for the maintainer. +export { + ENV_PROJECTION_MARKER, + classifyPackagedPermissionSet, + assertPermissionSetNotPackageDeclared, + PackagedPermissionSetLockedError, + PackagedPermissionSetProvenanceUnknownError, +} from './packaged-permission-set-lock.js'; +export type { + PackagedSetVerdict, + LayeredProbe, +} from './packaged-permission-set-lock.js'; +export { + OVERLAY_PAGE_LIMIT, + detectPackagedPermissionSetOverlays, + reportPackagedPermissionSetOverlays, +} from './packaged-permission-set-overlay-detection.js'; +export type { + PackagedPermissionSetOverlayFinding, + PackagedPermissionSetOverlayReading, + OverlayDetectionOptions, +} from './packaged-permission-set-overlay-detection.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 new file mode 100644 index 0000000000..638472077c --- /dev/null +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts @@ -0,0 +1,626 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Maintainer ruling 2026-08-24 (verbatim: 「同意 第一步(创业阶段,Salesforce + * 式)」) — step 1: LOCK THE BASE, CLONE TO CUSTOMIZE. + * + * A Studio/API save targeting a package-declared permission set is refused at + * 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. + * + * (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 (#11518) + * + * `buildExistingByName`'s UNSCOPED page cap (`limit: names.length`, + * `seed-name-lookup.ts`) truncates as soon as one name can carry more than one + * row, and a truncated page reads 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. + * + * So the provenance question is decided from the engine's SchemaRegistry — the + * same source `readDeclared` / `permission-set-overlay-discard.ts` already use, + * 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 #11518 truncates on, 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. + */ + +import { describe, it, expect } from 'vitest'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +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 #11518's + * truncation cannot be reproduced there at all — a page cap that the double + * ignores is a page cap that no test in that file can ever measure. + */ +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 #11518 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 #11518 turns into a false + // "absent". Modelled, not ignored. + return typeof q?.limit === 'number' ? hit.slice(0, q.limit) : hit; + }, + async findOne(object: string, q: any) { + const rows = tableFor(object); + return rows?.find((r) => matches(r, q?.where)) ?? null; + }, + async insert(object: string, data: any) { + const rows = tableFor(object); + if (!rows) return null; + rows.push({ ...data }); + return { id: data.id }; + }, + // 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; + }, + }; + 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, + }; + }, + }; + return protocol; +} + +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', + label: 'Quality Inspector', + objects: { obj_a: { allowRead: true }, obj_b: { allowRead: true } }, + systemPermissions: ['pkg.baseline'], + _packageId: 'com.example.ehr', + ...over, +}); + +/** An env-authored definition — the same body shape, with no package behind it. */ +const orgSet = (over: Record = {}) => ({ + name: 'org_support_agent', + label: 'Support Agent', + objects: { ticket: { allowRead: true } }, + systemPermissions: ['support.use'], + ...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']); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PIN 5 — fail-closed on ambiguity +// ───────────────────────────────────────────────────────────────────────────── + +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 #11518's fail-open +// ───────────────────────────────────────────────────────────────────────────── + +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 live on main at seed-name-lookup.ts + }); + 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. #11518 is 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); + }); +}); diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts new file mode 100644 index 0000000000..40b5b2c09a --- /dev/null +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts @@ -0,0 +1,315 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * LOCK THE BASE, CLONE TO CUSTOMIZE — the server half of the maintainer + * ruling of 2026-08-24, recorded verbatim and untranslated: + * + * > 同意 第一步(创业阶段,Salesforce 式) + * + * Step 1 of the mainstream-platform comparison: a package-declared permission + * set is a LOCKED BASE. A Studio/API save that targets one is refused loudly, + * with a message that names the sanctioned path — clone it and edit the clone. + * The clone is an ordinary org-owned set with no upgrade linkage, so upgrades + * keep flowing to the base untouched. No silent overlay row is ever minted + * again. + * + * ⛔ Explicitly NOT chartered by that ruling, and deliberately absent here: the + * ServiceNow-style explicit overlay layer (badge / customization list / + * diff-vs-base / revert / upgrade skip-report). Recorded as the mature + * direction if customer pull for in-place customization ever appears. + * + * ⛔ Also not chartered: any reap, merge or migration of overlays that already + * exist. {@link detectPackagedPermissionSetOverlays} is a READING — count plus + * names — and it writes nothing. Disposition of existing forks is a follow-up + * reading for the maintainer. + * + * ## The question this module answers, and the read it refuses to use + * + * "Is this permission set package-declared?" is the ONE question the lock + * turns on, so getting the read wrong inverts the whole feature: a read that + * answers "not package-declared" when it merely failed to find out would + * ACCEPT exactly the save this lock exists to refuse — a silent fork produced + * 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`) caps its UNSCOPED page at + * `limit: names.length`, which truncates the moment one name can carry more + * than one row — and a truncated page reads as `absent` (#11518, open at the + * time of writing; ⛔ not fixed here, it belongs to whoever takes it). Under + * this lock that `absent` would read as "not package-declared". + * + * ⭐ The answer comes from the engine's SchemaRegistry instead — the same + * source `bootstrapDeclaredPermissions`' {@link readDeclared} and + * `permission-set-overlay-discard.ts`'s eligibility test already use, so this + * plugin keeps ONE spelling of "package-declared" rather than two (Prime + * Directive #8). That read is an in-memory array: no page, no cap, no `$in`, + * no driver. Truncation is not a failure mode it HAS, which is a structural + * property rather than a promise — `packaged-permission-set-lock.test.ts` + * pins it by failing every name-keyed page read and showing the verdict + * unchanged. + * + * ## Why not `managed_by` + * + * The ruling's parenthetical calls the target "a row carrying package + * provenance", and the `managed_by` column is the obvious reading of that. It + * is also measurably the WRONG fact, in both directions: + * + * - too NARROW — the `provenance_skip` mechanism `permission-set-drift.ts` + * documents is precisely a genuinely package-declared set whose row's + * `managed_by` was never `'package'`. Gating on the column would leave the + * field-reported shape unlocked; + * - too BROAD — a `managed_by:'package'` row whose definition lives only in + * `sys_metadata` (authored and published through the METADATA door, + * ADR-0070, materialized by the ADR-0086 P2 publish path) has no artifact + * behind it. Editing it is a direct edit of the one stored definition; it + * forks nothing, and ADR-0094 D5-R names it the surviving + * `allowRuntimeCreate` neighbour. Locking it would retire a tier the ADR + * keeps on purpose, and would be the over-broad refusal the ruling did not + * ask for. + * + * So provenance is decided from the ARTIFACT, exactly as + * `permission-set-overlay-discard.ts` decided it for the discard action. + * + * ## Fail-closed on ambiguity + * + * {@link classifyPackagedPermissionSet} has THREE verdicts, not two, for the + * same reason `ExistingLookupResult` does: a read that could not ANSWER is not + * the answer "no". Both sources are consulted; if every source that exists + * fails — or none exists at all — the verdict is `unknown`, and the write door + * refuses. Accepting on `unknown` would be a guess on a write door, in the one + * direction that cannot be undone (an overlay, once minted, wins forever). + */ + +/** + * ⛔ 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. + * + * ⚠️ In particular it does not reuse {@link readDeclared}, and that is a + * deliberate strictness difference rather than a second spelling: `readDeclared` + * normalizes a failed registry read to `[]`, which is right for its callers + * (boot seeders that can safely do nothing) and exactly wrong for a write door, + * where `[]` would read as "no package declares this name" and ACCEPT. Same + * source, same member, same `'permission'` type key — the tri-state is what + * differs, and the module header says why. + */ + +/** + * 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. + * + * It lives HERE, and the projection module 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. + */ +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 + * declaration it shadows, and reading it as "declared" would lock a set that + * no package ships. + */ +const RUNTIME_SHADOW_PACKAGE_ID = 'sys_metadata'; + +/** Provenance verdict for one permission-set name. THREE outcomes, not two. */ +export type PackagedSetVerdict = + /** An installed package declares this name — the base is locked. */ + | { status: 'packaged'; packageId: string } + /** No package declares it: an ordinary org-owned set, freely editable. */ + | { status: 'org' } + /** Nothing could answer. The write door refuses; see the module header. */ + | { status: 'unknown'; reason: string }; + +/** + * A layered read the caller already holds for the same name, so the classifier + * can use it as a second artifact source without paying its own round trip. + * + * `failed` is deliberately representable: a caller that TRIED to read and was + * refused must be able to say so, because that is the difference between "no + * artifact" and "no answer". + */ +export type LayeredProbe = + | { status: 'read'; envelope: unknown } + | { status: 'failed'; reason: string }; + +/** Is this registry/layer item a real shipped artifact for `name`? */ +function declaredPackageIdOf(item: any, name: string): string | null { + if (!item || typeof item !== 'object') return null; + if (item.name !== name) return null; + // A projection echo is this plugin's own registry copy of an OVERLAY body — + // never an artifact. Without this skip, minting an overlay would make the + // set look packaged on the next pass, which is a lock that latches on the + // wrong evidence. + if (item[ENV_PROJECTION_MARKER]) return null; + const packageId = item._packageId ?? item.packageId; + if (typeof packageId !== 'string' || packageId === '') return null; + if (packageId === RUNTIME_SHADOW_PACKAGE_ID) return null; + return packageId; +} + +/** + * Decide whether `name` is declared by an installed package. + * + * Sources are consulted in order of authority; the FIRST positive answer wins, + * and `unknown` is reported when every source that exists failed to answer. + * + * 1. the engine SchemaRegistry (`ql.registry.listItems('permission')`) — the + * 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 + * page and no cap here either. + */ +export function classifyPackagedPermissionSet( + name: string, + ql: any, + layered?: LayeredProbe, +): PackagedSetVerdict { + if (typeof name !== 'string' || name === '') { + return { status: 'unknown', reason: 'no permission-set name to resolve provenance for' }; + } + + let anySourceAnswered = false; + const failures: string[] = []; + + // ── source 1: the engine SchemaRegistry ────────────────────────────────── + if (typeof ql?.registry?.listItems === 'function') { + let items: unknown; + let threw = false; + try { + items = ql.registry.listItems('permission'); + } catch (e) { + threw = true; + failures.push(`schema registry read failed (${(e as Error)?.message ?? e})`); + } + if (!threw) { + if (Array.isArray(items)) { + anySourceAnswered = true; + for (const item of items) { + const packageId = declaredPackageIdOf(item, name); + if (packageId) return { status: 'packaged', packageId }; + } + } else { + // ⛔ NOT "nothing is declared". `readDeclared` normalizes a missing + // list to `[]` because its callers are seeders that can safely do + // nothing; a WRITE DOOR cannot, so the non-list is kept as a failure. + failures.push('schema registry returned no list of declared permission sets'); + } + } + } + + // ── source 2: the layered read's `code` layer ──────────────────────────── + if (layered) { + if (layered.status === 'failed') { + failures.push(`layered metadata read failed (${layered.reason})`); + } else { + const envelope: any = layered.envelope; + const isEnvelope = envelope && typeof envelope === 'object' + && ('effective' in envelope || 'overlay' in envelope || 'code' in envelope); + if (isEnvelope) { + anySourceAnswered = true; + const packageId = declaredPackageIdOf(envelope.code, name); + if (packageId) return { status: 'packaged', packageId }; + } else { + failures.push('layered metadata read returned no layer envelope'); + } + } + } + + if (anySourceAnswered) return { status: 'org' }; + return { + status: 'unknown', + reason: failures.length > 0 + ? failures.join('; ') + : 'no artifact source available to decide package provenance', + }; +} + +/** + * The refusal a write door throws for a package-declared set. + * + * `NOT_OVERRIDABLE` / 403 is deliberately the SAME envelope the metadata + * protocol's ADR-0005 tier gate already answers with for this exact condition + * — one condition, one vocabulary (ADR-0112's closed set; the code is a + * StandardErrorCode, so no ledger entry is minted). What changes is the + * MESSAGE: the producer's says the type has not opted into overlay writes, + * which tells an admin nothing they can act on. The ruling's whole point is + * that the refusal teaches the sanctioned path. + * + * ⚠️ The message deliberately does NOT open with `[Security] Access denied`. + * That prefix is a MATCHER (`isPermissionDeniedError`, `mapDataError`, + * `rest-server`'s sanitiser all read it as "this is a 403 PERMISSION_DENIED"), + * and opening with it would re-flatten this refusal's code on the wire — + * see the note in `errors.ts`. + * + * `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. + */ +export class PackagedPermissionSetLockedError extends Error { + readonly code = 'NOT_OVERRIDABLE'; + readonly status = 403; + readonly statusCode = 403; + 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/data/sys_permission_set ` + + `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'; + } +} + +/** Thrown when provenance could not be determined at all — fail-closed. */ +export class PackagedPermissionSetProvenanceUnknownError extends Error { + readonly code = 'NOT_OVERRIDABLE'; + readonly status = 403; + readonly statusCode = 403; + constructor(name: string, reason: string) { + super( + `[Security] Permission set '${name}' cannot be saved right now: this environment could not determine ` + + `whether the set is declared by an installed package (${reason}). The save is refused rather than ` + + `accepted, because accepting it would silently fork a packaged set if it turns out to be one. Retry ` + + `once the metadata layer is readable; if you meant to customize a packaged set, clone it instead.`, + ); + this.name = 'PackagedPermissionSetProvenanceUnknownError'; + } +} + +/** + * The write-door assertion: refuse a save that targets a package-declared set, + * and refuse a save whose provenance cannot be determined. + * + * Returns the verdict on the accept path so a caller can log or branch on it. + */ +export function assertPermissionSetNotPackageDeclared( + name: string, + ql: any, + operation: 'insert' | 'update', + layered?: LayeredProbe, +): PackagedSetVerdict { + const verdict = classifyPackagedPermissionSet(name, ql, layered); + if (verdict.status === 'packaged') { + throw new PackagedPermissionSetLockedError(name, verdict.packageId, operation); + } + if (verdict.status === 'unknown') { + throw new PackagedPermissionSetProvenanceUnknownError(name, verdict.reason); + } + return verdict; +} 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 new file mode 100644 index 0000000000..aecb8940a4 --- /dev/null +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts @@ -0,0 +1,271 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN 4 of the 2026-08-24 ruling — the detection READING for overlays that + * already exist in live deployments. + * + * Ruled item 3, verbatim from the ruling comment: "implementation includes a + * detection reading (count + names, reported loudly — e.g. a boot warning or + * diagnostic listing), but ⛔ no automatic reap/merge — disposition of existing + * forks is a follow-up reading for the maintainer, not a silent migration." + * + * So the pins are, in order of what actually goes wrong: + * + * - ⭐ IDENTITIES, not just a count. Two offsetting errors — one set dropped, + * a different one picked up — hold a count perfectly constant while the + * listing names the wrong sets, and an operator acting on that listing + * would go and look at a healthy set while the forked one stays hidden. So + * every pin below asserts NAMES; + * - ⛔ it reaps NOTHING. Pinned as a data-level fact: the overlay row is still + * there afterwards, and so is the record; + * - the reading is LOUD — it reaches the warn channel with the count and the + * names on it, not just a return value nobody reads; + * - ⭐ the quiet case: an environment with no forks says nothing at all. A + * detector that warns unconditionally is a detector operators learn to + * ignore, which is the same as no detector. + * + * ## It must not depend on `customized`, and that is the card's own measurement + * + * `sys_permission_set.customized` is computed as + * `existing.managed_by === 'package' ? !!customized : false`, so on the exact + * field-reported shape — a genuinely package-declared set whose row's + * `managed_by` predates provenance tracking — it is FORCED FALSE while an + * overlay really is shadowing the row. The card measured it staying `0` for two + * weeks. A reading built on it would report zero forks on the one environment + * that had one. `sys_metadata` is therefore checked directly, exactly as + * #9952's `drift_status` overlay-shadow branch does, and the confounded row is + * a pin below rather than a footnote. + * + * ⛔ Making `customized` itself correct is NOT chartered by this ruling (it is + * listed among the card's *candidates*), and nothing here touches it. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { permissionSetRowFields } from './permission-set-projection.js'; +import { + detectPackagedPermissionSetOverlays, + reportPackagedPermissionSetOverlays, +} from './packaged-permission-set-overlay-detection.js'; + +/** In-memory ql over `sys_permission_set` + `sys_metadata`, read-only paths. */ +function makeQl(declared: any[] = []) { + const permRows: any[] = []; + const metaRows: any[] = []; + const tableFor = (object: string) => + 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 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; + }); + return { + permRows, + metaRows, + registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, + async find(object: string, q: any) { + const rows = tableFor(object); + if (!rows) return []; + const hit = rows.filter((r) => matches(r, q?.where)); + return typeof q?.limit === 'number' ? hit.slice(0, q.limit) : hit; + }, + }; +} + +const declaredSet = (over: Record = {}) => ({ + name: 'ehr_quality_inspector', + label: 'Quality Inspector', + objects: { obj_a: { allowRead: true }, obj_b: { allowRead: true } }, + _packageId: 'com.example.ehr', + ...over, +}); + +const rowFor = (ps: any, over: Record = {}) => ({ + id: `ps_${ps.name}`, + name: ps.name, + managed_by: 'package', + package_id: ps._packageId, + ...permissionSetRowFields(ps), + ...over, +}); + +const overlayFor = (name: string, over: Record = {}) => ({ + id: `meta_${name}`, + type: 'permission', + name, + state: 'active', + organization_id: null, + metadata: JSON.stringify({ name, label: name, objects: {} }), + ...over, +}); + +const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); + +describe('pin 4 — the detection reading NAMES the forked sets', () => { + it('reports the overlay by NAME and package, not merely a count', async () => { + const a = declaredSet(); + const b = declaredSet({ name: 'ehr_billing_clerk' }); + const ql = makeQl([a, b]); + ql.permRows.push(rowFor(a), rowFor(b)); + ql.metaRows.push(overlayFor('ehr_quality_inspector')); + + const reading = await detectPackagedPermissionSetOverlays(ql); + + expect(reading.count).toBe(1); + // ⭐ Identities. A count alone cannot tell "the right set" from "some set". + expect(reading.names).toEqual(['ehr_quality_inspector']); + expect(reading.findings[0]).toMatchObject({ + name: 'ehr_quality_inspector', + packageId: 'com.example.ehr', + }); + expect(reading.findings[0].overlayIds).toEqual(['meta_ehr_quality_inspector']); + // …and the un-forked sibling is NOT named. Without this, a detector that + // returned every declared set would satisfy the assertions above. + expect(reading.names).not.toContain('ehr_billing_clerk'); + }); + + it('names EVERY forked set when several are forked, sorted so the listing is stable', async () => { + const a = declaredSet(); + const b = declaredSet({ name: 'ehr_billing_clerk' }); + const c = declaredSet({ name: 'ehr_admin' }); + const ql = makeQl([a, b, c]); + ql.permRows.push(rowFor(a), rowFor(b), rowFor(c)); + ql.metaRows.push(overlayFor('ehr_quality_inspector'), overlayFor('ehr_admin')); + + const reading = await detectPackagedPermissionSetOverlays(ql); + expect(reading.count).toBe(2); + expect(reading.names).toEqual(['ehr_admin', 'ehr_quality_inspector']); + }); + + 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 + // reading that consulted the column would report zero forks on precisely + // the environment that had one — for two weeks, measured. + const a = declaredSet(); + const ql = makeQl([a]); + ql.permRows.push(rowFor(a, { managed_by: 'user', package_id: null, customized: 0 })); + ql.metaRows.push(overlayFor('ehr_quality_inspector')); + + const reading = await detectPackagedPermissionSetOverlays(ql); + expect(reading.names).toEqual(['ehr_quality_inspector']); + expect( + ql.permRows[0].customized, + "the column still reads 'not customized' — which is exactly why it is not the source", + ).toBeFalsy(); + }); + + it('an overlay whose name NO package declares is not reported — it is an ordinary env-authored set', async () => { + // The counter-direction. Every env-authored permission set has a + // `sys_metadata` row; a reading that listed them all would report a healthy + // environment as entirely forked, and be discarded within a day. + const ql = makeQl([declaredSet()]); + ql.metaRows.push(overlayFor('org_support_agent')); + const reading = await detectPackagedPermissionSetOverlays(ql); + expect(reading.count).toBe(0); + expect(reading.names).toEqual([]); + }); + + it('the legacy plural type spelling is read too', async () => { + const a = declaredSet(); + const ql = makeQl([a]); + ql.permRows.push(rowFor(a)); + ql.metaRows.push(overlayFor('ehr_quality_inspector', { id: 'meta_plural', type: 'permissions' })); + const reading = await detectPackagedPermissionSetOverlays(ql); + expect(reading.names).toEqual(['ehr_quality_inspector']); + }); + + 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. + const a = declaredSet(); + const ql = makeQl([a]); + ql.permRows.push(rowFor(a)); + ql.metaRows.push(overlayFor('ehr_quality_inspector', { organization_id: 'org_1' })); + const reading = await detectPackagedPermissionSetOverlays(ql); + expect(reading.count).toBe(0); + }); +}); + +describe('⛔ pin 4 — the reading REAPS NOTHING', () => { + it('the overlay row and the record are both still there afterwards', async () => { + const a = declaredSet(); + const ql = makeQl([a]); + ql.permRows.push(rowFor(a)); + ql.metaRows.push(overlayFor('ehr_quality_inspector')); + const metaBefore = JSON.stringify(ql.metaRows); + const permBefore = JSON.stringify(ql.permRows); + + await reportPackagedPermissionSetOverlays(ql, { logger: logger() }); + + expect(ql.metaRows.length, 'the overlay row is untouched — disposition is the maintainer\'s call').toBe(1); + expect(JSON.stringify(ql.metaRows)).toBe(metaBefore); + expect(JSON.stringify(ql.permRows)).toBe(permBefore); + }); + + it('the double exposes no write verb at all, so a reap could not even be expressed', async () => { + // Stronger than asserting the rows survived: this harness has no `insert`, + // `update` or `delete`. If a reap is ever added to this reading, it fails + // here with a TypeError instead of quietly passing on rows that happened + // not to match. + const ql = makeQl([declaredSet()]); + expect((ql as any).update).toBeUndefined(); + expect((ql as any).delete).toBeUndefined(); + expect((ql as any).insert).toBeUndefined(); + await expect(reportPackagedPermissionSetOverlays(ql, { logger: logger() })).resolves.toBeTruthy(); + }); +}); + +describe('pin 4 — the reading is LOUD, and quiet when there is nothing to say', () => { + it('warns with the count AND the names on the meta', async () => { + const a = declaredSet(); + const ql = makeQl([a]); + ql.permRows.push(rowFor(a)); + ql.metaRows.push(overlayFor('ehr_quality_inspector')); + const log = logger(); + + await reportPackagedPermissionSetOverlays(ql, { logger: log }); + + expect(log.warn).toHaveBeenCalledTimes(1); + const [message, meta] = log.warn.mock.calls[0]; + expect(String(message)).toMatch(/package-declared permission set/i); + // ⭐ The names travel WITH the warning. A count-only line sends an operator + // looking through every set they own. + expect(meta).toMatchObject({ count: 1, names: ['ehr_quality_inspector'] }); + // …and it says outright that nothing was reaped, so nobody reads the line + // as "handled". + expect(String(message)).toMatch(/nothing (has been |was )?(reaped|removed|changed)/i); + }); + + it('⭐ says NOTHING on a clean environment', async () => { + const a = declaredSet(); + const ql = makeQl([a]); + ql.permRows.push(rowFor(a)); + const log = logger(); + const reading = await reportPackagedPermissionSetOverlays(ql, { logger: log }); + expect(reading.count).toBe(0); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('a kernel with no readable artifact registry reports nothing rather than guessing', async () => { + // No SchemaRegistry means no way to know which names a package declares. + // The READING is not a write door, so the fail-closed direction here is the + // opposite one: naming sets it cannot prove are packaged would send + // operators after env-authored work. It stays silent, which is honest. + const ql: any = makeQl([]); + delete ql.registry; + ql.metaRows.push(overlayFor('ehr_quality_inspector')); + const log = logger(); + const reading = await reportPackagedPermissionSetOverlays(ql, { logger: log }); + expect(reading.count).toBe(0); + expect(log.warn).not.toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 0000000000..564e70088f --- /dev/null +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The DETECTION READING for permission sets that were already silently forked + * before the lock existed — item 3 of the maintainer ruling of 2026-08-24 + * (「同意 第一步(创业阶段,Salesforce 式)」), quoted from the ruling comment: + * + * > **Existing silent overlays** (already-forked rows in live deployments): + * > implementation includes a detection reading (count + names, reported + * > loudly — e.g. a boot warning or diagnostic listing), but ⛔ no automatic + * > reap/merge — disposition of existing forks is a follow-up reading for the + * > maintainer, not a silent migration. + * + * ⛔ So this module READS. It has no write verb, takes no `update`/`delete` + * path, and its test double deliberately exposes none either — a reap added + * here would fail with a `TypeError` rather than pass quietly. The disposition + * of an existing fork is the maintainer's call, and the sanctioned per-set + * remedy already exists as an explicit, audited operator action + * (`permission-set-overlay-discard.ts`), invoked one set at a time by a human. + * + * ## Why this is not `permission-set-drift.ts` + * + * The drift diagnostic (#9952) reports a package-declared set whose ENFORCED + * grants currently DIFFER from the shipped artifact, and attributes the cause. + * That is a different question, and it is strictly narrower: an overlay taken + * at a moment when it happened to equal the artifact is invisible to it — + * `recordDiffersFromBody` says "no difference", so nothing is reported — while + * the fork is entirely real and will freeze the set the instant the package + * ships its next version. This reading answers the ruling's question instead: + * WHICH package-declared sets carry an environment overlay at all, whether or + * not it has diverged yet. + * + * ## ⛔ It does not consult `customized`, and that is measured, not stylistic + * + * `upsertEnvPermissionSet` computes 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 + * `provenance_skip`) — that forces it FALSE while an overlay really is + * shadowing the row. The card measured it reading `0` for two weeks while the + * overlay froze the set. A reading built on it would have reported zero forks + * on the one environment that had one. `sys_metadata` is therefore read + * directly, exactly as #9952's `drift_status` overlay-shadow branch does. + * + * ⛔ Making `customized` itself correct is NOT chartered by this ruling — the + * card lists it among its *candidates* and the ruling did not take it up. + * Nothing here reads or writes that column. + * + * ## 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; + * - "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; + * - ⚠️ the overlay sweep is a page capped at {@link OVERLAY_PAGE_LIMIT}, the + * same cap and the same read the reconciler and the drift diagnostic + * already perform. A truncation there UNDER-reports this listing. That is a + * real limit and it is stated here rather than left to be discovered: it is + * tolerable because this is a diagnostic reading — under-reporting costs an + * operator a name, where the same truncation on the write door would cost a + * 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'; + +/** + * 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. + */ +export const OVERLAY_PAGE_LIMIT = 1000; + +/** One package-declared set that an environment overlay has forked. */ +export interface PackagedPermissionSetOverlayFinding { + /** The permission set's machine name — the identity an operator acts on. */ + name: string; + /** The package that declares it. */ + packageId: string; + /** The `sys_metadata` row id(s) carrying the overlay. Never acted on here. */ + overlayIds: string[]; +} + +/** The reading: how many, and — the part that matters — WHICH. */ +export interface PackagedPermissionSetOverlayReading { + count: number; + /** Sorted, so two boots of the same environment produce the same listing. */ + names: string[]; + findings: PackagedPermissionSetOverlayFinding[]; +} + +export interface OverlayDetectionOptions { + logger?: ProjectionLogger; +} + +/** + * Compute (never write) the listing of package-declared permission sets that + * an environment overlay is currently forking. + */ +export async function detectPackagedPermissionSetOverlays( + ql: any, + _opts: OverlayDetectionOptions = {}, +): Promise { + if (!ql || typeof ql.find !== 'function') return { count: 0, names: [], findings: [] }; + + // Both type spellings, same as the reconciler and the drift diagnostic. + const overlayIdsByName = new Map(); + for (const type of ['permission', 'permissions']) { + const rows = await tryFind(ql, 'sys_metadata', { type, state: 'active' }, OVERLAY_PAGE_LIMIT); + for (const r of rows) { + if ((r?.organization_id ?? null) !== null || !r?.name) continue; // env-wide only + const name = String(r.name); + const ids = overlayIdsByName.get(name); + if (ids) ids.push(String(r.id)); else overlayIdsByName.set(name, [String(r.id)]); + } + } + + const findings: PackagedPermissionSetOverlayFinding[] = []; + for (const [name, overlayIds] of overlayIdsByName) { + const verdict = classifyPackagedPermissionSet(name, ql); + // ⚠️ `packaged` ONLY. The fail-safe direction for a READING is the opposite + // of the write door's: naming a set this environment cannot PROVE is + // package-declared would send an operator after somebody's env-authored + // work, so `org` and `unknown` are both silent here. The write door refuses + // on `unknown` for the mirror-image reason — there, silence would mint the + // fork this whole feature exists to prevent. + if (verdict.status !== 'packaged') continue; + findings.push({ name, packageId: verdict.packageId, overlayIds }); + } + + findings.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + return { count: findings.length, names: findings.map((f) => f.name), findings }; +} + +/** + * Compute + report. What boot wiring calls. + * + * Loud when there is something to say, and SILENT when there is not: a boot + * line that fires unconditionally is a line operators learn to skip, which is + * indistinguishable from having no detector at all (the same rule + * `persistPermissionSetDriftDiagnostics` follows by writing `null` for an + * in-sync set rather than a quiet "ok" badge). + * + * `warn`, not `error`: nothing is broken and nothing failed — the environment + * is in a state the maintainer has to decide about. And the line says outright + * that nothing was reaped, so no one reads it as "handled". + */ +export async function reportPackagedPermissionSetOverlays( + ql: any, + opts: OverlayDetectionOptions = {}, +): Promise { + const reading = await detectPackagedPermissionSetOverlays(ql, opts); + if (reading.count === 0) return reading; + opts.logger?.warn?.( + `[security] ${reading.count} package-declared permission set(s) are being shadowed by an environment ` + + 'overlay — each one was forked from its package before the save door was locked, so its grants are ' + + 'frozen at the overlay and every future package upgrade of that set will be ignored, silently. ' + + 'DETECTION ONLY: nothing was reaped, removed or changed by this reading. To resync one set to its ' + + 'shipped artifact, use the audited "Discard Overlay" action on it; to keep the customization, clone the ' + + 'set and reassign to the clone.', + { count: reading.count, names: reading.names, findings: reading.findings }, + ); + return reading; +} diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index d4fd09dd0b..3bd4fb2628 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -44,14 +44,31 @@ * `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 is deliberately LEFT TO THE PRODUCER. This file does not - * pre-empt it by re-deriving artifact-backing: `isArtifactBacked` is the - * protocol's rule (it excludes the `'sys_metadata'` rehydration sentinel), - * and a second copy here would be the parallel-allowlist failure Prime - * Directive #8 exists to prevent. The `managed_by` column is measurably - * NOT that fact — `member_default`'s row is `managed_by:'admin'` and its - * edit is refused, `twodoors_pkgset`'s row is `managed_by:'package'` and - * its edit lands; + * - 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; 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, + * `permission-set-overlay-discard.ts`'s eligibility test, 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 @@ -70,6 +87,11 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { seedCtx } from './per-organization-catalog.js'; import { buildExistingByName, type ExistingByNameIndex } from './seed-name-lookup.js'; +import { + ENV_PROJECTION_MARKER, + assertPermissionSetNotPackageDeclared, + type LayeredProbe, +} from './packaged-permission-set-lock.js'; export const SYSTEM_CTX = { isSystem: true }; @@ -341,8 +363,13 @@ function touchesDefinition(payload: Record): boolean { * 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. */ -const ENV_PROJECTION_MARKER = '_envProjection'; /** Strip layered-read / registry decorations so a re-authored body is clean. */ function stripDecorations(body: any): any { @@ -860,10 +887,27 @@ export function mergeRowPatchIntoBody(base: any, patch: Record): an return pickSpecDeclaredKeys(body); } -/** Effective (layered, overlay-wins) body for a record's name, else the row itself. */ -async function effectiveBodyForRow(protocol: any, ql: any, row: any): Promise { +/** + * 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 = await protocol.getMetaItemLayered({ type: 'permission', name: row.name }); + 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); @@ -905,6 +949,31 @@ export function createPermissionSetWriteThrough( ): (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, + }; + } + }; + 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 @@ -1013,6 +1082,17 @@ export function createPermissionSetWriteThrough( err.status = 409; throw err; } + // [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. + assertPermissionSetNotPackageDeclared( + name, ql, 'insert', (await probeLayered(protocol, name)).probe, + ); // The metadata write is the authoritative one; spec validation // (PermissionSetSchema) runs inside saveMetaItem and rejects an // off-contract body with a structured 422. @@ -1052,10 +1132,45 @@ export function createPermissionSetWriteThrough( // 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 ("the type has not opted into per-org overlay writes") names + // 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 `permission-set-overlay-discard.ts` and + // `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 base = await effectiveBodyForRow(protocol, ql, row); + const base = await effectiveBodyForRow(protocol, ql, row, layeredByName.get(String(row.name))); const body = mergeRowPatchIntoBody(base, patch); body.name = row.name; await protocol.saveMetaItem({ type: 'permission', name: row.name, item: body, ...actorArg }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index bdc78464bf..b6355b3a1e 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -36,6 +36,7 @@ import { reconcilePermissionSetProjection, } from './permission-set-projection.js'; import { runPermissionSetDriftDiagnostics } from './permission-set-drift.js'; +import { reportPackagedPermissionSetOverlays } from './packaged-permission-set-overlay-detection.js'; import { discardPermissionSetOverlay, type PermissionSetOverlayDiscardDeps } from './permission-set-overlay-discard.js'; import { registerObjectPostureGate } from './object-posture-gate.js'; import { @@ -3203,6 +3204,28 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] permission-set drift diagnostics failed', { error: (e as Error).message }); } + // [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce + // 式)」, item 3] The DETECTION READING for sets that were already + // silently forked before the save door was locked: count + NAMES, + // reported loudly. ⛔ Reaps nothing, merges nothing, migrates + // nothing — disposition of an existing fork is a follow-up reading + // for the maintainer, and the per-set remedy is the explicit, + // audited "Discard Overlay" action a human invokes. + // + // Distinct from the drift diagnostic above and deliberately run + // beside it: drift reports a set whose grants ALREADY differ from + // the artifact, so an overlay that currently happens to match is + // invisible to it — while that fork is entirely real and freezes + // the set the moment the package ships its next version. + // + // Own try/catch, same discipline as its neighbours: a failure here + // must never take down boot, and must never read as "the drift + // diagnostics also failed". + try { + await reportPackagedPermissionSetOverlays(ql, { logger: ctx.logger }); + } catch (e) { + ctx.logger.warn('[security] packaged permission-set overlay detection failed', { error: (e as Error).message }); + } } catch (e) { ctx.logger.warn('[security] permission publish-materializer registration failed', { error: (e as Error).message }); } diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 6b0ee72942..8a1d5cadf4 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1441,6 +1441,16 @@ "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": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/per-organization-catalog.test.ts", "verb": "update",