diff --git a/.changeset/21789-lock-reads-row-provenance.md b/.changeset/21789-lock-reads-row-provenance.md new file mode 100644 index 00000000000..1961f05f0d8 --- /dev/null +++ b/.changeset/21789-lock-reads-row-provenance.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-security': patch +--- + +A permission set an organization owns, a clone of a packaged set, and a set saved into a writable runtime package are no longer locked as if a code package shipped them + +Clause-②: no + +The packaged-permission-set lock decides "is this set shipped by a code package?" from the engine registry. The registry also holds the stored definition rows, and a metadata list read (`GET /api/v1/meta/permission`, which every Studio page load issues) stamps a stored row's package binding onto it. A set saved into a writable runtime package (`PUT /api/v1/meta/permission/:name?package=`) therefore looked code-shipped after the first list read, and every later edit of it answered `403 NOT_OVERRIDABLE` at both the metadata door and the data door. The lock now skips a stored row by its provenance (`_provenance: 'org'`, which every stored row carries), the same test the platform's code-artifact check applies, so those edits are accepted again. + +The read had the matching defect. The security plugin keeps a marked in-memory copy of each stored definition for the permission evaluator, and the layered read (`GET /api/v1/meta/permission/:name/layers`) serves that copy as the item's `code` layer. The copy carried no provenance, so an org's own set, a clone and a runtime-package set all reported a `code` layer with no `provenance`, which the console's permission-matrix editor renders as "locked by a code package" while the server accepted the save. The copy now carries `_provenance: 'org'` exactly when the lock judges the set not code-shipped, so the layered read reports `provenance: 'org'` for those sets. + +Unchanged: a set a code package ships is still refused at both doors with `403 NOT_OVERRIDABLE` and the same message naming the clone path, and its layered read still reports `provenance: 'package'`, its package id and `editable: false`. No error code, route or field moves. 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 6693f5cc717..6947f61f787 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 @@ -77,6 +77,8 @@ import { describe, it, expect } from 'vitest'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; +import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; import { createPermissionSetWriteThrough, permissionSetRowFields, @@ -885,3 +887,101 @@ describe('control B — the provenance read is not a name-keyed table read at al expect(ql.metaRows.length).toBe(0); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// [#21789] The lock reads the ROW's provenance, not the presence of a package id +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The registry holds stored rows as well as artifacts. The metadata layer + * hydrates every env-wide `sys_metadata` row into it (a list read, and the + * boot) as `mergeArtifactProtection(stateTenantAuthorship(row), artifact)`: the + * row's `package_id` column stamped on as `_packageId`, `_provenance: 'org'` + * 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). + */ +const RUNTIME_PACKAGE = 'app.crm_workspace'; +/** Shape 1: a set saved into a writable runtime package, as hydrated. */ +const hydratedRuntimePackageRow = (over: Record = {}) => ({ + ...orgSet({ name: 'workspace_rep' }), + _packageId: RUNTIME_PACKAGE, + _provenance: 'org', + ...over, +}); +/** Shapes 2 and 3: the org's own set, and a clone, as hydrated (no package column). */ +const hydratedOrgRow = (over: Record = {}) => ({ ...orgSet(), _provenance: 'org', ...over }); +/** A code-shipped artifact as the loader registers it (`applyProtection` with a package id). */ +const shippedArtifact = (over: Record = {}) => packagedSet({ _provenance: 'package', ...over }); + +describe('[#21789] the lock judges "shipped by a code package" from the row\'s provenance', () => { + it('a set in a writable runtime package, hydrated with its package id, is NOT packaged', () => { + expect(classifyPackagedPermissionSet('workspace_rep', makeQl([hydratedRuntimePackageRow()]))) + .toEqual({ status: 'org' }); + }); + + it('an org-owned set and a clone, hydrated as stored rows, are NOT packaged', () => { + const ql = makeQl([hydratedOrgRow(), hydratedOrgRow({ name: 'ehr_quality_inspector_local' })]); + expect(classifyPackagedPermissionSet('org_support_agent', ql)).toEqual({ status: 'org' }); + expect(classifyPackagedPermissionSet('ehr_quality_inspector_local', ql)).toEqual({ status: 'org' }); + }); + + it('control: a code-shipped set stays packaged, alone and beside a stored row of its name', () => { + expect(classifyPackagedPermissionSet('ehr_quality_inspector', makeQl([shippedArtifact()]))) + .toEqual({ status: 'packaged', packageId: 'com.example.ehr' }); + // A legacy overlay of a shipped name is hydrated wearing the artifact's + // envelope, and the artifact is in the same list: still packaged, in either order. + const legacyOverlay = shippedArtifact({ systemPermissions: ['overlaid'] }); + for (const items of [[legacyOverlay, shippedArtifact()], [shippedArtifact(), legacyOverlay]]) { + expect(classifyPackagedPermissionSet('ehr_quality_inspector', makeQl(items))) + .toEqual({ status: 'packaged', packageId: 'com.example.ehr' }); + } + }); + + it('the layered read\'s code layer is judged the same way when there is no registry', () => { + const ql = makeQl(null); + const read = (code: unknown) => ({ status: 'read' as const, envelope: { code, overlay: null, effective: code } }); + expect(classifyPackagedPermissionSet('workspace_rep', ql, read(hydratedRuntimePackageRow()))) + .toEqual({ status: 'org' }); + expect(classifyPackagedPermissionSet('ehr_quality_inspector', ql, read(shippedArtifact()))) + .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); + + 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() })) + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); +}); 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 9ce83485fec..020d604935e 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts @@ -87,6 +87,8 @@ * direction that cannot be undone (an overlay, once minted, wins forever). */ +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 @@ -146,7 +148,24 @@ export type LayeredProbe = | { status: 'read'; envelope: unknown } | { status: 'failed'; reason: string }; -/** Is this registry/layer item a real shipped artifact for `name`? */ +/** + * Is this registry/layer item a real shipped artifact for `name`? + * + * [#21789] A package id on the item does not answer that by itself. The + * registry holds stored rows as well as artifacts: the metadata layer hydrates + * env-wide `sys_metadata` rows into it, and the list read (`getMetaItems`) + * stamps the row's `package_id` column onto the body as `_packageId` on the + * way. A set saved into a writable runtime package therefore carries a package + * id just as a code-shipped one does, and reading "has a package id" as + * "shipped by code" locked it after the first list read. What tells the two apart is the + * provenance the hydrator writes on every stored row (ADR-0010 + * `_provenance: 'org'`), read through `isTenantAuthored`: the exclusion + * `isCodeArtifactBody` and `SchemaRegistry.getArtifactItem` already apply, so + * this is the platform's one answer, not a second one. A stored row of a name a + * code package ships is hydrated wearing the artifact's envelope + * (`_provenance: 'package'`), and the artifact itself is in the same list, so a + * code-shipped set stays locked. + */ function declaredPackageIdOf(item: any, name: string): string | null { if (!item || typeof item !== 'object') return null; if (item.name !== name) return null; @@ -155,6 +174,9 @@ function declaredPackageIdOf(item: any, name: string): string | null { // set look packaged on the next pass, which is a lock that latches on the // wrong evidence. if (item[ENV_PROJECTION_MARKER]) return null; + // A stored (tenant-authored) row is never a shipped artifact, whatever + // package it is bound to. See the doc comment. + if (isTenantAuthored(item)) return null; const packageId = item._packageId ?? item.packageId; if (typeof packageId !== 'string' || packageId === '') return null; if (packageId === RUNTIME_SHADOW_PACKAGE_ID) return null; diff --git a/packages/plugins/plugin-security/src/permission-set-projection.test.ts b/packages/plugins/plugin-security/src/permission-set-projection.test.ts index 41754bf54d0..944ef6ee9c2 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -419,6 +419,40 @@ describe('projectPermissionMutation (the awaited projector)', () => { 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); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 63b23a6a8e9..9310106e6d4 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -97,6 +97,7 @@ import type { PermissionSetNameCollisionDiagnostic } from './permission-set-name import { ENV_PROJECTION_MARKER, assertPermissionSetNotPackageDeclared, + classifyPackagedPermissionSet, type LayeredProbe, } from './packaged-permission-set-lock.js'; import { PermissionSetNameConflictError } from './errors.js'; @@ -723,6 +724,21 @@ export async function upsertEnvPermissionSet( * 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. + * * Best-effort: when the facade lacks `registerInMemory`, overlay-only names * still resolve via the DatabaseLoader / record dbLoader. */ @@ -731,12 +747,14 @@ async function syncEvaluatorRegistry( name: string, body: any, overlayBacked: boolean, + tenantAuthored = false, ): Promise { try { if (!metadata || typeof metadata.registerInMemory !== 'function' || !name) return; if (overlayBacked && body?.name) { metadata.registerInMemory('permission', name, { ...stripDecorations(body), + ...(tenantAuthored ? { _provenance: 'org' } : {}), [ENV_PROJECTION_MARKER]: true, }); return; @@ -829,6 +847,9 @@ export async function projectPermissionMutation( 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') { const layered = await protocol.getMetaItemLayered({ type: 'permission', @@ -846,6 +867,7 @@ export async function projectPermissionMutation( 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); @@ -885,7 +907,11 @@ export async function projectPermissionMutation( // 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) { - await syncEvaluatorRegistry(metadata, evt.name, body, overlayBacked); + // [#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'; + await syncEvaluatorRegistry(metadata, evt.name, body, overlayBacked, tenantAuthored); } return out; } diff --git a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts new file mode 100644 index 00000000000..8704f64ddb0 --- /dev/null +++ b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21789] The packaged-permission-set lock fires only for a set a code +// (artifact) package ships, judged from the row's provenance — and the read +// the console renders from reports the same judgment the write doors enforce. +// Over the real showcase composition, across a cold boot on one database file. +// +// ## What was broken +// +// The lock (`packaged-permission-set-lock.ts`, plugin-security) asked the +// engine registry "does any item of this name carry a package id?". The +// registry holds stored rows as well as artifacts, and a list read +// (`GET /meta/permission`) hydrates every env-wide `sys_metadata` row into it +// with the row's `package_id` column stamped onto the body as `_packageId`. +// So a set saved into a writable runtime package read as +// code-shipped after the first list read, and every edit of it answered +// `403 NOT_OVERRIDABLE` at both doors. The provenance that tells the two apart +// was on the same body all along: the hydrator writes `_provenance: 'org'` on +// every stored row (ADR-0010), and `isCodeArtifactBody` already reads it. +// +// The READ had its own half. The security plugin syncs an overlay-backed body +// into the metadata manager as a marked "projection echo", and the protocol's +// layered read serves that echo as the item's `code` layer. The echo carried +// no provenance, so an org's own set, a clone, and a runtime-package set all +// reported a code layer with no provenance, which is how objectui's +// permission-matrix editor reads "a code package ships this": it rendered them +// locked while the doors accepted the save. The echo now carries the lock's +// verdict (`_provenance: 'org'` exactly when the lock answers `org`). +// +// Measured on `origin/main` (088428fb) through this file's own steps before the +// fix: the runtime-package set's registry row was +// `{ _packageId: , _provenance: 'org' }` after the list read, +// then `PUT /meta/permission/?package=` and +// `PATCH /data/sys_permission_set/` both answered 403 `NOT_OVERRIDABLE`; +// all three shapes' layered reads served a `code` layer with no provenance and +// no envelope `provenance`, beside `editable: true`. +// +// ## The pins hold both directions +// +// The lock's declared population is code-shipped sets. This file pins the three +// shapes the card names as editable (the metadata door, the data door, and the +// layered read's report), and pins that a set the showcase package ships is +// still refused at both doors with the same code and status and is still +// reported code-shipped by the read. +// +// ## Why a booted stack, booted twice +// +// The lock's input has one producer, the list read: boot 1 issues it and +// asserts the runtime package was stamped onto the set's registry row BEFORE it +// asserts any edit lands — without that precondition an accepted edit would +// prove nothing. (The boot's own hydration does not stamp the package id; that +// was measured, so no cold-boot leg pretends to exercise it.) The read's input +// has a second producer: the projection echo is minted again by the boot's +// reconciliation rather than by a save, so boot 2, on the same file, reads the +// three shapes again before anything is written. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { securityObjects } from '@objectstack/plugin-security'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); +const SYS = { context: { isSystem: true } } as const; +const API_BASE = '/api/v1'; + +/** A writable runtime package, created through `POST /packages`. */ +const PKG = 'com.dogfood.lock21789'; +/** Shape 1 — a set saved into that runtime package. */ +const PKG_SET = 'lock21789_pkgset'; +/** Shape 2 — a set the org created through the data door. */ +const ORG_SET = 'lock21789_orgset'; +/** Shape 3 — a "Clone to customize" clone of a packaged set. */ +const CLONE = 'lock21789_clone'; +/** The control: a set the showcase package (`com.example.showcase`) ships. */ +const SHIPPED = 'showcase_contributor'; +const SHIPPED_PACKAGE = 'com.example.showcase'; + +interface Shape { label: string; name: string; packageQuery: string } +const SHAPES: Shape[] = [ + { label: 'runtime-package set', name: PKG_SET, packageQuery: `?package=${PKG}` }, + { label: 'org-owned set', name: ORG_SET, packageQuery: '' }, + { label: 'clone', name: CLONE, packageQuery: '' }, +]; + +interface CloneParam { name?: string; field?: string; defaultFromRow?: boolean } +interface CloneAction { method: string; target: string; bodyExtra?: Record; params: CloneParam[] } + +/** The shipped Clone action, read off the object the security plugin registers. */ +function cloneAction(): CloneAction { + const object = (securityObjects as any[]).find((o) => o?.name === 'sys_permission_set'); + const action = (object?.actions ?? []).find((a: any) => a?.name === 'clone_permission_set'); + if (!action) throw new Error('clone_permission_set is missing from sys_permission_set.actions'); + return action as CloneAction; +} + +/** Both doors answer this refusal as `{ error: , code }`: the code is top-level. */ +const refusalCode = (json: any): unknown => json?.code; + +describe('[#21789] the permission-set lock reads the row\'s provenance — the three org-owned shapes edit, a code-shipped set stays refused (showcase)', () => { + let prevCwd: string; + let dir: string; + let dbFile: string; + let stack: VerifyStack | undefined; + let token: string; + let ql: any; + + const registryRows = (name: string): any[] => + (ql.registry.listItems('permission') ?? []).filter((i: any) => i?.name === name); + + const layered = async (name: string): Promise => { + const res = await stack!.apiAs(token, 'GET', `/meta/permission/${name}/layers`); + expect(res.status, `layered read of ${name}`).toBe(200); + const json: any = await res.json(); + return json?.data ?? json; + }; + + /** A metadata-door save of the whole definition, through the door the console's editor uses. */ + const putDefinition = async (shape: Pick, label: string) => { + const res = await stack!.apiAs(token, 'PUT', `/meta/permission/${shape.name}${shape.packageQuery}`, { + name: shape.name, + label, + objects: { showcase_task: { allowRead: true } }, + }); + return { status: res.status, json: await res.json().catch(() => ({})) }; + }; + + /** A data-door edit of the record, the way Setup saves it. */ + const patchRecord = async (name: string, description: string) => { + const [row] = await ql.find('sys_permission_set', { where: { name }, limit: 1 }, SYS); + expect(row?.id, `the ${name} record exists`).toBeTruthy(); + const res = await stack!.apiAs(token, 'PATCH', `/data/sys_permission_set/${row.id}`, { description }); + return { status: res.status, json: await res.json().catch(() => ({})), id: row.id as string }; + }; + + beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + dir = mkdtempSync(join(tmpdir(), 'dogfood-21789-')); + dbFile = join(dir, 'showcase.db'); + stack = await bootStack(showcaseStack, { databaseFile: dbFile }); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + // Shape 1: a writable runtime package, and a set saved into it. + const pkg = await stack.apiAs(token, 'POST', '/packages', { + manifest: { id: PKG, name: 'Lock provenance probe', version: '0.1.0', type: 'app' }, + }); + expect(pkg.status, JSON.stringify(await pkg.clone().json().catch(() => ({})))).toBe(201); + const saved = await putDefinition({ name: PKG_SET, packageQuery: `?package=${PKG}` }, 'Runtime package set'); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + + // Shape 2: the org's own set, created through the data door. + const created = await stack.apiAs(token, 'POST', '/data/sys_permission_set', { + name: ORG_SET, + label: 'Org-owned set', + object_permissions: JSON.stringify({ showcase_task: { allowRead: true } }), + }); + expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); + + // Shape 3: a clone, made the way the Setup dialog makes it — the payload + // is built from the shipped action's own declaration. + const [base] = await ql.find('sys_permission_set', { where: { name: SHIPPED }, limit: 1 }, SYS); + const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`); + const served: any = await read.json(); + const row = served?.record ?? served?.data?.record ?? served?.data; + const action = cloneAction(); + const body: Record = { ...(action.bodyExtra ?? {}) }; + for (const p of action.params) { + if (p.name === 'label') body.label = 'Contributor (clone)'; + else if (p.name === 'name') body.name = CLONE; + else if (p.field && p.defaultFromRow) body[p.field] = row?.[p.field]; + } + expect(action.target.startsWith(API_BASE), action.target).toBe(true); + const cloned = await stack.apiAs(token, action.method, action.target.slice(API_BASE.length), body); + expect(cloned.status, JSON.stringify(await cloned.clone().json().catch(() => ({})))).toBe(201); + + // The producer the card names: the list read a Studio page load issues. + const list = await stack.apiAs(token, 'GET', '/meta/permission'); + expect(list.status).toBe(200); + }, 300_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('precondition: the list read stamped the runtime package onto its set\'s registry row, as a stored row', () => { + // ⛔ Without this, an accepted edit below proves nothing: a list read + // that stopped stamping the package id would also leave the set editable. + const rows = registryRows(PKG_SET); + expect(rows.map((r) => ({ _packageId: r._packageId ?? null, _provenance: r._provenance ?? null }))) + .toEqual([{ _packageId: PKG, _provenance: 'org' }]); + }); + + for (const shape of SHAPES) { + it(`${shape.label}: the metadata door accepts an edit, and the edit lands`, async () => { + const label = `${shape.label} (edited at the metadata door)`; + const put = await putDefinition(shape, label); + expect(put.status, JSON.stringify(put.json)).toBe(200); + expect((await layered(shape.name))?.effective?.label).toBe(label); + }); + + it(`${shape.label}: the data door accepts an edit, and the edit lands`, async () => { + const description = `${shape.label} (edited at the data door)`; + const patch = await patchRecord(shape.name, description); + expect(patch.status, JSON.stringify(patch.json)).toBe(200); + const [after] = await ql.find('sys_permission_set', { where: { id: patch.id }, limit: 1 }, SYS); + expect(after?.description).toBe(description); + }); + + it(`${shape.label}: the layered read reports it editable and tenant-authored, not code-shipped`, async () => { + // The console's artifact test reads the `code` layer and the + // envelope's `provenance`; `'org'` is the value it carves out. + const env = await layered(shape.name); + expect({ editable: env?.editable, provenance: env?.provenance }).toEqual({ editable: true, provenance: 'org' }); + }); + } + + it('control: a set the showcase package ships is still refused at both doors with 403 NOT_OVERRIDABLE', async () => { + const put = await putDefinition({ name: SHIPPED, packageQuery: '' }, 'Contributor (customized)'); + expect({ status: put.status, code: refusalCode(put.json) }, JSON.stringify(put.json)) + .toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + const patch = await patchRecord(SHIPPED, 'customized'); + expect({ status: patch.status, code: refusalCode(patch.json) }, JSON.stringify(patch.json)) + .toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + }); + + it('control: the layered read still reports the shipped set as code-shipped and not editable', async () => { + const env = await layered(SHIPPED); + expect({ + editable: env?.editable, + provenance: env?.provenance, + packageId: env?.packageId, + codePackageId: env?.code?._packageId, + }).toEqual({ editable: false, provenance: 'package', packageId: SHIPPED_PACKAGE, codePackageId: SHIPPED_PACKAGE }); + }); + + describe('after a cold boot on the same database file', () => { + // The echo the read serves is minted again here by the boot's + // reconciliation, not by a save: nothing is written before the reads. + beforeAll(async () => { + await stack?.stop(); + stack = undefined; + stack = await bootStack(showcaseStack, { databaseFile: dbFile }); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 300_000); + + it('the echo the boot mints carries the lock\'s verdict: all three shapes read editable and tenant-authored', async () => { + for (const shape of SHAPES) { + const env = await layered(shape.name); + expect({ name: shape.name, editable: env?.editable, provenance: env?.provenance }) + .toEqual({ name: shape.name, editable: true, provenance: 'org' }); + } + }); + + it('control: the shipped set is still refused at the metadata door with 403 NOT_OVERRIDABLE', async () => { + const put = await putDefinition({ name: SHIPPED, packageQuery: '' }, 'Contributor (customized)'); + expect({ status: put.status, code: refusalCode(put.json) }, JSON.stringify(put.json)) + .toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + }); + }); +});