diff --git a/.changeset/15196-s10-position-namespace.md b/.changeset/15196-s10-position-namespace.md new file mode 100644 index 00000000000..ae8656ef085 --- /dev/null +++ b/.changeset/15196-s10-position-namespace.md @@ -0,0 +1,20 @@ +--- +'@objectstack/plugin-security': minor +--- + +feat(plugin-security)!: under `single`, a Setup create of a position, or a rename into a name, that a package or a built-in already holds is refused with the metadata door's own refusal (ADR-0048 addendum N.2/N.3) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +Positions hold one name per deployment (ADR-0048 addendum, N.2). A position a package declares, and the platform's built-in positions, are managed items: the metadata door already refused an environment save under their names (`PUT /api/v1/meta/position/:name` answers `403 NOT_OVERRIDABLE`). The Setup data door did not. + +**FROM.** Under `single`, a non-system `POST /api/v1/data/sys_position` whose `name` a package or a built-in holds answered `201`. The new row sat beside the package's (or the platform's) position under the same name. A `PATCH` that renamed an administrator's position into such a name answered `200`, and it also deleted that position's own environment definition. Measured on the showcase: a create of `manager` (package-declared), `everyone` or `guest` (built-in audience anchors) answered `201`, and a rename into `exec` answered `200`. + +**TO.** Under `single`, the same create, or a rename into such a name, is refused with the metadata door's own refusal, relayed as the door builds it: `403 NOT_OVERRIDABLE`. No row is kept, a renamed row keeps its old name, and its definition is untouched. The door's own verdict is asked; nothing is written to environment metadata for such a name. + +- **Unchanged.** The engine's own refusals answer first, as before: a reserved built-in identity name (`platform_admin`, `org_owner`, `org_admin`, `org_member`) stays `400 VALIDATION_FAILED`, and a name already held in the same organization stays `409 UNIQUE_VIOLATION`. An edit that keeps such a name, and a delete, behave as before. System writes, walled postures and kernels without a metadata door behave as before. Under a walled posture, each organization already holds its own rows of the declared and built-in names, so the same create answers `409 UNIQUE_VIOLATION`, as before. +- **Remedy.** Rename the position: give it a name no package and no built-in holds (for example `regional_manager` instead of `manager`), then point any assignment at the new name. `GET /api/v1/meta/position` lists the names already held. diff --git a/packages/plugins/plugin-security/src/position-write-through.test.ts b/packages/plugins/plugin-security/src/position-write-through.test.ts index 16e4161b0f4..92be1d290c6 100644 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ b/packages/plugins/plugin-security/src/position-write-through.test.ts @@ -19,9 +19,13 @@ * - a create, or a rename, into a name the metadata door refuses answers the * door's own refusal and keeps nothing (seat re-rule Q1 = A); an edit of a * row that already carries such a name still lands as a row write; - * - a name a package or a built-in holds stands the write-through down: the - * write is passed on and nothing reaches metadata (Q2 = A), pinned at the - * write-through's own level rather than at the data door's answer; + * - an edit that keeps a name a package or a built-in holds, and a delete of + * such a row, stand the write-through down: the write is passed on and + * nothing reaches metadata (Q2 = A), pinned at the write-through's own level + * rather than at the data door's answer; + * - a create, or a rename into, a name a package or a built-in holds answers + * the metadata door's own locked-base refusal and keeps nothing (C2 stage + * S10, ADR-0048 addendum N.2/N.3); the engine's own refusals keep theirs; * - a package registering a name a Setup position holds in the environment * ledger is refused `NAMESPACE_CONFLICT` (Q3 = A, within Q4 = A); * - controls: a walled posture, a system write and a kernel without a metadata @@ -393,17 +397,21 @@ describe('[ADR-0131 D3] Q1 = A — a name the metadata door refuses is refused w }); }); -describe('[ADR-0131 D3] Q2 = A — a name a package or a built-in holds stands the write-through down', () => { +describe('[ADR-0131 D3] Q2 = A — an edit that keeps, or a delete of, a name a package or a built-in holds stands the write-through down', () => { // Pinned at the write-through's own level, never at the data door's answer: // whether the data door admits an edit of a package-declared row is the // system-row gate's call, decided on the row's provenance stamp, and that // stamp is another change's. The write-through must stand down whatever the // stamp says, because it reads the name's holder from the engine registry. - it('the write-through passes a write on a package-held or built-in name to the next step, and writes nothing to metadata', async () => { + // A CREATE under such a name is no stand-down since stage S10 (below). + it('the write-through passes an edit or a delete on a package-held name to the next step, and writes nothing to metadata', async () => { const b = await boot(); const door = { saveMetaItem: vi.fn(async (_request: Record) => undefined), deleteMetaItem: vi.fn(async (_request: Record) => undefined), + // The real door's verdict, so the S10 refusal is in force for this double too. + packagedBaseRefusal: vi.fn((request: { type: string; name: string; operation: 'save' | 'delete' }) => + b.protocol.packagedBaseRefusal(request) as Error | null), }; const writeThrough = createPositionWriteThrough({ ql: b.engine, getProtocol: () => door, getPosture: () => 'single', logger: b.logger, @@ -418,9 +426,6 @@ describe('[ADR-0131 D3] Q2 = A — a name a package or a built-in holds stands t const edit = { object: 'sys_position', operation: 'update', data: { id: 'pos_pkg', label: 'Edited', delegatable: true }, context: b.admin }; expect(await passOn(edit, () => b.engine.update('sys_position', { id: 'pos_pkg', label: 'Edited', delegatable: true }, { context: SYS } as any))) .toBe(1); - // A create under a built-in name (the plugin registers the six under its own package). - const builtIn = { object: 'sys_position', operation: 'insert', data: { name: 'everyone', label: 'Everyone' }, context: b.admin }; - expect(await passOn(builtIn, async () => ({ id: 'pos_builtin', name: 'everyone' }))).toBe(1); // A delete of the package-declared row. const del = { object: 'sys_position', operation: 'delete', options: { where: { id: 'pos_pkg' } }, context: b.admin }; expect(await passOn(del, () => b.engine.delete('sys_position', { where: { id: 'pos_pkg' }, context: SYS } as any))).toBe(1); @@ -446,6 +451,112 @@ describe('[ADR-0131 D3] Q2 = A — a name a package or a built-in holds stands t }); }); +describe('[C2 stage S10, ADR-0048 addendum N.2/N.3] one namespace — a create or a rename into a held name answers the door\'s refusal', () => { + /** The metadata door's own envelope for an environment save over a name a package or a built-in holds. */ + const DOOR_REFUSAL = { code: 'NOT_OVERRIDABLE', status: 403 }; + + it.each([ + ['a package declares', PKG_POSITION], + ['the platform declares as an audience anchor', 'everyone'], + ['the platform declares as an audience anchor (guest)', 'guest'], + ])('a create under a name %s is refused with the door\'s envelope, and keeps no row and no definition', async (_holder, name) => { + const b = await boot(); + const before = (await b.rows(name)).map((r) => r.id); + const refusal = await refusalOf(create(b, { name, label: 'Mine' })); + expect(envelope(refusal)).toEqual(DOOR_REFUSAL); + expect((await b.rows(name)).map((r) => r.id), 'the created row was undone').toEqual(before); + expect(await b.envRows(name)).toEqual([]); + }); + + it('the refusal is the door\'s own, relayed as the door built it — the same verdict a metadata save gets', async () => { + const b = await boot(); + const refusal: any = await refusalOf(create(b, { name: PKG_POSITION, label: 'Mine' })); + const door: any = await refusalOf( + b.protocol.saveMetaItem({ type: 'position', name: PKG_POSITION, item: { name: PKG_POSITION, label: 'Mine' } }), + ); + expect({ code: refusal?.code, status: refusal?.status, message: refusal?.message }) + .toEqual({ code: door?.code, status: door?.status, message: door?.message }); + }); + + it('a bulk create carrying one held name is refused whole: no row of the write is kept', async () => { + const b = await boot(); + const refusal = await refusalOf(b.engine.insert('sys_position', [ + { name: 'bulk_lead', label: 'Bulk lead' }, + { name: PKG_POSITION, label: 'Mine' }, + ], { context: b.admin } as any)); + expect(envelope(refusal)).toEqual(DOOR_REFUSAL); + expect(await b.rows('bulk_lead')).toEqual([]); + expect(await b.envRows('bulk_lead')).toEqual([]); + expect((await b.rows(PKG_POSITION)).map((r) => r.id)).toEqual(['pos_pkg']); + }); + + it.each([ + ['a package declares', PKG_POSITION], + ['the platform declares', 'guest'], + ])('a rename into a name %s is refused; the row and its definition keep the old name', async (_holder, held) => { + const b = await boot(); + const created: any = await create(b, { name: 'route_lead', label: 'Route lead' }); + const refusal = await refusalOf(patch(b, created.id, { name: held, label: 'Renamed' })); + expect(envelope(refusal)).toEqual(DOOR_REFUSAL); + const [row] = await b.rows('route_lead'); + expect({ id: row?.id, label: row?.label }).toEqual({ id: created.id, label: 'Route lead' }); + expect((await b.rows(held)).map((r) => r.id)).not.toContain(created.id); + // Before S10 the rename stood down for the new name and then deleted the old name's definition. + expect((await b.envRows('route_lead')).map((r) => r.body)).toEqual([ + { name: 'route_lead', label: 'Route lead', delegatable: false }, + ]); + expect(await b.envRows(held)).toEqual([]); + }); + + it('a verdict the door cannot reach is no admission: the create and the rename are undone, and the failure is the answer', async () => { + const b = await boot(); + const created: any = await create(b, { name: 'yard_lead', label: 'Yard lead' }); + const unreadable = Object.assign(new Error('registry unreadable'), { code: 'METADATA_STORE_UNAVAILABLE', status: 503 }); + vi.spyOn(b.protocol, 'packagedBaseRefusal').mockImplementation(() => { throw unreadable; }); + expect(await refusalOf(create(b, { name: 'dock_lead', label: 'Dock lead' }))).toBe(unreadable); + expect(await b.rows('dock_lead')).toEqual([]); + expect(await refusalOf(patch(b, created.id, { name: 'gate_lead' }))).toBe(unreadable); + expect((await b.rows('yard_lead')).map((r) => r.id)).toEqual([created.id]); + expect(await b.rows('gate_lead')).toEqual([]); + }); + + it('control: the engine\'s own refusals keep their answer — a reserved identity name stays VALIDATION_FAILED', async () => { + const b = await boot(); + // The platform holds `org_admin` too, but the engine's rule validator refuses + // the row before the door is asked (the REST layer answers it 400). + const refusal: any = await refusalOf(create(b, { name: 'org_admin', label: 'Mine' })); + expect({ name: refusal?.name, code: refusal?.code }).toEqual({ name: 'ValidationError', code: 'VALIDATION_FAILED' }); + expect(await b.rows('org_admin')).toEqual([]); + }); + + it('control: an edit that keeps a held name stays a row write, with no definition and no refusal', async () => { + const b = await boot(); + // A row that predates this stage: an organization's own row under the package's name. + await b.engine.insert('sys_position', { + id: 'pos_twin', name: PKG_POSITION, label: 'Twin', organization_id: ORG, + }, { context: SYS } as any); + await patch(b, 'pos_twin', { label: 'Twin (relabelled)' }); + expect((await b.rows(PKG_POSITION)).find((r) => r.id === 'pos_twin')?.label).toBe('Twin (relabelled)'); + expect(await b.envRows(PKG_POSITION)).toEqual([]); + }); + + it('control: a door that brings no locked-base verdict keeps the stand-down', async () => { + const b = await boot(); + const door = { + saveMetaItem: vi.fn(async (_request: Record) => undefined), + deleteMetaItem: vi.fn(async (_request: Record) => undefined), + }; + const writeThrough = createPositionWriteThrough({ + ql: b.engine, getProtocol: () => door, getPosture: () => 'single', logger: b.logger, + }); + const opCtx: any = { object: 'sys_position', operation: 'insert', data: { name: 'everyone', label: 'Everyone' }, context: b.admin }; + const next = vi.fn(async () => { opCtx.result = { id: 'pos_builtin', name: 'everyone' }; }); + await writeThrough(opCtx, next); + expect(next).toHaveBeenCalledTimes(1); + expect(door.saveMetaItem).not.toHaveBeenCalled(); + }); +}); + describe('[ADR-0131 D3] Q3 = A — a package cannot register a name a Setup position holds in the environment ledger', () => { it('the registry item seam refuses it 422 NAMESPACE_CONFLICT, naming both holders', async () => { const b = await boot(); diff --git a/packages/plugins/plugin-security/src/position-write-through.ts b/packages/plugins/plugin-security/src/position-write-through.ts index 2eb46617da5..2d36e8cfbad 100644 --- a/packages/plugins/plugin-security/src/position-write-through.ts +++ b/packages/plugins/plugin-security/src/position-write-through.ts @@ -39,6 +39,34 @@ * for every definition the catalog lists at the next boot, so the deleted * position would come back. * + * ## One namespace — a name a package or a built-in holds is not taken (C2 stage S10) + * + * Positions hold one name per deployment (ADR-0048 addendum, N.2), and a write + * with no package provenance over a package-held name stays under ADR-0005 + * overlay precedence (N.3): `position` takes no environment overlay + * (`allowOrgOverride: false`), so the metadata door refuses that save with its + * locked-base refusal (`403 NOT_OVERRIDABLE`). A Setup **create**, or a + * **rename into** such a name, is now answered with that same refusal, and the + * row write is undone — before this stage the write-through stood down and the + * row landed beside the package's (or the platform's) position under the same + * name, while a rename also deleted the renamed position's own definition. + * + * - **asked of its owner, never re-derived**: the verdict is the metadata + * door's `packagedBaseRefusal` (the predicate and the emitter `saveMetaItem` + * uses — the engine registry's artifact provenance, + * `SchemaRegistry.getArtifactItem`), relayed as the door built it, code, + * status and sentence; this module stamps no code of its own. It is asked + * for the verdict alone, so nothing is written to environment metadata for + * such a name, whatever the answer; + * - **after the engine's own checks**: the row first, as for every other write + * here, so a refusal the engine already makes keeps its own answer (the + * reserved built-in identity names `400 VALIDATION_FAILED`, one name per + * organization `409 UNIQUE_VIOLATION`); only a row the engine accepted is + * asked about, and its write is undone when the door refuses the name; + * - **a door without that verdict** (a protocol that does not bring + * `packagedBaseRefusal`) keeps the stand-down below, as the `/automation` + * doors keep theirs. + * * ## Where it stands down — the write proceeds exactly as before * * - **a system write** (`isSystem`): the seeders and the package door write @@ -48,12 +76,14 @@ * (the walled half is a later stage's); * - **a kernel whose metadata protocol cannot save and delete**: the legacy * direct write, as the permission-set write-through does; - * - **a name a package or a built-in holds**: its definition is the package's - * (or the platform's), and the metadata door refuses an environment save over - * it (`403 NOT_OVERRIDABLE`, `allowOrgOverride: false`). The question is read - * from the engine registry's artifact provenance + * - **an edit that keeps a name a package or a built-in holds, and a delete + * of such a row**: its definition is the package's (or the platform's), and + * the metadata door refuses an environment save over it. The question is + * read from the engine registry's artifact provenance * (`SchemaRegistry.getArtifactItem`), the same source the door decides - * `NOT_OVERRIDABLE` from (seat re-rule, Q2 = A); + * `NOT_OVERRIDABLE` from (seat re-rule, Q2 = A). Whether the data door admits + * such an edit at all is the system-row gate's call, on the row's provenance + * stamp; * - **an edit of a row whose name the metadata door does not accept**, with the * name unchanged: such a row predates this stage, has no definition and can * have none, so the edit stays a row write (Q1 = A's control). The predicate @@ -103,6 +133,13 @@ export interface PositionWriteThroughLogger { export interface PositionMetadataDoor { saveMetaItem(request: { type: string; name: string; item: Record; actor?: string }): Promise; deleteMetaItem(request: { type: string; name: string; actor?: string }): Promise; + /** + * The door's locked-base verdict without a write: the refusal `saveMetaItem` + * would raise for `(type, name)` on that ground, or `null` + * (`ObjectStackProtocolImplementation.packagedBaseRefusal`). Optional: a door + * without it keeps the stand-down for a package-held name (module note). + */ + packagedBaseRefusal?(request: { type: string; name: string; operation: 'save' | 'delete' }): Error | null; } export interface PositionWriteThroughDeps { @@ -152,6 +189,17 @@ export function packageHoldsPosition(ql: any, name: string): boolean { return false; } +/** + * [C2 stage S10, ADR-0048 addendum N.2/N.3] The metadata door's refusal of a + * position taking `name` — its locked-base verdict, asked and never re-derived + * (module note, "One namespace"). `null` when the door would not refuse that + * save on the locked-base ground, or brings no such verdict. + */ +function heldPositionNameRefusal(door: PositionMetadataDoor, name: unknown): Error | null { + if (typeof name !== 'string' || name === '' || typeof door.packagedBaseRefusal !== 'function') return null; + return door.packagedBaseRefusal({ type: POSITION_METADATA_TYPE, name, operation: 'save' }); +} + let cachedPositionNameSchema: { safeParse(v: unknown): { success: boolean } } | null = null; /** @@ -225,8 +273,10 @@ function logError(logger: PositionWriteThroughLogger | undefined, message: strin /** * Engine middleware: under `single`, write every non-system data-door create, - * edit, rename and delete of a position through to its environment definition. - * See the module note for the order, the undo and where it stands down. + * edit, rename and delete of a position through to its environment definition, + * and refuse a create or a rename into a name a package or a built-in holds. + * See the module note for the order, the undo, the refusal and where it stands + * down. */ export function createPositionWriteThrough( deps: PositionWriteThroughDeps, @@ -281,7 +331,7 @@ export function createPositionWriteThrough( `[security] a ${POSITION_OBJECT} create was refused by the metadata door, and undoing its row FAILED: the ` + `position '${String(row.name)}' exists as a row with no environment definition while the caller was told ` + 'the create was refused. Fix: delete the row by hand, then create the position again under a name the ' + - 'metadata door accepts (lowercase snake_case).', + 'metadata door accepts (lowercase snake_case, and not a name a package or a built-in already holds).', e, { id: row.id, name: row.name }, ); @@ -326,9 +376,23 @@ export function createPositionWriteThrough( const result = opCtx.result; const written = (Array.isArray(result) ? result : [result]) .filter((r: unknown): r is Record => !!r && typeof r === 'object' && scalarId((r as any).id)); + const createdRows: Record[] = []; + for (const created of written) createdRows.push((await readRowById(ql, created.id)) ?? created); + // [S10] A name a package or a built-in holds is not taken: the door's + // refusal is the answer, asked before any definition is saved. A verdict + // the door could not reach (it re-raises anything but its refusal) is + // no answer either: the rows go, and the failure is the answer. + try { + for (const row of createdRows) { + const refusal = heldPositionNameRefusal(door, row.name); + if (refusal) throw refusal; + } + } catch (e) { + await undoInsert(written); + throw e; + } const saved: Record[] = []; - for (const created of written) { - const row = (await readRowById(ql, created.id)) ?? created; + for (const row of createdRows) { if (!environmentOwns(row.name)) continue; try { await saveDefinition(door, row, actor); @@ -364,6 +428,19 @@ export function createPositionWriteThrough( if (post) posts.push({ pre, post }); } + // [S10] A rename into a name a package or a built-in holds is not taken: + // the door's refusal (or its failure to answer) is the answer, and the row + // gets its old values back. An edit that keeps such a name stands down below. + try { + for (const { pre, post } of posts) { + const refusal = pre.name !== post.name ? heldPositionNameRefusal(door, post.name) : null; + if (refusal) throw refusal; + } + } catch (e) { + await undoUpdate(targets, patch); + throw e; + } + // Every new definition first; the old names' definitions go only once all landed. const saved: Array<{ pre: Record; post: Record }> = []; for (const pair of posts) { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 597bd3407f7..7175cd16e5e 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4537,9 +4537,11 @@ export class SecurityPlugin implements Plugin { // rename or delete of a position also writes its DEFINITION through the // metadata door at environment scope — the row first, then the // definition; a refused definition undoes the row. Registered AFTER the - // security middleware, so it runs INSIDE it. Stands down for a walled - // posture, a name a package or built-in holds, and a kernel without a - // capable metadata protocol. See `position-write-through.ts`. + // security middleware, so it runs INSIDE it. A create or a rename into a + // name a package or built-in holds answers the metadata door's own + // refusal (C2 stage S10). Stands down for a walled posture, an edit that + // keeps or a delete of such a name, and a kernel without a capable + // metadata protocol. See `position-write-through.ts`. ql.registerMiddleware( createPositionWriteThrough({ ql,