diff --git a/.changeset/22360-declared-position-package-provenance.md b/.changeset/22360-declared-position-package-provenance.md new file mode 100644 index 00000000000..805428cbae7 --- /dev/null +++ b/.changeset/22360-declared-position-package-provenance.md @@ -0,0 +1,32 @@ +--- +'@objectstack/plugin-security': minor +--- + +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 answers `403 PERMISSION_DENIED`, with the message it already gives for a built-in position, to: + +- 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, 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: its definition is refused the same way, and its row state stays switchable. + +**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/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..e8a23dd5c85 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts @@ -25,6 +25,14 @@ * 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 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'; @@ -140,22 +148,121 @@ 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 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 + * + * 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 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 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 {}; + 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 +277,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 +303,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 +324,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 +334,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 +381,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 }; 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/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/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..af1f3f4d57f --- /dev/null +++ b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts @@ -0,0 +1,250 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#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 +// +// 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`). +// +// ## 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'`; 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 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'; +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; +}; +/** 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 = '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: definition locked at the data door, row state switchable, across 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 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(); + 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(outcome(put), JSON.stringify(put.json)).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + }); + + it('the declared row carries package provenance', async () => { + const row = await one(EDITED); + expect({ label: row.label, managed_by: row.managed_by }) + .toEqual({ label: declaredLabel(EDITED), managed_by: 'package' }); + }); + + 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); + 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 one(ADMIN_AUTHORED)).label).toBe('Admin authored (edited)'); + }); + + it('control: a built-in refuses even a bare { active } patch', async () => { + const everyone = await one('everyone'); + expect(everyone.managed_by).toBe('platform'); + 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 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 one(UPGRADED); + await ql.update('sys_position', { id: upgraded.id, managed_by: 'admin', delegatable: true }, SYS); + expect([(await one(UPGRADED)).managed_by, (await one(UPGRADED)).delegatable]).toEqual(['admin', true]); + // Drifted display text, which only a system write can reach now. + 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' }); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + + await stack!.stop(); + await start(); + + 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 patch(UPGRADED, { label: 'Executive (edited in Setup)' }); + expect(outcome(refused), JSON.stringify(refused.json)).toEqual(REFUSED); + + expect((await one(DRIFTED)).label).toBe(declaredLabel(DRIFTED)); + }); + + it('controls after the cold boot: administrator- and environment-authored positions keep unmanaged, editable rows', async () => { + 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); + }); +});