From 6e261f76a80018f1f175bb5ad0860d9367c47e47 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:33:09 +0000 Subject: [PATCH 1/7] fix(plugin-security): stamp package provenance on the rows of package-held declared positions The declared-position seeder wrote its rows with no provenance stamp, so they carried the object default (admin) and the system-row write gate did not protect them: a data-door edit of a package-declared position's label answered 200 and the next boot wrote the declaration back over it. The metadata door refuses the same edit (403 NOT_OVERRIDABLE). A position a code package holds -- asked of the registry's artifact lookup, the one the metadata door refuses a save from -- is now stamped managed_by 'package' on insert, and an existing row lacking a managed value (an upgraded deployment's) is corrected in place, managed_by and nothing else. The gate is unchanged. An environment-authored definition keeps an unmanaged row. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../src/bootstrap-declared-positions.test.ts | 115 ++++++++++++++- .../src/bootstrap-declared-positions.ts | 139 ++++++++++++++++-- 2 files changed, 242 insertions(+), 12 deletions(-) diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts index 5f822d73ae1..34c1dbbcb2a 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts @@ -91,8 +91,9 @@ describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)', expect(r.seeded).toBe(1); const row = ql.rows[0]; expect(row).toMatchObject({ name: 'contributor', label: 'Contributor', active: true, is_default: false }); - // Provenance is NOT stamped by the declared seeder (bootstrapBuiltinRoles - // owns the built-in anchors; declared positions carry the object default). + // No code package holds this name (the list registry names none), so the + // row gets no provenance stamp and carries the object default. The stamp + // a package-held name gets is pinned in the #22360 block below. expect(row.managed_by).toBeUndefined(); }); @@ -297,3 +298,113 @@ describe('bootstrapDeclaredPositions — one catalog read, both sources (ADR-013 expect(ql.rows).toEqual([]); }); }); + +/** + * [#22360] The row of a position a code package holds carries the package's + * provenance, so the system-row write gate refuses an admin-door edit of it — + * as the metadata door already refuses one (ADR-0131 D6). Before this the row + * carried the object default (`admin`): a data-door label edit answered 200 + * and the next boot wrote the declaration back over it. + * + * The registry is the real `SchemaRegistry`, so "a package holds the name" is + * the registry's own artifact lookup — the one the metadata door refuses a + * save from — not a fixture's opinion. + */ +describe('bootstrapDeclaredPositions — package provenance on a package-held name (#22360)', () => { + const PKG = 'com.example.pkg'; + const packaged = (...items: Array>) => { + const registry = new SchemaRegistry(); + for (const item of items) registry.registerItem('position', { ...item }, 'name', PKG); + return registry; + }; + /** The payloads the pass handed to `update`, as it handed them. */ + const recordUpdates = (ql: ReturnType) => { + const sent: any[] = []; + const update = ql.update.bind(ql); + ql.update = async (object: string, data: any) => { sent.push({ object, data: { ...data } }); return update(object, data); }; + return sent; + }; + + it('stamps a new row package-managed', async () => { + const ql = makeQl([], packaged({ name: 'field_rep', label: 'Field Rep' })); + const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); + expect(ql.rows.map((row) => [row.name, row.managed_by])).toEqual([['field_rep', 'package']]); + expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 }); + }); + + it('corrects an upgraded deployment\'s admin-stamped row in place: managed_by and nothing else', async () => { + const ql = makeQl([], packaged({ name: 'field_rep', label: 'Field Rep', description: 'In the field' })); + // The row a pre-fix boot wrote, with the columns an administrator set since. + const before = { + id: 'pos_1', name: 'field_rep', label: 'Field Rep', description: 'In the field', + managed_by: 'admin', active: false, is_default: true, delegatable: true, + }; + ql.rows.push({ ...before }); + const sent = recordUpdates(ql); + const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); + expect(sent).toEqual([{ object: 'sys_position', data: { id: 'pos_1', managed_by: 'package' } }]); + expect(ql.rows).toEqual([{ ...before, managed_by: 'package' }]); + expect(r).toEqual({ seeded: 0, updated: 1, unchanged: 0, unreadable: 0 }); + }); + + it('refreshes drifted display text and corrects the stamp in one write', async () => { + const ql = makeQl([], packaged({ name: 'field_rep', label: 'Field Rep v2' })); + ql.rows.push({ id: 'pos_1', name: 'field_rep', label: 'Field Rep', description: null, managed_by: 'admin' }); + const sent = recordUpdates(ql); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); + expect(sent.map((s) => s.data)).toEqual([ + { id: 'pos_1', label: 'Field Rep v2', description: null, managed_by: 'package' }, + ]); + }); + + it('a stamped row is left alone by the next pass', async () => { + const ql = makeQl([], packaged({ name: 'field_rep', label: 'Field Rep' })); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); + const sent = recordUpdates(ql); + const again = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); + expect(sent).toEqual([]); + expect(again).toEqual({ seeded: 0, updated: 0, unchanged: 1, unreadable: 0 }); + }); + + it('a row the write gate already treats as managed keeps its value', async () => { + const names = ['kept_platform', 'kept_system', 'kept_config']; + const ql = makeQl([], packaged(...names.map((name) => ({ name, label: name })))); + ql.rows.push( + { id: 'pos_p', name: 'kept_platform', label: 'kept_platform', description: null, managed_by: 'platform' }, + { id: 'pos_s', name: 'kept_system', label: 'kept_system', description: null, managed_by: 'system' }, + { id: 'pos_c', name: 'kept_config', label: 'kept_config', description: null, managed_by: 'config' }, + ); + const sent = recordUpdates(ql); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); + expect(sent).toEqual([]); + expect(ql.rows.map((row) => row.managed_by)).toEqual(['platform', 'system', 'config']); + }); + + // Control: a position the environment authored through the metadata door is + // hydrated into the registry with no package, and that door lets its author + // edit it — so its row stays unmanaged, exactly as before. + it('control: an environment-authored definition keeps an unmanaged row', async () => { + const registry = new SchemaRegistry(); + registry.registerItem('position', { name: 'door_authored', label: 'Door Authored' }, 'name'); + registry.registerItem('position', { name: 'door_kept', label: 'Door Kept' }, 'name'); + const ql = makeQl([], registry); + ql.rows.push({ id: 'pos_k', name: 'door_kept', label: 'Door Kept', description: null, managed_by: 'admin' }); + const sent = recordUpdates(ql); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); + expect(sent).toEqual([]); + const byName = Object.fromEntries(ql.rows.map((row) => [row.name, row.managed_by])); + expect(byName).toEqual({ door_kept: 'admin', door_authored: undefined }); + }); + + // A registry without the artifact lookup is asked the way the metadata door + // asks one: the definition names a package, and not the rehydration sentinel. + it('a registry without the artifact lookup: a named package stamps, the sys_metadata sentinel does not', async () => { + const ql = makeQl([ + { name: 'shipped', label: 'Shipped', _packageId: PKG }, + { name: 'rehydrated', label: 'Rehydrated', _packageId: 'sys_metadata' }, + ]); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); + const byName = Object.fromEntries(ql.rows.map((row) => [row.name, row.managed_by])); + expect(byName).toEqual({ shipped: 'package', rehydrated: undefined }); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts index 186c393ec63..85be9d2de13 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts @@ -25,6 +25,12 @@ * over the driver's compatibility arm and leaves the strict equality alone. * `single` posture keeps exactly one organization-less pass. Doctrine, and the * loud guard that stands in place of a reap: `per-organization-catalog.ts`. + * + * [#22360] PROVENANCE: the row of a position a code package holds is stamped + * `managed_by: 'package'` — on insert, and on an existing row that lacks a + * managed value — so the system-row write gate refuses an admin-door edit or + * delete of it, as the metadata door already refuses one (ADR-0131 D6). See + * {@link packageProvenanceStamp}. */ import { createSecurityCatalogReader } from '@objectstack/core'; @@ -140,22 +146,119 @@ function isSeededHere(item: { name?: unknown } | undefined): boolean { * [#8378] No `{ name, content }` unwrap: the catalog entry's `definition` IS the * authoring document — see `bootstrap-declared-permissions.ts`. It is the * reader's own object, shared with every other reader, and is only read here. + * + * [#22360] Each definition comes back with whether a code package holds its + * name ({@link packageHoldsName}), asked here, before the pass writes + * anything, so a registry that cannot answer fails the pass the way an + * unreadable catalog does instead of failing it half-way through. */ -async function readDeclaredPositions(engine: any, metadataService: any): Promise { +async function readDeclaredPositions( + engine: any, + metadataService: any, +): Promise> { const catalog = createSecurityCatalogReader({ registry: engine?.registry, metadata: metadataService }); const listed = await catalog.list('position'); - return listed.filter(isSeededHere).map((entry) => entry.definition); + return listed.filter(isSeededHere).map((entry) => ({ + definition: entry.definition, + packageHeld: packageHoldsName(engine?.registry, entry), + })); } /** * The columns a re-seed writes. Position IDENTITY + display only: the record - * side (bindings, `active`, `is_default`, `delegatable`, `managed_by`) belongs - * to the runtime/admin and is never projected from the declaration (#2909 T2). + * side (bindings, `active`, `is_default`, `delegatable`) belongs to the + * runtime and is never projected from the declaration (#2909 T2). + * + * `managed_by` is not projected either — a position declaration has no such + * key. It is written separately, by {@link packageProvenanceStamp}, and only + * to record WHO holds the name. */ function positionRowFields(r: any): { label: any; description: any } { return { label: r.label ?? r.name, description: r.description ?? null }; } +/** + * The provenance value this seeder stamps on the row of a position a package + * holds — the value the permission-set and capability seeders stamp on their + * declared rows, and one the system-row write gate refuses an admin-door + * update or delete of (`SYSTEM_ROW_PROVENANCE`, security-plugin.ts). + */ +const PACKAGE_PROVENANCE = 'package'; + +/** + * The `managed_by` values the system-row write gate already treats as managed + * on `sys_position` — `SYSTEM_ROW_PROVENANCE` in security-plugin.ts, canonical + * and legacy spellings both. Keep the two in lockstep (see the same note in + * `normalize-managed-by.ts`). A row carrying one of these is never re-stamped: + * `platform`/`system` are the built-in seeder's, and a legacy `config` row is + * already guarded and is healed to `package` by the boot normalizer. + */ +const GATE_MANAGED_VALUES: ReadonlySet = new Set(['platform', 'package', 'system', 'config']); + +/** + * Does a code package hold this position name? + * + * Asked of the engine registry's artifact lookup (`getArtifactItem`), which + * answers only an item a code package registered — an environment definition + * saved through the metadata door carries no package there. It is the same + * lookup the metadata door refuses a save of the name from (`403 + * NOT_OVERRIDABLE`), so the data door now refuses what the metadata door + * refuses, and no more: a position the environment authored keeps an + * unmanaged row, editable as before. + * + * A registry without that lookup is asked the way the metadata door asks one + * (`lookupArtifactItem`, metadata-protocol): the definition must name a + * package, and not the `sys_metadata` rehydration sentinel. + */ +function packageHoldsName(registry: any, entry: { name: string; packageId?: string }): boolean { + if (typeof registry?.getArtifactItem === 'function') { + return registry.getArtifactItem('position', entry.name) !== undefined; + } + return typeof entry.packageId === 'string' && entry.packageId !== '' && entry.packageId !== 'sys_metadata'; +} + +/** + * [#22360] The provenance a pass writes on a declared position's row: the + * `package` stamp when a package holds the name and the row does not already + * carry a managed value, nothing otherwise. + * + * ## Why the row needs it + * + * Before this, a declared position's row carried the object default + * (`admin`), so the system-row write gate did not protect it: a data-door edit + * of its label answered `200`, and the next boot wrote the declaration's label + * back over it with no message. The metadata door refused the same edit + * (`403 NOT_OVERRIDABLE`). ADR-0131 D6: no door edits a managed definition; + * D3: managed items are read-only and clonable. With the stamp, the data door + * refuses through the gate that already protects the six built-ins. + * + * ## Why this is not a #2909 T2 projection + * + * T2 locks that a re-seed never writes the record-authoritative columns from + * the declaration (bindings, `delegatable`, `active`, `is_default`). The stamp + * is not declaration content — a position declaration has no `managed_by` — + * and it overwrites no admin edit: `managed_by` is `readonly`, and the gate + * refuses an admin-door payload naming `platform`/`package`, so the `admin` + * on a declared row was only ever the object default. It records which door + * owns the name, the fact the metadata door already reads. + * + * ## The existing row of an upgraded deployment + * + * A declared row written before this carries `admin`. The pass corrects the + * stamp in place — `managed_by` and nothing else beyond the label and + * description refresh it always made — so the edit is refused there too, and + * the columns an administrator set before the upgrade stay as they are. + * ⚠️ That row is matched by NAME, as the label refresh always matched it: a + * Setup-created position whose name a package declares later is taken over by + * the package here, and from then on refused at the data door like any other + * package position. + */ +function packageProvenanceStamp(packageHeld: boolean, existing?: any): { managed_by?: string } { + if (!packageHeld) return {}; + if (existing && GATE_MANAGED_VALUES.has(String(existing.managed_by ?? ''))) return {}; + return { managed_by: PACKAGE_PROVENANCE }; +} + /** True when the stored row differs from what a re-seed would write (#10946). */ function positionRecordDiffers(row: any, fields: { label: any; description: any }): boolean { return (row?.label ?? null) !== (fields.label ?? null) @@ -170,8 +273,10 @@ export async function bootstrapDeclaredPositions( if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { return { seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }; } - const positions = await readDeclaredPositions(ql, metadataService); - if (positions.length === 0) return { seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }; + const declared = await readDeclaredPositions(ql, metadataService); + if (declared.length === 0) return { seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }; + const positions = declared.map((d) => d.definition); + const packageHeld = new Map(declared.map((d) => [String(d.definition?.name), d.packageHeld])); // [#10946] ONE existence read for the whole declaration, before the loop. // See `seed-name-lookup.ts` for why a read that cannot ANSWER must never be @@ -194,6 +299,9 @@ export async function bootstrapDeclaredPositions( let updated = 0; let unchanged = 0; let unreadable = 0; + // [#22360] Existing rows whose provenance this pass corrected to `package` — + // counted inside `updated` as well, and named in the pass's info line. + let restamped = 0; // One log per pass, not per refused row: a legacy platform-wide unique index // refuses EVERY declared position, and a line each would bury the remedy. const refusals = createSeedWriteRefusals(); @@ -212,6 +320,7 @@ export async function bootstrapDeclaredPositions( // no-op the per-organization catalog exists to prevent. if (lookup.status === 'absent' && lookup.organizationLessResidue) residue.push(String(r.name)); const existing = lookup.status === 'present' ? lookup.row : undefined; + const heldByPackage = packageHeld.get(String(r.name)) === true; if (existing?.id) { // [#10946] Only write when the stored row actually differs. An // unconditional UPDATE here cost two remote round trips per position on @@ -221,16 +330,24 @@ export async function bootstrapDeclaredPositions( // description drifted from the declaration still gets its UPDATE. Only // the display fields are compared because only the display fields are // written — the record-authoritative columns (`active`, `is_default`, - // `delegatable`, `managed_by`) are deliberately never touched by a - // re-seed (#2909 T2), so they can neither cause nor suppress one. - if (!positionRecordDiffers(existing, fields)) { + // `delegatable`) are deliberately never touched by a re-seed (#2909 T2), + // so they can neither cause nor suppress one. + // + // [#22360] The one other column a pass writes is the provenance stamp, + // and only where it is missing (`packageProvenanceStamp`): an upgraded + // deployment's declared row, stamped `admin` by the object default. + const display = positionRecordDiffers(existing, fields) ? fields : {}; + const stamp = packageProvenanceStamp(heldByPackage, existing); + if (Object.keys(display).length === 0 && Object.keys(stamp).length === 0) { unchanged += 1; - } else if (await tryUpdate(ql, 'sys_position', { id: existing.id, ...fields }, organizationId, refusals)) { + } else if (await tryUpdate(ql, 'sys_position', { id: existing.id, ...display, ...stamp }, organizationId, refusals)) { updated += 1; + if (stamp.managed_by) restamped += 1; } } else { const row = { id: genId('position'), name: r.name, ...fields, active: true, is_default: false, + ...packageProvenanceStamp(heldByPackage), }; const created = await tryInsert(ql, 'sys_position', row, organizationId, refusals); if (created) { @@ -260,6 +377,8 @@ export async function bootstrapDeclaredPositions( } options.logger?.info?.('[security] declared positions seeded into sys_position', { seeded, updated, unchanged, unreadable, total: positions.length, + // Present only when a row was corrected, so a routine boot's line is unchanged. + ...(restamped > 0 ? { restampedPackageProvenance: restamped } : {}), ...(organizationId ? { organization: organizationId } : {}), }); return { seeded, updated, unchanged, unreadable }; From 5c75248225a67ff2a7c6a3e7913dc525e3b86c45 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:36:42 +0000 Subject: [PATCH 2/7] test(dogfood): a package-declared position is refused at the data door and keeps its declared label across a cold boot Over the real showcase composition under single, two boots of one database file: the declared row carries package provenance, a data-door label edit is refused with the gate's code and leaves the row unchanged, an upgraded deployment's admin-stamped row is re-stamped at boot and refused the same way. Controls: administrator- and environment-authored positions stay editable, a built-in's refusal is unchanged, and the seeder's own system write still refreshes a declared row's display text. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- ...clared-position-provenance.dogfood.test.ts | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts diff --git a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts new file mode 100644 index 00000000000..be23709d650 --- /dev/null +++ b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22360] A position the showcase package declares is refused at the data +// door, the way the metadata door already refuses it, and keeps its declared +// label across a cold boot on one database file — over the real showcase +// composition under `single`. +// +// ## What was broken +// +// The declared-position seeder wrote its rows with no provenance stamp, so they +// carried the object default (`managed_by: 'admin'`) and the system-row write +// gate did not protect them. Measured on `origin/main` before the fix, through +// this file's own steps: `PATCH /api/v1/data/sys_position/:id` of `manager`'s +// label answered 200 and the row carried the new label; the next boot wrote the +// declared label back over it, with no message. The metadata door refused the +// same edit (403 `NOT_OVERRIDABLE`). +// +// ## What each case pins +// +// - precondition: the showcase package holds the name, and the metadata door +// refuses an edit of it; +// - the declared row carries `managed_by: 'package'`, and a data-door edit of +// its label is refused with the gate's code, the row unchanged; +// - after a cold boot the row still carries the declared label; +// - an upgraded deployment's declared row (stamped `admin` before the fix) is +// re-stamped at boot — the columns an administrator set before are kept — +// and then refused the same way; +// - controls: an administrator-authored position and an environment-authored +// one (saved through the metadata door) stay editable; a built-in's refusal +// is unchanged; the seeder's own system write still refreshes a declared +// row's display text from the declaration. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +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 SHOWCASE_PACKAGE = 'com.example.showcase'; + +/** Positions the showcase package declares, with the labels it declares them with. */ +const declaredLabel = (name: string): string => { + const declared = ((showcaseStack as any).positions ?? []).find((p: any) => p?.name === name); + if (!declared?.label) throw new Error(`the showcase no longer declares a labelled position '${name}'`); + return declared.label; +}; +const EDITED = 'manager'; +const UPGRADED = 'exec'; +const DRIFTED = 'contributor'; +const ADMIN_AUTHORED = 'p22360_admin_authored'; +const ENV_AUTHORED = 'p22360_env_authored'; + +describe('[#22360] a package-declared position is refused at the data door and survives a cold boot (showcase)', () => { + let prevCwd: string; + let dir: string; + let dbFile: string; + let stack: VerifyStack | undefined; + let token: string; + let ql: any; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack!.apiAs(token, method, path, body); + const json: any = await res.json().catch(() => ({})); + return { status: res.status, code: json?.code ?? null, json }; + }; + const row = async (name: string) => + ((await ql.find('sys_position', { where: { name }, limit: 2 }, SYS)) as any[]); + const start = async () => { + stack = await bootStack(showcaseStack, { databaseFile: dbFile }); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }; + + beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + dir = mkdtempSync(join(tmpdir(), 'dogfood-22360-')); + dbFile = join(dir, 'showcase.db'); + await start(); + }, 300_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('PRECONDITION: single posture; the showcase package holds the name, and the metadata door refuses an edit of it', async () => { + expect(stack!.tenancy().requestedPosture).toBe('single'); + expect((ql.registry.getArtifactItem('position', EDITED) as any)?._packageId).toBe(SHOWCASE_PACKAGE); + const put = await call('PUT', `/meta/position/${EDITED}`, { name: EDITED, label: 'Manager (metadata door)' }); + expect({ status: put.status, code: put.code }, JSON.stringify(put.json)).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + }); + + it('the declared row carries package provenance', async () => { + const rows = await row(EDITED); + expect(rows.map((r) => ({ label: r.label, managed_by: r.managed_by }))) + .toEqual([{ label: declaredLabel(EDITED), managed_by: 'package' }]); + }); + + it('a data-door edit of its label is refused with the gate\'s code, and the row is unchanged', async () => { + const [before] = await row(EDITED); + const edited = await call('PATCH', `/data/sys_position/${before.id}`, { label: 'Manager (edited in Setup)' }); + expect({ status: edited.status, code: edited.code }, JSON.stringify(edited.json)) + .toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect((await row(EDITED)).map((r) => r.label)).toEqual([declaredLabel(EDITED)]); + }); + + it('control: an administrator-authored position stays editable', async () => { + const created = await call('POST', '/data/sys_position', { name: ADMIN_AUTHORED, label: 'Admin authored' }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + const [r] = await row(ADMIN_AUTHORED); + expect(r.managed_by).toBe('admin'); + const edited = await call('PATCH', `/data/sys_position/${r.id}`, { label: 'Admin authored (edited)' }); + expect(edited.status, JSON.stringify(edited.json)).toBe(200); + expect((await row(ADMIN_AUTHORED)).map((x) => x.label)).toEqual(['Admin authored (edited)']); + }); + + it('control: a built-in\'s refusal is unchanged', async () => { + const [everyone] = await row('everyone'); + expect(everyone.managed_by).toBe('platform'); + const edited = await call('PATCH', `/data/sys_position/${everyone.id}`, { label: 'Everyone (edited)' }); + expect({ status: edited.status, code: edited.code }, JSON.stringify(edited.json)) + .toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + }); + + it('after a cold boot: the declared label stands, an upgraded deployment\'s row is re-stamped and refused, the seeder still refreshes display text', async () => { + // The upgraded shape: the row a pre-fix boot wrote (the object default, + // `admin`) carrying a column an administrator set before the upgrade. + const [upgraded] = await row(UPGRADED); + await ql.update('sys_position', { id: upgraded.id, managed_by: 'admin', delegatable: true }, SYS); + expect((await row(UPGRADED)).map((r) => [r.managed_by, r.delegatable])).toEqual([['admin', true]]); + // Drifted display text, which only a system write can reach now. + const [drifted] = await row(DRIFTED); + await ql.update('sys_position', { id: drifted.id, label: 'Contributor (stale)' }, SYS); + // An environment-authored position: a metadata-door save, whose row the + // seeder writes at the next boot. + const saved = await call('PUT', `/meta/position/${ENV_AUTHORED}`, { name: ENV_AUTHORED, label: 'Environment authored' }); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + + await stack!.stop(); + await start(); + + expect((await row(EDITED)).map((r) => ({ label: r.label, managed_by: r.managed_by }))) + .toEqual([{ label: declaredLabel(EDITED), managed_by: 'package' }]); + + const [restamped] = await row(UPGRADED); + expect({ managed_by: restamped.managed_by, delegatable: restamped.delegatable, label: restamped.label }) + .toEqual({ managed_by: 'package', delegatable: true, label: declaredLabel(UPGRADED) }); + const refused = await call('PATCH', `/data/sys_position/${restamped.id}`, { label: 'Executive (edited in Setup)' }); + expect({ status: refused.status, code: refused.code }, JSON.stringify(refused.json)) + .toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + + expect((await row(DRIFTED)).map((r) => r.label)).toEqual([declaredLabel(DRIFTED)]); + }); + + it('controls after the cold boot: administrator- and environment-authored positions keep unmanaged, editable rows', async () => { + expect((await row(ADMIN_AUTHORED)).map((r) => [r.label, r.managed_by])).toEqual([['Admin authored (edited)', 'admin']]); + const [env] = await row(ENV_AUTHORED); + expect(env?.managed_by, 'the seeder wrote the environment definition\'s row').toBe('admin'); + const edited = await call('PATCH', `/data/sys_position/${env.id}`, { label: 'Environment authored (edited)' }); + expect(edited.status, JSON.stringify(edited.json)).toBe(200); + }); +}); From 8c3e68a2b50fe4439daa826a5c2eb5d4eda40abf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:40:36 +0000 Subject: [PATCH 3/7] chore(changeset): plugin-security refuses a data-door edit of a package-declared position Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- ...60-declared-position-package-provenance.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .changeset/22360-declared-position-package-provenance.md diff --git a/.changeset/22360-declared-position-package-provenance.md b/.changeset/22360-declared-position-package-provenance.md new file mode 100644 index 00000000000..41a5bf8d094 --- /dev/null +++ b/.changeset/22360-declared-position-package-provenance.md @@ -0,0 +1,30 @@ +--- +'@objectstack/plugin-security': minor +--- + +fix(plugin-security)!: a position a code package declares is refused at the data door, as the metadata door already refuses it; its row now carries package provenance + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing of the `sys_position` data door, shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 (no door edits a managed definition) and D3 (managed items are read-only and clonable). + +**What was wrong.** The declared-position seeder wrote the row of a package-declared position with no provenance stamp, so the row carried the object default, `managed_by: 'admin'`, and the system-row write gate did not protect it. A Setup or data API edit of its label answered `200`, and the next boot wrote the declared label back over it with no message. The metadata door already refused the same edit with `403 NOT_OVERRIDABLE`. + +**What is refused now.** A position is package-declared when the engine registry's artifact lookup answers a code package for its name, the same lookup the metadata door refuses a save from. On such a position's row, the admin door now gets the refusal it already gets on a built-in position, `403 PERMISSION_DENIED`: + +- an update of any column. That is the label and description, and also `active`, `is_default` and `delegatable`, so Setup's Activate, Deactivate and Set as Default actions on such a position are refused. Until now those three landed and persisted. +- a delete. Until now it answered `200` and the next boot created the row again. + +System-context writes are unchanged: the boot seeder still refreshes the row's label and description from the declaration. + +**What is unchanged.** + +- A position an administrator created in Setup, and a position the environment authored through the metadata door, keep an unmanaged row (`admin`) and stay editable. +- The built-in positions are refused exactly as before. +- Assigning a position to users and binding permission sets to it are writes on other rows (`sys_user_position`, `sys_position_permission_set`), and this change does not touch them. + +**At the first boot after upgrading.** The existing row of each package-declared position is re-stamped `package` in place when it carries `admin`, no value, or the legacy `user`. Only `managed_by` changes: the `active`, `is_default` and `delegatable` values an administrator set before the upgrade are kept. The boot's `declared positions seeded` info line counts these rows as `restampedPackageProvenance`. The seeder matches a row by name, as its label refresh always has, so a position created in Setup whose name a package declares later becomes that package's position and is refused the same way. + +**What to do.** To change a package-declared position, change it in the package and publish a new version. To have a position you can edit in Setup, clone it under a new name with the Clone Position action, bind its permission sets, and assign the clone. From 16fe3da3c0dcf5f4f1c01b9e83b0dd6aeff7116c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:25:49 +0000 Subject: [PATCH 4/7] fix(plugin-security): a row-state-only patch of a package-provided position passes the system-row write gate A package position row is locked the way a packaged permission set is: a patch touching only active and/or is_default on a sys_position row stamped package (or its legacy config spelling) passes, single-id or filtered, as permission-set-projection's write door passes a bare { active } patch on a packaged set. Definition columns (name, label, description, delegatable), any other column, delete and the other lifecycle verbs stay refused with the gate's code and message. Platform rows and sys_capability get no carve-out; the provenance-stamp refusal is unchanged. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../plugin-security/src/security-plugin.ts | 71 +++++- ...em-row-write-gate-package-position.test.ts | 226 ++++++++++++++++++ 2 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 packages/plugins/plugin-security/src/system-row-write-gate-package-position.test.ts diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 5777136ffba..05177cb0b61 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -7359,6 +7359,10 @@ export class SecurityPlugin implements Plugin { * driver. * (c) admin-authored rows (`managed_by` user/∅/admin) are untouched — the * admin fully owns those (incl. a delegate's rows in their own subtree). + * (d) [#22360] ONE carve-out from (b): an `update` of a PACKAGE-provided + * `sys_position` row whose patch touches only ROW STATE passes — see + * {@link SecurityPlugin.PACKAGE_POSITION_ROW_STATE_COLUMNS}. Platform + * rows and `sys_capability` get no carve-out. * Fails CLOSED and never depends on the caller's grants, so a superuser with * modifyAllRecords cannot delete a platform position either. */ @@ -7395,17 +7399,27 @@ export class SecurityPlugin implements Plugin { if (!this.ql) return; + // (d) Whether this write is the row-state-only update the carve-out admits. + const rowStateOnly = SecurityPlugin.isPackagePositionRowStatePatch(opCtx); + const targetId = this.extractSingleId(opCtx); if (targetId == null) { // Multi-row / filter write with no single id. Deny ONLY if a managed row // actually falls within the write's own filter — so a bulk edit that // targets only admin-authored rows still succeeds (no over-broad block). A // whole-table write (no filter) matches every managed row, so it is denied. + // + // (d) A row-state-only patch asks only about the rows the carve-out does + // not cover: package positions in the filter no longer refuse it, a + // platform position in the filter still does. + const guardedValues = rowStateOnly + ? managedValues.filter((v) => !SecurityPlugin.PACKAGE_POSITION_PROVENANCE.has(v)) + : managedValues; const writeWhere = opCtx?.options?.where; const managedWhere = writeWhere && typeof writeWhere === 'object' - ? { $and: [writeWhere, { managed_by: { $in: managedValues } }] } - : { managed_by: { $in: managedValues } }; + ? { $and: [writeWhere, { managed_by: { $in: guardedValues } }] } + : { managed_by: { $in: guardedValues } }; // [#7505] No `.catch(() => null)` — see the sibling gate: a swallowed // fault made this asset guard answer "nothing managed matched" and admit // the write. Fail-closed: propagate. @@ -7427,6 +7441,8 @@ export class SecurityPlugin implements Plugin { ? String((existing as Record).managed_by ?? '') : ''; const ownerLabel = spec.managed[existingManagedBy]; + // (d) The carve-out: a row-state-only patch of a package-provided position. + if (existing && rowStateOnly && SecurityPlugin.PACKAGE_POSITION_PROVENANCE.has(existingManagedBy)) return; if (existing && ownerLabel) { const row = existing as Record; const source = ownerLabel === 'the platform' ? 'platform definition' : 'application package'; @@ -7439,6 +7455,57 @@ export class SecurityPlugin implements Plugin { } } + /** + * [#22360] The ROW-STATE columns of `sys_position`: the one named allowlist + * the carve-out (d) of {@link SecurityPlugin.assertSystemRowWriteGate} admits + * on a package-provided position. + * + * A package position row is locked the way a packaged permission set is. + * `permission-set-projection.ts`'s write door (#4669) passes a bare + * `{ active }` patch on a packaged set straight through: switching a packaged + * set off is not a customization of it. Under #15196's Q2 = A, + * `sys_position.active` stays row-authoritative, so switching a package's + * position off — or making it the default for new users — is the operator's + * lever on the row, not an edit of the package's definition. Neither column + * is a `PositionSchema` key. + * + * Everything else stays refused, with the gate's own code and message: + * a definition column (`name`, `label`, `description`, `delegatable`), any + * column not named here, `delete`, and `transfer` / `restore` / `purge`. + * `delegatable` is deliberately NOT row state: it is declared on the position + * definition, and the triage ruling locks it ahead of #15196's S8b reader + * switch. ⛔ Widening this set is a ruling, not an edit. + */ + private static readonly PACKAGE_POSITION_ROW_STATE_COLUMNS: ReadonlySet = new Set(['active', 'is_default']); + + /** + * [#22360] The `sys_position` provenance values carve-out (d) applies to: + * `package`, and `config`, its legacy pre-A4 spelling, which + * `SYSTEM_ROW_PROVENANCE` guards as the same owner ("an application + * package") — the two vocabularies are kept in lockstep there, so a legacy + * row the boot normalizer has not healed yet answers as its canonical twin. + * ⛔ Never `platform` / `system`: switching off a built-in such as + * `everyone` is not an operator lever. + */ + private static readonly PACKAGE_POSITION_PROVENANCE: ReadonlySet = new Set(['package', 'config']); + + /** + * [#22360] Is this the write carve-out (d) admits: an `update` of + * `sys_position` whose patch is ONE object naming at least one row-state + * column and nothing else? `id` is the row's identifier (the single-id + * branch reads it from the payload), not a column the patch changes. An + * array payload is never admitted here: its rows are judged by the gate + * exactly as before. + */ + private static isPackagePositionRowStatePatch(opCtx: any): boolean { + if (opCtx?.object !== 'sys_position' || opCtx?.operation !== 'update') return false; + const patch = opCtx.data; + if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return false; + const columns = Object.keys(patch).filter((k) => k !== 'id'); + return columns.length > 0 + && columns.every((k) => SecurityPlugin.PACKAGE_POSITION_ROW_STATE_COLUMNS.has(k)); + } + /** * [#8552 / ADR-0066 D1] Curated-name authoring refusal for `sys_capability`. * diff --git a/packages/plugins/plugin-security/src/system-row-write-gate-package-position.test.ts b/packages/plugins/plugin-security/src/system-row-write-gate-package-position.test.ts new file mode 100644 index 00000000000..187cf7adf09 --- /dev/null +++ b/packages/plugins/plugin-security/src/system-row-write-gate-package-position.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22360] `assertSystemRowWriteGate` on a PACKAGE-provided `sys_position` row: +// its definition is locked, its row state is not. +// +// The declared-position seeder stamps `managed_by: 'package'` on the row of a +// position a code package holds, so this gate now protects that row. A package +// position row is locked the way a packaged permission set is +// (`permission-set-projection.ts`, #4669): a patch touching ONLY row state +// (`active`, `is_default`) passes, because switching a package's position off, +// or making it the default for new users, is not an edit of its definition. +// Everything else stays refused with the gate's own code: a definition column +// (`name`, `label`, `description`, `delegatable`), any other column, a delete +// and the other lifecycle verbs. Platform (built-in) rows and `sys_capability` +// get no carve-out. +// +// Each case runs one write through the plugin's REAL middleware over a store +// double, so "admitted" means every gate after this one admitted it too. + +import { describe, it, expect, vi } from 'vitest'; +import { SecurityPlugin } from './security-plugin.js'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; + +type Row = Record; + +const POSITION_FIELDS = Object.fromEntries( + ['id', 'name', 'label', 'description', 'active', 'is_default', 'delegatable', 'managed_by'].map((n) => [n, { name: n }]), +); +const SCHEMAS: Record = { + sys_position: { name: 'sys_position', fields: POSITION_FIELDS }, + sys_capability: { name: 'sys_capability', fields: POSITION_FIELDS }, +}; + +/** Plain CRUD on both asset objects — no wildcard, no `modifyAllRecords`. */ +const ADMIN_SET: PermissionSet = { + name: 'admin_set', + label: 'Admin Set', + objects: { + sys_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + sys_capability: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + }, +} as unknown as PermissionSet; + +const rows = (): Record => ({ + sys_position: [ + { id: 'pos_pkg_a', name: 'field_rep', label: 'Field Rep', managed_by: 'package', active: true }, + { id: 'pos_pkg_b', name: 'auditor', label: 'Auditor', managed_by: 'package', active: true }, + { id: 'pos_legacy', name: 'legacy_lead', label: 'Legacy Lead', managed_by: 'config', active: true }, + { id: 'pos_platform', name: 'everyone', label: 'Everyone', managed_by: 'platform', active: true }, + { id: 'pos_admin', name: 'regional_lead', label: 'Regional Lead', managed_by: 'admin', active: true }, + ], + sys_capability: [ + { id: 'cap_pkg', name: 'pkg_cap', label: 'Package Capability', managed_by: 'package', active: true }, + ], +}); + +async function boot() { + const store = rows(); + const matches = (row: Row, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + if (Array.isArray(where.$and) && !where.$and.every((w: any) => matches(row, w))) return false; + if (Array.isArray(where.$or) && !where.$or.some((w: any) => matches(row, w))) return false; + return Object.entries(where).every(([k, v]) => { + if (k === '$and' || k === '$or') return true; + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) { + return (v as any).$in.map(String).includes(String(row[k])); + } + return String(row[k]) === String(v); + }); + }; + + let middleware: any; + const ql = { + registerMiddleware: (mw: any) => { + if (!middleware) middleware = mw; + }, + getSchema: (object: string) => SCHEMAS[object], + find: vi.fn(async (object: string, o: any = {}) => (store[object] ?? []).filter((r) => matches(r, o?.where))), + findOne: vi.fn(async (object: string, o: any = {}) => { + assertEngineFindOnePredicate(object, o); + return (store[object] ?? []).find((r) => matches(r, o?.where)) ?? null; + }), + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async (n: string) => SCHEMAS[n], list: async () => [ADMIN_SET] }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ defaultPermissionSets: [ADMIN_SET], fallbackPermissionSet: ADMIN_SET.name }); + await plugin.init(ctx); + await plugin.start(ctx); + + /** One write through the real middleware: 'admitted', or the refusal's code + status + message. */ + const write = async (opCtx: Record): Promise<'admitted' | { code: unknown; status: unknown; message: string }> => { + let admitted = false; + try { + await middleware( + { options: {}, context: { userId: 'usr_admin', tenantId: 'org-1', positions: [], permissions: [] }, ...opCtx } as any, + async () => { admitted = true; }, + ); + } catch (e: any) { + return { code: e?.code, status: e?.status ?? e?.statusCode, message: String(e?.message ?? '') }; + } + if (!admitted) throw new Error('middleware resolved without calling next()'); + return 'admitted'; + }; + return { write }; +} + +const update = (object: string, data: unknown, where: Row = {}) => ({ object, operation: 'update', data, options: { where } }); +const byId = (id: string, patch: Row) => update('sys_position', { id, ...patch }, { id }); +const REFUSED = { code: 'PERMISSION_DENIED', status: 403 }; + +describe('[#22360] a package position row: row state switches, the definition stays locked', () => { + it('a row-state-only patch passes: deactivate, activate, Set as Default, both together', async () => { + const h = await boot(); + expect(await h.write(byId('pos_pkg_a', { active: false }))).toBe('admitted'); + expect(await h.write(byId('pos_pkg_a', { active: true }))).toBe('admitted'); + expect(await h.write(byId('pos_pkg_a', { is_default: true }))).toBe('admitted'); + expect(await h.write(byId('pos_pkg_a', { active: false, is_default: false }))).toBe('admitted'); + // The id carried only by the filter, as the engine's by-id update spells it. + expect(await h.write(update('sys_position', { active: false }, { id: 'pos_pkg_a' }))).toBe('admitted'); + }); + + it('a definition column, any other column, or a mixed patch is refused with the gate\'s code', async () => { + const h = await boot(); + for (const patch of [ + { label: 'Field Rep (edited)' }, + { description: 'edited' }, + { delegatable: true }, + { name: 'field_rep_two' }, + { active: false, label: 'Field Rep (edited)' }, + { is_default: true, delegatable: true }, + { active: false, notes: 'not a row-state column' }, + ]) { + const refused = await h.write(byId('pos_pkg_a', patch)); + expect(refused, JSON.stringify(patch)).toMatchObject(REFUSED); + expect((refused as any).message).toContain('provided by an application package'); + } + }); + + it('an empty patch admits nothing new: it is refused as before', async () => { + const h = await boot(); + expect(await h.write(byId('pos_pkg_a', {}))).toMatchObject(REFUSED); + }); + + it('delete and the other lifecycle verbs stay refused', async () => { + const h = await boot(); + for (const operation of ['delete', 'transfer', 'restore', 'purge']) { + const refused = await h.write({ object: 'sys_position', operation, options: { where: { id: 'pos_pkg_a' } } }); + expect(refused, operation).toMatchObject(REFUSED); + } + }); + + it('the legacy `config` spelling of package provenance answers as its canonical twin', async () => { + const h = await boot(); + expect(await h.write(byId('pos_legacy', { active: false }))).toBe('admitted'); + expect(await h.write(byId('pos_legacy', { label: 'edited' }))).toMatchObject(REFUSED); + }); + + it('control: a built-in\'s bare { active } patch stays refused', async () => { + const h = await boot(); + const refused = await h.write(byId('pos_platform', { active: false })); + expect(refused).toMatchObject(REFUSED); + expect((refused as any).message).toContain('provided by the platform'); + expect(await h.write(byId('pos_platform', { is_default: true }))).toMatchObject(REFUSED); + }); + + it('control: sys_capability gets no carve-out', async () => { + const h = await boot(); + expect(await h.write(update('sys_capability', { id: 'cap_pkg', active: false }, { id: 'cap_pkg' }))).toMatchObject(REFUSED); + }); + + it('control: an administrator-authored position stays fully editable', async () => { + const h = await boot(); + expect(await h.write(byId('pos_admin', { label: 'Regional Lead (edited)', delegatable: true }))).toBe('admitted'); + }); + + it('control: forging provenance in a row-state patch is still refused (a)', async () => { + const h = await boot(); + const refused = await h.write(byId('pos_admin', { active: false, managed_by: 'package' })); + expect(refused).toMatchObject(REFUSED); + expect((refused as any).message).toContain('cannot stamp'); + }); +}); + +describe('[#22360] a filtered update over package position rows', () => { + const PKG_NAMES = { name: { $in: ['field_rep', 'auditor'] } }; + + it('a row-state-only patch over package rows passes', async () => { + const h = await boot(); + expect(await h.write(update('sys_position', { active: false }, PKG_NAMES))).toBe('admitted'); + expect(await h.write(update('sys_position', { is_default: false }, PKG_NAMES))).toBe('admitted'); + }); + + it('a label patch over a package row is refused', async () => { + const h = await boot(); + const refused = await h.write(update('sys_position', { label: 'Renamed' }, PKG_NAMES)); + expect(refused).toMatchObject(REFUSED); + expect((refused as any).message).toContain('provided by the platform or an application package'); + }); + + it('a row-state-only patch whose filter also reaches a built-in is refused', async () => { + const h = await boot(); + expect(await h.write(update('sys_position', { active: false }, { name: { $in: ['field_rep', 'everyone'] } }))) + .toMatchObject(REFUSED); + // A whole-table write matches every built-in. + expect(await h.write({ object: 'sys_position', operation: 'update', data: { active: false }, options: {} })) + .toMatchObject(REFUSED); + }); + + it('control: a filtered delete over package rows stays refused', async () => { + const h = await boot(); + expect(await h.write({ object: 'sys_position', operation: 'delete', options: { where: PKG_NAMES } })) + .toMatchObject(REFUSED); + }); +}); From f9d3a1f3536531e6e4fdf9fc88de786226b1823c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:27:58 +0000 Subject: [PATCH 5/7] test(dogfood): a package-declared position's row state switches and persists while its definition stays locked Deactivate, Activate and Set as Default answer 200 and persist across a cold boot; a label edit, a delegatable edit and a delete answer 403 PERMISSION_DENIED with the row unchanged; an updateMany row-state patch lands and an updateMany label patch is refused; a built-in refuses even a bare { active } patch. The changeset now states that definition edits and delete are refused and row state stays switchable. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- ...60-declared-position-package-provenance.md | 18 +- ...clared-position-provenance.dogfood.test.ts | 185 +++++++++++++----- 2 files changed, 143 insertions(+), 60 deletions(-) diff --git a/.changeset/22360-declared-position-package-provenance.md b/.changeset/22360-declared-position-package-provenance.md index 41a5bf8d094..805428cbae7 100644 --- a/.changeset/22360-declared-position-package-provenance.md +++ b/.changeset/22360-declared-position-package-provenance.md @@ -2,29 +2,31 @@ '@objectstack/plugin-security': minor --- -fix(plugin-security)!: a position a code package declares is refused at the data door, as the metadata door already refuses it; its row now carries package provenance +fix(plugin-security)!: a definition edit or delete of a position a code package declares is refused at the data door, as the metadata door already refuses it; its row now carries package provenance, and its row state stays switchable Clause-②: no (narrowing) - + **BREAKING**: an accept-set narrowing of the `sys_position` data door, shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 (no door edits a managed definition) and D3 (managed items are read-only and clonable). **What was wrong.** The declared-position seeder wrote the row of a package-declared position with no provenance stamp, so the row carried the object default, `managed_by: 'admin'`, and the system-row write gate did not protect it. A Setup or data API edit of its label answered `200`, and the next boot wrote the declared label back over it with no message. The metadata door already refused the same edit with `403 NOT_OVERRIDABLE`. -**What is refused now.** A position is package-declared when the engine registry's artifact lookup answers a code package for its name, the same lookup the metadata door refuses a save from. On such a position's row, the admin door now gets the refusal it already gets on a built-in position, `403 PERMISSION_DENIED`: +**What is refused now.** A position is package-declared when the engine registry's artifact lookup answers a code package for its name, the same lookup the metadata door refuses a save from. On such a position's row, the admin door now answers `403 PERMISSION_DENIED`, with the message it already gives for a built-in position, to: -- an update of any column. That is the label and description, and also `active`, `is_default` and `delegatable`, so Setup's Activate, Deactivate and Set as Default actions on such a position are refused. Until now those three landed and persisted. -- a delete. Until now it answered `200` and the next boot created the row again. +- an update that touches a definition column (`name`, `label`, `description`, `delegatable`) or any column other than the two row-state columns below. That covers single-record edits, `updateMany`, and filtered updates. Until now a label or description edit answered `200` and the next boot wrote the declaration back over it; a `delegatable` edit answered `200` and persisted. +- a delete. Until now it answered `200` and the next boot created the row again. The other lifecycle writes (transfer, restore, purge) are refused as well. + +**What stays switchable.** A patch that touches only row state (`active` and/or `is_default`) still passes, so Setup's Activate, Deactivate and Set as Default keep working on a package-declared position and persist across restarts. This is the rule a packaged permission set already follows: switching it off is not an edit of its definition. A filtered update passes on the same terms, as long as its filter reaches no built-in position. System-context writes are unchanged: the boot seeder still refreshes the row's label and description from the declaration. **What is unchanged.** - A position an administrator created in Setup, and a position the environment authored through the metadata door, keep an unmanaged row (`admin`) and stay editable. -- The built-in positions are refused exactly as before. +- The built-in positions are refused exactly as before, a bare `active` or `is_default` patch included. - Assigning a position to users and binding permission sets to it are writes on other rows (`sys_user_position`, `sys_position_permission_set`), and this change does not touch them. -**At the first boot after upgrading.** The existing row of each package-declared position is re-stamped `package` in place when it carries `admin`, no value, or the legacy `user`. Only `managed_by` changes: the `active`, `is_default` and `delegatable` values an administrator set before the upgrade are kept. The boot's `declared positions seeded` info line counts these rows as `restampedPackageProvenance`. The seeder matches a row by name, as its label refresh always has, so a position created in Setup whose name a package declares later becomes that package's position and is refused the same way. +**At the first boot after upgrading.** The existing row of each package-declared position is re-stamped `package` in place when it carries `admin`, no value, or the legacy `user`. Only `managed_by` changes: the `active`, `is_default` and `delegatable` values an administrator set before the upgrade are kept. The boot's `declared positions seeded` info line counts these rows as `restampedPackageProvenance`. The seeder matches a row by name, as its label refresh always has, so a position created in Setup whose name a package declares later becomes that package's position: its definition is refused the same way, and its row state stays switchable. -**What to do.** To change a package-declared position, change it in the package and publish a new version. To have a position you can edit in Setup, clone it under a new name with the Clone Position action, bind its permission sets, and assign the clone. +**What to do.** To change a package-declared position's label, description or `delegatable`, change it in the package and publish a new version. To have a position you can edit in Setup, clone it under a new name with the Clone Position action, bind its permission sets, and assign the clone. diff --git a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts index be23709d650..af1f3f4d57f 100644 --- a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts +++ b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts @@ -1,9 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#22360] A position the showcase package declares is refused at the data -// door, the way the metadata door already refuses it, and keeps its declared -// label across a cold boot on one database file — over the real showcase -// composition under `single`. +// [#22360] A position the showcase package declares has its DEFINITION locked +// at the data door, the way the metadata door already locks it, while its ROW +// STATE stays switchable — over the real showcase composition under `single`, +// across a cold boot on one database file. // // ## What was broken // @@ -15,20 +15,30 @@ // declared label back over it, with no message. The metadata door refused the // same edit (403 `NOT_OVERRIDABLE`). // +// ## The rule now +// +// The seeder stamps `managed_by: 'package'`, and a package position row is +// locked the way a packaged permission set is (`permission-set-projection.ts`, +// #4669): a patch touching only row state (`active`, `is_default` — Setup's +// Activate, Deactivate and Set as Default) passes; a definition column +// (`label`, `delegatable`, …) and a delete are refused with the gate's code. +// // ## What each case pins // // - precondition: the showcase package holds the name, and the metadata door // refuses an edit of it; -// - the declared row carries `managed_by: 'package'`, and a data-door edit of -// its label is refused with the gate's code, the row unchanged; -// - after a cold boot the row still carries the declared label; -// - an upgraded deployment's declared row (stamped `admin` before the fix) is -// re-stamped at boot — the columns an administrator set before are kept — -// and then refused the same way; +// - the declared row carries `managed_by: 'package'`; a label edit, a +// `delegatable` edit and a delete are refused with the gate's code and +// leave the row as it was; +// - Deactivate, Activate and Set as Default answer 200; a multi-row +// (`updateMany`) row-state patch lands, a multi-row label patch is refused; +// - after a cold boot: the declared label and the refused columns stand, the +// row-state writes persisted, an upgraded deployment's row (stamped `admin` +// before the fix) is re-stamped and refused the same way; // - controls: an administrator-authored position and an environment-authored -// one (saved through the metadata door) stay editable; a built-in's refusal -// is unchanged; the seeder's own system write still refreshes a declared -// row's display text from the declaration. +// one (saved through the metadata door) stay editable; a built-in refuses +// even a bare `{ active }` patch; the seeder's own system write still +// refreshes a declared row's display text from the declaration. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -49,13 +59,22 @@ const declaredLabel = (name: string): string => { if (!declared?.label) throw new Error(`the showcase no longer declares a labelled position '${name}'`); return declared.label; }; +/** Definition edits and the delete are tried here. */ const EDITED = 'manager'; +const DELETED = 'auditor'; +/** Row-state writes, one position each, so every write is read back on its own row. */ +const DEACTIVATED = 'contributor'; +const ACTIVATED = 'legal'; +const DEFAULTED = 'ops'; +const BULK = ['finance', 'client_liaison']; +/** The upgraded-deployment and drift simulations. */ const UPGRADED = 'exec'; -const DRIFTED = 'contributor'; +const DRIFTED = 'field_ops_delegate'; const ADMIN_AUTHORED = 'p22360_admin_authored'; const ENV_AUTHORED = 'p22360_env_authored'; +const REFUSED = { status: 403, code: 'PERMISSION_DENIED' }; -describe('[#22360] a package-declared position is refused at the data door and survives a cold boot (showcase)', () => { +describe('[#22360] a package-declared position: definition locked at the data door, row state switchable, across a cold boot (showcase)', () => { let prevCwd: string; let dir: string; let dbFile: string; @@ -68,8 +87,16 @@ describe('[#22360] a package-declared position is refused at the data door and s const json: any = await res.json().catch(() => ({})); return { status: res.status, code: json?.code ?? null, json }; }; - const row = async (name: string) => + const rows = async (name: string) => ((await ql.find('sys_position', { where: { name }, limit: 2 }, SYS)) as any[]); + const one = async (name: string) => { + const found = await rows(name); + expect(found.length, `exactly one '${name}' row`).toBe(1); + return found[0]; + }; + const patch = async (name: string, body: Record) => + call('PATCH', `/data/sys_position/${(await one(name)).id}`, body); + const outcome = (r: { status: number; code: unknown }) => ({ status: r.status, code: r.code }); const start = async () => { stack = await bootStack(showcaseStack, { databaseFile: dbFile }); token = await stack.signIn(); @@ -94,50 +121,96 @@ describe('[#22360] a package-declared position is refused at the data door and s expect(stack!.tenancy().requestedPosture).toBe('single'); expect((ql.registry.getArtifactItem('position', EDITED) as any)?._packageId).toBe(SHOWCASE_PACKAGE); const put = await call('PUT', `/meta/position/${EDITED}`, { name: EDITED, label: 'Manager (metadata door)' }); - expect({ status: put.status, code: put.code }, JSON.stringify(put.json)).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(outcome(put), JSON.stringify(put.json)).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); }); it('the declared row carries package provenance', async () => { - const rows = await row(EDITED); - expect(rows.map((r) => ({ label: r.label, managed_by: r.managed_by }))) - .toEqual([{ label: declaredLabel(EDITED), managed_by: 'package' }]); + const row = await one(EDITED); + expect({ label: row.label, managed_by: row.managed_by }) + .toEqual({ label: declaredLabel(EDITED), managed_by: 'package' }); }); - it('a data-door edit of its label is refused with the gate\'s code, and the row is unchanged', async () => { - const [before] = await row(EDITED); - const edited = await call('PATCH', `/data/sys_position/${before.id}`, { label: 'Manager (edited in Setup)' }); - expect({ status: edited.status, code: edited.code }, JSON.stringify(edited.json)) - .toEqual({ status: 403, code: 'PERMISSION_DENIED' }); - expect((await row(EDITED)).map((r) => r.label)).toEqual([declaredLabel(EDITED)]); + it('a label edit and a delegatable edit are refused with the gate\'s code, and the row is unchanged', async () => { + const before = await one(EDITED); + const label = await patch(EDITED, { label: 'Manager (edited in Setup)' }); + expect(outcome(label), JSON.stringify(label.json)).toEqual(REFUSED); + const delegatable = await patch(EDITED, { delegatable: !before.delegatable }); + expect(outcome(delegatable), JSON.stringify(delegatable.json)).toEqual(REFUSED); + const after = await one(EDITED); + expect({ label: after.label, delegatable: after.delegatable }) + .toEqual({ label: declaredLabel(EDITED), delegatable: before.delegatable }); + }); + + it('a delete is refused with the gate\'s code, and the row stays', async () => { + const row = await one(DELETED); + const deleted = await call('DELETE', `/data/sys_position/${row.id}`); + expect(outcome(deleted), JSON.stringify(deleted.json)).toEqual(REFUSED); + expect((await rows(DELETED)).map((r) => r.id)).toEqual([row.id]); + }); + + it('Deactivate, Activate and Set as Default answer 200 and land on the row', async () => { + const deactivated = await patch(DEACTIVATED, { active: false }); + expect(deactivated.status, JSON.stringify(deactivated.json)).toBe(200); + + // Activate needs a deactivated row first; only a system write can make one here + // without going through the door under test. + await ql.update('sys_position', { id: (await one(ACTIVATED)).id, active: false }, SYS); + const activated = await patch(ACTIVATED, { active: true }); + expect(activated.status, JSON.stringify(activated.json)).toBe(200); + + const defaulted = await patch(DEFAULTED, { is_default: true }); + expect(defaulted.status, JSON.stringify(defaulted.json)).toBe(200); + + expect({ + deactivated: (await one(DEACTIVATED)).active, + activated: (await one(ACTIVATED)).active, + defaulted: (await one(DEFAULTED)).is_default, + }).toEqual({ deactivated: false, activated: true, defaulted: true }); + }); + + it('a multi-row row-state patch lands, and a multi-row label patch over a package row is refused', async () => { + const ids = await Promise.all(BULK.map(async (name) => (await one(name)).id)); + const switched = await call('POST', '/data/sys_position/updateMany', { + records: ids.map((id) => ({ id, data: { active: false } })), + }); + expect(switched.status, JSON.stringify(switched.json)).toBe(200); + expect(await Promise.all(BULK.map(async (name) => (await one(name)).active))).toEqual([false, false]); + + const relabelled = await call('POST', '/data/sys_position/updateMany', { + records: [{ id: ids[0], data: { label: 'Finance (edited in Setup)' } }], + }); + const refusals = JSON.stringify(relabelled.json); + expect(refusals, `status ${relabelled.status}`).toContain('PERMISSION_DENIED'); + expect((await one(BULK[0])).label).toBe(declaredLabel(BULK[0])); }); it('control: an administrator-authored position stays editable', async () => { const created = await call('POST', '/data/sys_position', { name: ADMIN_AUTHORED, label: 'Admin authored' }); expect(created.status, JSON.stringify(created.json)).toBe(201); - const [r] = await row(ADMIN_AUTHORED); - expect(r.managed_by).toBe('admin'); - const edited = await call('PATCH', `/data/sys_position/${r.id}`, { label: 'Admin authored (edited)' }); + expect((await one(ADMIN_AUTHORED)).managed_by).toBe('admin'); + const edited = await patch(ADMIN_AUTHORED, { label: 'Admin authored (edited)' }); expect(edited.status, JSON.stringify(edited.json)).toBe(200); - expect((await row(ADMIN_AUTHORED)).map((x) => x.label)).toEqual(['Admin authored (edited)']); + expect((await one(ADMIN_AUTHORED)).label).toBe('Admin authored (edited)'); }); - it('control: a built-in\'s refusal is unchanged', async () => { - const [everyone] = await row('everyone'); + it('control: a built-in refuses even a bare { active } patch', async () => { + const everyone = await one('everyone'); expect(everyone.managed_by).toBe('platform'); - const edited = await call('PATCH', `/data/sys_position/${everyone.id}`, { label: 'Everyone (edited)' }); - expect({ status: edited.status, code: edited.code }, JSON.stringify(edited.json)) - .toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + const switched = await patch('everyone', { active: false }); + expect(outcome(switched), JSON.stringify(switched.json)).toEqual(REFUSED); + const relabelled = await patch('everyone', { label: 'Everyone (edited)' }); + expect(outcome(relabelled), JSON.stringify(relabelled.json)).toEqual(REFUSED); + expect((await one('everyone')).active).toBe(everyone.active); }); - it('after a cold boot: the declared label stands, an upgraded deployment\'s row is re-stamped and refused, the seeder still refreshes display text', async () => { + it('after a cold boot: the definition stands, the row-state writes persisted, an upgraded deployment\'s row is re-stamped and refused, the seeder still refreshes display text', async () => { // The upgraded shape: the row a pre-fix boot wrote (the object default, // `admin`) carrying a column an administrator set before the upgrade. - const [upgraded] = await row(UPGRADED); + const upgraded = await one(UPGRADED); await ql.update('sys_position', { id: upgraded.id, managed_by: 'admin', delegatable: true }, SYS); - expect((await row(UPGRADED)).map((r) => [r.managed_by, r.delegatable])).toEqual([['admin', true]]); + expect([(await one(UPGRADED)).managed_by, (await one(UPGRADED)).delegatable]).toEqual(['admin', true]); // Drifted display text, which only a system write can reach now. - const [drifted] = await row(DRIFTED); - await ql.update('sys_position', { id: drifted.id, label: 'Contributor (stale)' }, SYS); + await ql.update('sys_position', { id: (await one(DRIFTED)).id, label: 'Field ops delegate (stale)' }, SYS); // An environment-authored position: a metadata-door save, whose row the // seeder writes at the next boot. const saved = await call('PUT', `/meta/position/${ENV_AUTHORED}`, { name: ENV_AUTHORED, label: 'Environment authored' }); @@ -146,24 +219,32 @@ describe('[#22360] a package-declared position is refused at the data door and s await stack!.stop(); await start(); - expect((await row(EDITED)).map((r) => ({ label: r.label, managed_by: r.managed_by }))) - .toEqual([{ label: declaredLabel(EDITED), managed_by: 'package' }]); - - const [restamped] = await row(UPGRADED); + const edited = await one(EDITED); + expect({ label: edited.label, managed_by: edited.managed_by }) + .toEqual({ label: declaredLabel(EDITED), managed_by: 'package' }); + expect((await rows(DELETED)).length).toBe(1); + expect({ + deactivated: (await one(DEACTIVATED)).active, + activated: (await one(ACTIVATED)).active, + defaulted: (await one(DEFAULTED)).is_default, + bulk: await Promise.all(BULK.map(async (name) => (await one(name)).active)), + }).toEqual({ deactivated: false, activated: true, defaulted: true, bulk: [false, false] }); + + const restamped = await one(UPGRADED); expect({ managed_by: restamped.managed_by, delegatable: restamped.delegatable, label: restamped.label }) .toEqual({ managed_by: 'package', delegatable: true, label: declaredLabel(UPGRADED) }); - const refused = await call('PATCH', `/data/sys_position/${restamped.id}`, { label: 'Executive (edited in Setup)' }); - expect({ status: refused.status, code: refused.code }, JSON.stringify(refused.json)) - .toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + const refused = await patch(UPGRADED, { label: 'Executive (edited in Setup)' }); + expect(outcome(refused), JSON.stringify(refused.json)).toEqual(REFUSED); - expect((await row(DRIFTED)).map((r) => r.label)).toEqual([declaredLabel(DRIFTED)]); + expect((await one(DRIFTED)).label).toBe(declaredLabel(DRIFTED)); }); it('controls after the cold boot: administrator- and environment-authored positions keep unmanaged, editable rows', async () => { - expect((await row(ADMIN_AUTHORED)).map((r) => [r.label, r.managed_by])).toEqual([['Admin authored (edited)', 'admin']]); - const [env] = await row(ENV_AUTHORED); - expect(env?.managed_by, 'the seeder wrote the environment definition\'s row').toBe('admin'); - const edited = await call('PATCH', `/data/sys_position/${env.id}`, { label: 'Environment authored (edited)' }); + const admin = await one(ADMIN_AUTHORED); + expect([admin.label, admin.managed_by]).toEqual(['Admin authored (edited)', 'admin']); + const env = await one(ENV_AUTHORED); + expect(env.managed_by, 'the seeder wrote the environment definition\'s row').toBe('admin'); + const edited = await patch(ENV_AUTHORED, { label: 'Environment authored (edited)' }); expect(edited.status, JSON.stringify(edited.json)).toBe(200); }); }); From 649d1b64e42404cd51155f8e8b1150976851a0e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:37:15 +0000 Subject: [PATCH 6/7] docs(plugin-security): the seeder's provenance notes name the gate's row-state carve-out Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../src/bootstrap-declared-positions.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts index 85be9d2de13..e8a23dd5c85 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts @@ -28,9 +28,11 @@ * * [#22360] PROVENANCE: the row of a position a code package holds is stamped * `managed_by: 'package'` — on insert, and on an existing row that lacks a - * managed value — so the system-row write gate refuses an admin-door edit or - * delete of it, as the metadata door already refuses one (ADR-0131 D6). See - * {@link packageProvenanceStamp}. + * managed value — so the system-row write gate refuses an admin-door edit of + * its definition, or a delete of it, as the metadata door already refuses one + * (ADR-0131 D6). Its row state (`active`, `is_default`) stays switchable, as a + * packaged permission set's does — carve-out (d) of the gate in + * `security-plugin.ts`. See {@link packageProvenanceStamp}. */ import { createSecurityCatalogReader } from '@objectstack/core'; @@ -230,7 +232,8 @@ function packageHoldsName(registry: any, entry: { name: string; packageId?: stri * back over it with no message. The metadata door refused the same edit * (`403 NOT_OVERRIDABLE`). ADR-0131 D6: no door edits a managed definition; * D3: managed items are read-only and clonable. With the stamp, the data door - * refuses through the gate that already protects the six built-ins. + * refuses a definition edit or a delete through the gate that already protects + * the six built-ins; a row-state-only patch passes its carve-out (d). * * ## Why this is not a #2909 T2 projection * @@ -246,12 +249,13 @@ function packageHoldsName(registry: any, entry: { name: string; packageId?: stri * * A declared row written before this carries `admin`. The pass corrects the * stamp in place — `managed_by` and nothing else beyond the label and - * description refresh it always made — so the edit is refused there too, and - * the columns an administrator set before the upgrade stay as they are. + * description refresh it always made — so a definition edit is refused there + * too, and the columns an administrator set before the upgrade stay as they + * are. * ⚠️ That row is matched by NAME, as the label refresh always matched it: a * Setup-created position whose name a package declares later is taken over by - * the package here, and from then on refused at the data door like any other - * package position. + * the package here, and from then on its definition is refused at the data + * door like any other package position's. */ function packageProvenanceStamp(packageHeld: boolean, existing?: any): { managed_by?: string } { if (!packageHeld) return {}; From c345f93b739c35b22607dc4159fd5f83ea6e13bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:12:52 +0000 Subject: [PATCH 7/7] test(plugin-security): the package-position gate pins move beside the gate's #7505 cases, on its ledgered store double A new test file carried a second findOne engine double the pinned ledger does not record; housing the cases in store-fault-fail-closed.test.ts, which already tests assertSystemRowWriteGate over a ledgered double, covers the same carve-out without a ledger change. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../src/store-fault-fail-closed.test.ts | 153 +++++++++++- ...em-row-write-gate-package-position.test.ts | 226 ------------------ 2 files changed, 149 insertions(+), 230 deletions(-) delete mode 100644 packages/plugins/plugin-security/src/system-row-write-gate-package-position.test.ts diff --git a/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts b/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts index ef62c0b8c5d..e4653c0fc2f 100644 --- a/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts +++ b/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts @@ -66,15 +66,19 @@ function datasourceOutage(object: string): Error { return e; } +const ASSET_FIELDS = Object.fromEntries( + ['id', 'name', 'label', 'description', 'active', 'is_default', 'delegatable', 'managed_by'].map((n) => [n, { name: n }]), +); + const SCHEMAS: Record = { sys_permission_set: { name: 'sys_permission_set', fields: { id: { name: 'id' }, name: { name: 'name' }, managed_by: { name: 'managed_by' } }, }, - sys_position: { - name: 'sys_position', - fields: { id: { name: 'id' }, name: { name: 'name' }, managed_by: { name: 'managed_by' } }, - }, + // [#22360] The row-state and definition columns the package-position cases + // below write; the #7505 cases only ever name `id` / `name` / `managed_by`. + sys_position: { name: 'sys_position', fields: ASSET_FIELDS }, + sys_capability: { name: 'sys_capability', fields: ASSET_FIELDS }, crm_task: { name: 'crm_task', sharingModel: 'private', @@ -93,6 +97,7 @@ const ADMIN_SET: PermissionSet = { objects: { sys_permission_set: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, sys_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + sys_capability: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, crm_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, }, } as unknown as PermissionSet; @@ -305,6 +310,146 @@ describe('[#7505] assertSystemRowWriteGate — ADR-0066 asset ownership under a }); }); +/** + * [#22360] The same gate, in steady state, on a PACKAGE-provided `sys_position` + * row: its definition is locked, its row state is not. + * + * The declared-position seeder stamps `managed_by: 'package'` on the row of a + * position a code package holds, so this gate now protects that row. A package + * position row is locked the way a packaged permission set is + * (`permission-set-projection.ts`, #4669): a patch touching ONLY row state + * (`active`, `is_default`) passes, because switching a package's position off, + * or making it the default for new users, is not an edit of its definition. + * Everything else stays refused with the gate's own code: a definition column + * (`name`, `label`, `description`, `delegatable`), any other column, a delete + * and the other lifecycle verbs. Platform (built-in) rows and `sys_capability` + * get no carve-out. Housed here, beside the #7505 cases of the same gate, so the + * suite's one store double serves both (no second double to ledger). + */ +describe('[#22360] assertSystemRowWriteGate — a package position row: row state switches, the definition stays locked', () => { + const rows = () => ({ + sys_position: [ + { id: 'pos_pkg_a', name: 'field_rep', label: 'Field Rep', managed_by: 'package', active: true }, + { id: 'pos_pkg_b', name: 'auditor', label: 'Auditor', managed_by: 'package', active: true }, + { id: 'pos_legacy', name: 'legacy_lead', label: 'Legacy Lead', managed_by: 'config', active: true }, + { id: 'pos_platform', name: 'everyone', label: 'Everyone', managed_by: 'platform', active: true }, + { id: 'pos_admin', name: 'regional_lead', label: 'Regional Lead', managed_by: 'admin', active: true }, + ], + sys_capability: [ + { id: 'cap_pkg', name: 'pkg_cap', label: 'Package Capability', managed_by: 'package', active: true }, + ], + }); + const update = (object: string, data: unknown, where: Row = {}) => ({ object, operation: 'update', data, options: { where } }); + const byId = (id: string, patch: Row) => update('sys_position', { id, ...patch }, { id }); + const PKG_NAMES = { name: { $in: ['field_rep', 'auditor'] } }; + /** The refusal's code and status — the gate's existing envelope, unchanged by the carve-out. */ + const refused = async (run: Promise) => { + const err = await refusalOf(run); + return { code: err.code, status: err.status ?? err.statusCode, message: String(err.message) }; + }; + const REFUSED = { code: 'PERMISSION_DENIED', status: 403 }; + + it('a row-state-only patch passes: deactivate, activate, Set as Default, both together', async () => { + const h = await boot({ rows: rows() }); + await expect(h.write(byId('pos_pkg_a', { active: false }))).resolves.toBe('admitted'); + await expect(h.write(byId('pos_pkg_a', { active: true }))).resolves.toBe('admitted'); + await expect(h.write(byId('pos_pkg_a', { is_default: true }))).resolves.toBe('admitted'); + await expect(h.write(byId('pos_pkg_a', { active: false, is_default: false }))).resolves.toBe('admitted'); + // The id carried only by the filter, as the engine's by-id update spells it. + await expect(h.write(update('sys_position', { active: false }, { id: 'pos_pkg_a' }))).resolves.toBe('admitted'); + }); + + it('a definition column, any other column, or a mixed patch is refused with the gate\'s code', async () => { + const h = await boot({ rows: rows() }); + for (const patch of [ + { label: 'Field Rep (edited)' }, + { description: 'edited' }, + { delegatable: true }, + { name: 'field_rep_two' }, + { active: false, label: 'Field Rep (edited)' }, + { is_default: true, delegatable: true }, + { active: false, notes: 'not a row-state column' }, + ]) { + const r = await refused(h.write(byId('pos_pkg_a', patch))); + expect(r, JSON.stringify(patch)).toMatchObject(REFUSED); + expect(r.message).toContain('provided by an application package'); + } + }); + + it('an empty patch admits nothing new: it is refused as before', async () => { + const h = await boot({ rows: rows() }); + expect(await refused(h.write(byId('pos_pkg_a', {})))).toMatchObject(REFUSED); + }); + + it('delete and the other lifecycle verbs stay refused', async () => { + const h = await boot({ rows: rows() }); + for (const operation of ['delete', 'transfer', 'restore', 'purge']) { + const r = await refused(h.write({ object: 'sys_position', operation, options: { where: { id: 'pos_pkg_a' } } })); + expect(r, operation).toMatchObject(REFUSED); + } + }); + + it('the legacy `config` spelling of package provenance answers as its canonical twin', async () => { + const h = await boot({ rows: rows() }); + await expect(h.write(byId('pos_legacy', { active: false }))).resolves.toBe('admitted'); + expect(await refused(h.write(byId('pos_legacy', { label: 'edited' })))).toMatchObject(REFUSED); + }); + + it('control: a built-in\'s bare { active } patch stays refused', async () => { + const h = await boot({ rows: rows() }); + const r = await refused(h.write(byId('pos_platform', { active: false }))); + expect(r).toMatchObject(REFUSED); + expect(r.message).toContain('provided by the platform'); + expect(await refused(h.write(byId('pos_platform', { is_default: true })))).toMatchObject(REFUSED); + }); + + it('control: sys_capability gets no carve-out', async () => { + const h = await boot({ rows: rows() }); + expect(await refused(h.write(update('sys_capability', { id: 'cap_pkg', active: false }, { id: 'cap_pkg' })))) + .toMatchObject(REFUSED); + }); + + it('control: an administrator-authored position stays fully editable', async () => { + const h = await boot({ rows: rows() }); + await expect(h.write(byId('pos_admin', { label: 'Regional Lead (edited)', delegatable: true }))).resolves.toBe('admitted'); + }); + + it('control: forging provenance in a row-state patch is still refused (a)', async () => { + const h = await boot({ rows: rows() }); + const r = await refused(h.write(byId('pos_admin', { active: false, managed_by: 'package' }))); + expect(r).toMatchObject(REFUSED); + expect(r.message).toContain('cannot stamp'); + }); + + it('filtered: a row-state-only patch over package rows passes', async () => { + const h = await boot({ rows: rows() }); + await expect(h.write(update('sys_position', { active: false }, PKG_NAMES))).resolves.toBe('admitted'); + await expect(h.write(update('sys_position', { is_default: false }, PKG_NAMES))).resolves.toBe('admitted'); + }); + + it('filtered: a label patch over a package row is refused', async () => { + const h = await boot({ rows: rows() }); + const r = await refused(h.write(update('sys_position', { label: 'Renamed' }, PKG_NAMES))); + expect(r).toMatchObject(REFUSED); + expect(r.message).toContain('provided by the platform or an application package'); + }); + + it('filtered: a row-state-only patch whose filter also reaches a built-in is refused', async () => { + const h = await boot({ rows: rows() }); + expect(await refused(h.write(update('sys_position', { active: false }, { name: { $in: ['field_rep', 'everyone'] } })))) + .toMatchObject(REFUSED); + // A whole-table write matches every built-in. + expect(await refused(h.write({ object: 'sys_position', operation: 'update', data: { active: false }, options: {} }))) + .toMatchObject(REFUSED); + }); + + it('control (filtered): a delete over package rows stays refused', async () => { + const h = await boot({ rows: rows() }); + expect(await refused(h.write({ object: 'sys_position', operation: 'delete', options: { where: PKG_NAMES } }))) + .toMatchObject(REFUSED); + }); +}); + describe('[#7505] getCallerPreImage — the owner-anchor echo under a store fault', () => { const rows = () => ({ crm_task: [ diff --git a/packages/plugins/plugin-security/src/system-row-write-gate-package-position.test.ts b/packages/plugins/plugin-security/src/system-row-write-gate-package-position.test.ts deleted file mode 100644 index 187cf7adf09..00000000000 --- a/packages/plugins/plugin-security/src/system-row-write-gate-package-position.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#22360] `assertSystemRowWriteGate` on a PACKAGE-provided `sys_position` row: -// its definition is locked, its row state is not. -// -// The declared-position seeder stamps `managed_by: 'package'` on the row of a -// position a code package holds, so this gate now protects that row. A package -// position row is locked the way a packaged permission set is -// (`permission-set-projection.ts`, #4669): a patch touching ONLY row state -// (`active`, `is_default`) passes, because switching a package's position off, -// or making it the default for new users, is not an edit of its definition. -// Everything else stays refused with the gate's own code: a definition column -// (`name`, `label`, `description`, `delegatable`), any other column, a delete -// and the other lifecycle verbs. Platform (built-in) rows and `sys_capability` -// get no carve-out. -// -// Each case runs one write through the plugin's REAL middleware over a store -// double, so "admitted" means every gate after this one admitted it too. - -import { describe, it, expect, vi } from 'vitest'; -import { SecurityPlugin } from './security-plugin.js'; -import type { PermissionSet } from '@objectstack/spec/security'; -import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; - -type Row = Record; - -const POSITION_FIELDS = Object.fromEntries( - ['id', 'name', 'label', 'description', 'active', 'is_default', 'delegatable', 'managed_by'].map((n) => [n, { name: n }]), -); -const SCHEMAS: Record = { - sys_position: { name: 'sys_position', fields: POSITION_FIELDS }, - sys_capability: { name: 'sys_capability', fields: POSITION_FIELDS }, -}; - -/** Plain CRUD on both asset objects — no wildcard, no `modifyAllRecords`. */ -const ADMIN_SET: PermissionSet = { - name: 'admin_set', - label: 'Admin Set', - objects: { - sys_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, - sys_capability: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, - }, -} as unknown as PermissionSet; - -const rows = (): Record => ({ - sys_position: [ - { id: 'pos_pkg_a', name: 'field_rep', label: 'Field Rep', managed_by: 'package', active: true }, - { id: 'pos_pkg_b', name: 'auditor', label: 'Auditor', managed_by: 'package', active: true }, - { id: 'pos_legacy', name: 'legacy_lead', label: 'Legacy Lead', managed_by: 'config', active: true }, - { id: 'pos_platform', name: 'everyone', label: 'Everyone', managed_by: 'platform', active: true }, - { id: 'pos_admin', name: 'regional_lead', label: 'Regional Lead', managed_by: 'admin', active: true }, - ], - sys_capability: [ - { id: 'cap_pkg', name: 'pkg_cap', label: 'Package Capability', managed_by: 'package', active: true }, - ], -}); - -async function boot() { - const store = rows(); - const matches = (row: Row, where: any): boolean => { - if (!where || typeof where !== 'object') return true; - if (Array.isArray(where.$and) && !where.$and.every((w: any) => matches(row, w))) return false; - if (Array.isArray(where.$or) && !where.$or.some((w: any) => matches(row, w))) return false; - return Object.entries(where).every(([k, v]) => { - if (k === '$and' || k === '$or') return true; - if (v && typeof v === 'object' && Array.isArray((v as any).$in)) { - return (v as any).$in.map(String).includes(String(row[k])); - } - return String(row[k]) === String(v); - }); - }; - - let middleware: any; - const ql = { - registerMiddleware: (mw: any) => { - if (!middleware) middleware = mw; - }, - getSchema: (object: string) => SCHEMAS[object], - find: vi.fn(async (object: string, o: any = {}) => (store[object] ?? []).filter((r) => matches(r, o?.where))), - findOne: vi.fn(async (object: string, o: any = {}) => { - assertEngineFindOnePredicate(object, o); - return (store[object] ?? []).find((r) => matches(r, o?.where)) ?? null; - }), - }; - const services: Record = { - manifest: { register: vi.fn() }, - objectql: ql, - metadata: { get: async (n: string) => SCHEMAS[n], list: async () => [ADMIN_SET] }, - }; - const ctx: any = { - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - }; - const plugin = new SecurityPlugin({ defaultPermissionSets: [ADMIN_SET], fallbackPermissionSet: ADMIN_SET.name }); - await plugin.init(ctx); - await plugin.start(ctx); - - /** One write through the real middleware: 'admitted', or the refusal's code + status + message. */ - const write = async (opCtx: Record): Promise<'admitted' | { code: unknown; status: unknown; message: string }> => { - let admitted = false; - try { - await middleware( - { options: {}, context: { userId: 'usr_admin', tenantId: 'org-1', positions: [], permissions: [] }, ...opCtx } as any, - async () => { admitted = true; }, - ); - } catch (e: any) { - return { code: e?.code, status: e?.status ?? e?.statusCode, message: String(e?.message ?? '') }; - } - if (!admitted) throw new Error('middleware resolved without calling next()'); - return 'admitted'; - }; - return { write }; -} - -const update = (object: string, data: unknown, where: Row = {}) => ({ object, operation: 'update', data, options: { where } }); -const byId = (id: string, patch: Row) => update('sys_position', { id, ...patch }, { id }); -const REFUSED = { code: 'PERMISSION_DENIED', status: 403 }; - -describe('[#22360] a package position row: row state switches, the definition stays locked', () => { - it('a row-state-only patch passes: deactivate, activate, Set as Default, both together', async () => { - const h = await boot(); - expect(await h.write(byId('pos_pkg_a', { active: false }))).toBe('admitted'); - expect(await h.write(byId('pos_pkg_a', { active: true }))).toBe('admitted'); - expect(await h.write(byId('pos_pkg_a', { is_default: true }))).toBe('admitted'); - expect(await h.write(byId('pos_pkg_a', { active: false, is_default: false }))).toBe('admitted'); - // The id carried only by the filter, as the engine's by-id update spells it. - expect(await h.write(update('sys_position', { active: false }, { id: 'pos_pkg_a' }))).toBe('admitted'); - }); - - it('a definition column, any other column, or a mixed patch is refused with the gate\'s code', async () => { - const h = await boot(); - for (const patch of [ - { label: 'Field Rep (edited)' }, - { description: 'edited' }, - { delegatable: true }, - { name: 'field_rep_two' }, - { active: false, label: 'Field Rep (edited)' }, - { is_default: true, delegatable: true }, - { active: false, notes: 'not a row-state column' }, - ]) { - const refused = await h.write(byId('pos_pkg_a', patch)); - expect(refused, JSON.stringify(patch)).toMatchObject(REFUSED); - expect((refused as any).message).toContain('provided by an application package'); - } - }); - - it('an empty patch admits nothing new: it is refused as before', async () => { - const h = await boot(); - expect(await h.write(byId('pos_pkg_a', {}))).toMatchObject(REFUSED); - }); - - it('delete and the other lifecycle verbs stay refused', async () => { - const h = await boot(); - for (const operation of ['delete', 'transfer', 'restore', 'purge']) { - const refused = await h.write({ object: 'sys_position', operation, options: { where: { id: 'pos_pkg_a' } } }); - expect(refused, operation).toMatchObject(REFUSED); - } - }); - - it('the legacy `config` spelling of package provenance answers as its canonical twin', async () => { - const h = await boot(); - expect(await h.write(byId('pos_legacy', { active: false }))).toBe('admitted'); - expect(await h.write(byId('pos_legacy', { label: 'edited' }))).toMatchObject(REFUSED); - }); - - it('control: a built-in\'s bare { active } patch stays refused', async () => { - const h = await boot(); - const refused = await h.write(byId('pos_platform', { active: false })); - expect(refused).toMatchObject(REFUSED); - expect((refused as any).message).toContain('provided by the platform'); - expect(await h.write(byId('pos_platform', { is_default: true }))).toMatchObject(REFUSED); - }); - - it('control: sys_capability gets no carve-out', async () => { - const h = await boot(); - expect(await h.write(update('sys_capability', { id: 'cap_pkg', active: false }, { id: 'cap_pkg' }))).toMatchObject(REFUSED); - }); - - it('control: an administrator-authored position stays fully editable', async () => { - const h = await boot(); - expect(await h.write(byId('pos_admin', { label: 'Regional Lead (edited)', delegatable: true }))).toBe('admitted'); - }); - - it('control: forging provenance in a row-state patch is still refused (a)', async () => { - const h = await boot(); - const refused = await h.write(byId('pos_admin', { active: false, managed_by: 'package' })); - expect(refused).toMatchObject(REFUSED); - expect((refused as any).message).toContain('cannot stamp'); - }); -}); - -describe('[#22360] a filtered update over package position rows', () => { - const PKG_NAMES = { name: { $in: ['field_rep', 'auditor'] } }; - - it('a row-state-only patch over package rows passes', async () => { - const h = await boot(); - expect(await h.write(update('sys_position', { active: false }, PKG_NAMES))).toBe('admitted'); - expect(await h.write(update('sys_position', { is_default: false }, PKG_NAMES))).toBe('admitted'); - }); - - it('a label patch over a package row is refused', async () => { - const h = await boot(); - const refused = await h.write(update('sys_position', { label: 'Renamed' }, PKG_NAMES)); - expect(refused).toMatchObject(REFUSED); - expect((refused as any).message).toContain('provided by the platform or an application package'); - }); - - it('a row-state-only patch whose filter also reaches a built-in is refused', async () => { - const h = await boot(); - expect(await h.write(update('sys_position', { active: false }, { name: { $in: ['field_rep', 'everyone'] } }))) - .toMatchObject(REFUSED); - // A whole-table write matches every built-in. - expect(await h.write({ object: 'sys_position', operation: 'update', data: { active: false }, options: {} })) - .toMatchObject(REFUSED); - }); - - it('control: a filtered delete over package rows stays refused', async () => { - const h = await boot(); - expect(await h.write({ object: 'sys_position', operation: 'delete', options: { where: PKG_NAMES } })) - .toMatchObject(REFUSED); - }); -});