Skip to content
Merged
32 changes: 32 additions & 0 deletions .changeset/22360-declared-position-package-provenance.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export, type or stored shape is added, removed, renamed or re-shaped. What changes is one column the boot seeder writes on its own rows (sys_position.managed_by, now package for a position a code package declares) and, through the system-row write gate, a runtime write door: an admin-door update of such a row's definition columns, and its delete, are refused, while a patch of its row state (active, is_default) passes. No stored row is left for `objectstack migrate meta` to convert, because the seeder corrects the stamp itself at the next boot. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no TypeScript declaration moves (not runtime-interface-only or type-surface-only). -->

**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.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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<Record<string, unknown>>) => {
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<typeof makeQl>) => {
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 });
});
});
Loading
Loading