diff --git a/.changeset/22937-core-row-definition-mappers.md b/.changeset/22937-core-row-definition-mappers.md new file mode 100644 index 00000000000..e302bda0e5b --- /dev/null +++ b/.changeset/22937-core-row-definition-mappers.md @@ -0,0 +1,12 @@ +--- +"@objectstack/core": minor +--- + +feat(core): two pure row → definition mappers for the ADR-0131 upgrade ceremony (`permissionSetDefinitionFromRow`, `positionDefinitionFromRow`) + +Clause-②: yes (widening) + +- **What is new.** `permissionSetDefinitionFromRow(row)` turns one `sys_permission_set` row into the permission-set definition it carries, and `positionDefinitionFromRow(row)` turns one `sys_position` row into the position definition it carries. The row types `PermissionSetRowForDefinition` and `PositionRowForDefinition` are exported with them. Both sit beside `convertPositionBindingRows`. The upgrade ceremony (`os migrate`) is to apply them, to save Setup-authored rows through the metadata door. ⛔ Neither runs at boot. +- **The permission-set mapping.** The JSON-text facet columns become the camelCase body keys: `object_permissions` → `objects`, `field_permissions` → `fields`, `system_permissions` → `systemPermissions`, `row_level_security` → `rowLevelSecurity`, `tab_permissions` → `tabPermissions`, and `admin_scope` → `adminScope` only when the column is truthy and reads to a truthy value. `label` falls back to the name only when the column is `null` or absent; an empty-string label is kept (unlike the position rule below). A facet column that is `null`, absent, an empty string, JSON `null` or unparseable text answers its default; a facet a driver already parsed is kept as it is. Only the definition columns are read, so `active`, `managed_by`, `customized` and the timestamps never enter the body. The body is then filtered to the keys `PermissionSetSchema` declares. +- **The position mapping.** `{ name, label, description, delegatable }`: `label` falls back to the name unless it is a non-empty string (so `''` is replaced by the name). `description` travels only when it is a non-empty string: `null`, `''`, an absent column and a non-string are absent, and a whitespace-only string travels as stored. `delegatable` is `true` only for a stored `true`, `1`, `'1'` or `'true'`. `active` and `is_default` are row state and never travel. +- **No behaviour changes.** Both functions are pure (one row in, one body out). They produce the same output as the security plugin's own copies, `permissionSetBodyFromRow` and `positionBodyFromRow`, and nothing calls them yet. Neither validates: the metadata door still refuses a body or a name it does not accept. diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index c73c21c12ed..397a792ba11 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -279,6 +279,18 @@ export { type PositionBindingConversion, } from './position-binding-conversion.js'; +// ADR-0131 D3/D10 — a Setup-authored catalog row turned into the definition it +// carries, so the upgrade ceremony (`os migrate`) can save it through the +// metadata door. Pure; applied by the ceremony, never at boot. +export { + permissionSetDefinitionFromRow, + type PermissionSetRowForDefinition, +} from './permission-set-row-definition.js'; +export { + positionDefinitionFromRow, + type PositionRowForDefinition, +} from './position-row-definition.js'; + // [commit f8eb73601] The measured read surface of the administrator derivation — the // single source `plugin-auth`'s break-glass standing-key lists correspond to. export { diff --git a/packages/core/src/security/permission-set-row-definition.test.ts b/packages/core/src/security/permission-set-row-definition.test.ts new file mode 100644 index 00000000000..9e86412cfa5 --- /dev/null +++ b/packages/core/src/security/permission-set-row-definition.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D10] The pin of `permissionSetDefinitionFromRow` — the mapping + * the upgrade ceremony applies to turn a Setup-authored `sys_permission_set` + * row into the definition it saves through the metadata door. + * + * The expected bodies are the outputs the module doc states, written out by + * hand: this package cannot import the security plugin's copy, so the + * byte-for-behaviour comparison with it was a one-time measurement, recorded on + * the pull request that added this file. + */ + +import { describe, expect, it } from 'vitest'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { permissionSetDefinitionFromRow } from './permission-set-row-definition.js'; + +/** A row as `sys_permission_set` stores it: the facets are JSON text, and the state columns ride along. */ +const storedRow = { + id: 'ps_01', + name: 'sales_ops', + label: 'Sales Operations', + description: 'Edits accounts and reads opportunities', + object_permissions: JSON.stringify({ account: { allowRead: true, allowEdit: true }, opportunity: { allowRead: true } }), + field_permissions: JSON.stringify({ 'account.annual_revenue': { readable: true, editable: false } }), + system_permissions: JSON.stringify(['export_reports']), + row_level_security: JSON.stringify([]), + tab_permissions: JSON.stringify({ accounts: 'visible' }), + admin_scope: JSON.stringify({ businessUnit: 'emea' }), + active: false, + managed_by: 'admin', + customized: true, + package_id: null, + organization_id: 'org_1', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-02T00:00:00.000Z', +}; + +/** Every body key the mapping can emit, in the order it emits them. */ +const EVERY_BODY_KEY = [ + 'name', 'label', 'description', 'objects', 'fields', + 'systemPermissions', 'rowLevelSecurity', 'tabPermissions', 'adminScope', +]; + +describe('permissionSetDefinitionFromRow', () => { + it('turns the snake_case JSON facet columns into the camelCase body, key for key and in order', () => { + const body = permissionSetDefinitionFromRow(storedRow); + expect(body).toStrictEqual({ + name: 'sales_ops', + label: 'Sales Operations', + description: 'Edits accounts and reads opportunities', + objects: { account: { allowRead: true, allowEdit: true }, opportunity: { allowRead: true } }, + fields: { 'account.annual_revenue': { readable: true, editable: false } }, + systemPermissions: ['export_reports'], + rowLevelSecurity: [], + tabPermissions: { accounts: 'visible' }, + adminScope: { businessUnit: 'emea' }, + }); + expect(Object.keys(body)).toEqual(EVERY_BODY_KEY); + }); + + it('every key it emits is one PermissionSetSchema declares, and none is filtered away (a spec rename reddens this)', () => { + const declared = Object.keys((PermissionSetSchema as unknown as { shape: Record }).shape); + for (const key of EVERY_BODY_KEY) expect(declared, key).toContain(key); + expect(Object.keys(permissionSetDefinitionFromRow(storedRow))).toHaveLength(EVERY_BODY_KEY.length); + }); + + it('a well-formed row answers a body the permission-set schema accepts', () => { + const parsed = PermissionSetSchema.safeParse(permissionSetDefinitionFromRow(storedRow)); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('never carries a row state, provenance or identity column', () => { + const body = permissionSetDefinitionFromRow(storedRow); + for (const column of ['id', 'active', 'managed_by', 'customized', 'package_id', 'organization_id', 'created_at', 'updated_at']) { + expect(body, column).not.toHaveProperty(column); + } + }); + + it('takes a facet a driver already parsed as it is', () => { + const body = permissionSetDefinitionFromRow({ + name: 'parsed', + object_permissions: { account: { allowRead: true } }, + system_permissions: ['manage_users'], + admin_scope: { businessUnit: 'apac' }, + }); + expect(body.objects).toEqual({ account: { allowRead: true } }); + expect(body.systemPermissions).toEqual(['manage_users']); + expect(body.adminScope).toEqual({ businessUnit: 'apac' }); + }); + + it('an absent, null, empty, JSON-null or unparseable facet answers its default', () => { + for (const stored of [undefined, null, '', 'null', '{not json']) { + const body = permissionSetDefinitionFromRow({ + name: 'defaults', + object_permissions: stored, + field_permissions: stored, + system_permissions: stored, + row_level_security: stored, + tab_permissions: stored, + }); + expect(body, String(stored)).toStrictEqual({ + name: 'defaults', + label: 'defaults', + objects: {}, + fields: {}, + systemPermissions: [], + rowLevelSecurity: [], + tabPermissions: {}, + }); + } + }); + + it('answers a fresh default per call, never one shared object', () => { + const first = permissionSetDefinitionFromRow({ name: 'a' }); + const second = permissionSetDefinitionFromRow({ name: 'b' }); + expect(first.objects).not.toBe(second.objects); + expect(first.systemPermissions).not.toBe(second.systemPermissions); + }); + + it('the label falls back to the name only when the column is null or absent', () => { + expect(permissionSetDefinitionFromRow({ name: 'n', label: null }).label).toBe('n'); + expect(permissionSetDefinitionFromRow({ name: 'n' }).label).toBe('n'); + expect(permissionSetDefinitionFromRow({ name: 'n', label: '' }).label).toBe(''); + }); + + it('the description travels unless the column is null or absent', () => { + expect(permissionSetDefinitionFromRow({ name: 'n', description: null })).not.toHaveProperty('description'); + expect(permissionSetDefinitionFromRow({ name: 'n' })).not.toHaveProperty('description'); + expect(permissionSetDefinitionFromRow({ name: 'n', description: '' }).description).toBe(''); + }); + + it('the admin scope travels only when the column is set and reads to a value', () => { + for (const stored of [undefined, null, '', 'null', 'false', '0', '{not json']) { + expect(permissionSetDefinitionFromRow({ name: 'n', admin_scope: stored }), String(stored)).not.toHaveProperty('adminScope'); + } + expect(permissionSetDefinitionFromRow({ name: 'n', admin_scope: '{"businessUnit":"emea"}' }).adminScope).toEqual({ businessUnit: 'emea' }); + }); + + it('a null or absent row answers the body of a row with every column absent', () => { + const empty = { + name: undefined, + label: undefined, + objects: {}, + fields: {}, + systemPermissions: [], + rowLevelSecurity: [], + tabPermissions: {}, + }; + expect(permissionSetDefinitionFromRow(null)).toStrictEqual(empty); + expect(permissionSetDefinitionFromRow(undefined)).toStrictEqual(empty); + }); + + it('is pure: the row is read, never written, and the same row answers the same body', () => { + const row = Object.freeze({ ...storedRow }); + const first = permissionSetDefinitionFromRow(row); + expect(permissionSetDefinitionFromRow(row)).toStrictEqual(first); + expect(row).toStrictEqual(storedRow); + }); +}); diff --git a/packages/core/src/security/permission-set-row-definition.ts b/packages/core/src/security/permission-set-row-definition.ts new file mode 100644 index 00000000000..987eabe8d00 --- /dev/null +++ b/packages/core/src/security/permission-set-row-definition.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D10, ADR-0094 D4] A `sys_permission_set` row, turned into the + * permission-set DEFINITION it carries. + * + * The security catalog's one home is environment metadata (ADR-0131 D3). A + * permission set an administrator authored in Setup before that move is a row + * and nothing else, and the upgrade ceremony (`os migrate`, C7b) converts it: + * it reads the row, calls this function, and saves the answer through the + * metadata door. ⛔ Never at boot: a conversion that ran on every start would be + * the dual source ADR-0131 D10 retires. + * + * The function is pure: one row in, one body out — no engine, no I/O. It is + * the logic the security plugin's boot backfill applies today + * (`permissionSetBodyFromRow`), held here so the ceremony and that plugin read + * one mapping; the plugin's copy is deleted when its boot module is. + * + * The mapping is the inverse of the row shape the security plugin projects a + * definition into: the snake_case facet columns, stored as JSON text, become + * the camelCase body keys — + * + * - `name` → `name`; `label` → `label`, falling back to the name when the + * column is `null` or absent; + * - `description` → `description`, only when the column is neither `null` + * nor absent; + * - `object_permissions` → `objects` (default `{}`), `field_permissions` → + * `fields` (`{}`), `system_permissions` → `systemPermissions` (`[]`), + * `row_level_security` → `rowLevelSecurity` (`[]`), `tab_permissions` → + * `tabPermissions` (`{}`). A column that is JSON text is parsed; one a + * driver already parsed is taken as it is; a `null` or absent column, an + * empty string, JSON `null`, or text that does not parse answers the + * default; + * - `admin_scope` → `adminScope` (ADR-0090 D12), only when the column is + * truthy and reads to a truthy value. + * + * The body is built from those columns alone, so a row's state and provenance + * columns (`active`, `managed_by`, `customized`, the timestamps) are not a + * definition and never travel into one. It then keeps only the keys + * `PermissionSetSchema` declares, read from the schema's own shape, never + * transcribed, so the door never meets an unrecognized key; a spec key renamed + * away would drop out here, which is why the pin asserts every key above + * survives. + * + * ⛔ It is not validation. A row whose stored facet JSON is off-contract still + * answers a body; the metadata door's validation is what refuses it, and + * reporting that refusal is the ceremony's. + */ + +import { PermissionSetSchema } from '@objectstack/spec/security'; + +/** + * One `sys_permission_set` row, as the mapping reads it. Every other column a + * row carries is accepted and ignored. + */ +export interface PermissionSetRowForDefinition { + readonly name?: unknown; + readonly label?: unknown; + readonly description?: unknown; + readonly object_permissions?: unknown; + readonly field_permissions?: unknown; + readonly system_permissions?: unknown; + readonly row_level_security?: unknown; + readonly tab_permissions?: unknown; + readonly admin_scope?: unknown; + readonly [column: string]: unknown; +} + +/** A facet column as stored: JSON text is parsed, a parsed value is kept, anything unreadable is the default. */ +const parseMaybeJson = (value: unknown, fallback: unknown): unknown => { + if (typeof value !== 'string') return value ?? fallback; + try { + const parsed = JSON.parse(value === '' ? 'null' : value); + return parsed ?? fallback; + } catch { + return fallback; + } +}; + +/** + * The body keys `PermissionSetSchema` declares. Resolved on first use, not at + * module load: the schema is a lazy proxy, and touching `.shape` at import + * would materialize it for every process that merely loads this module. + */ +let cachedSpecBodyKeys: ReadonlySet | null = null; +function permissionSpecBodyKeys(): ReadonlySet { + return (cachedSpecBodyKeys ??= new Set( + Object.keys((PermissionSetSchema as unknown as { shape?: Record }).shape ?? {}), + )); +} + +/** + * The permission-set definition one `sys_permission_set` row carries (module + * doc). Deterministic: the same row answers the same body, key for key and in + * the same key order. A `null` or absent row answers the body of a row with + * every column absent. + */ +export function permissionSetDefinitionFromRow( + row: PermissionSetRowForDefinition | null | undefined, +): Record { + const adminScope = row?.admin_scope ? parseMaybeJson(row.admin_scope, undefined) : undefined; + const candidate: Record = { + name: row?.name, + label: row?.label ?? row?.name, + ...(row?.description != null ? { description: row.description } : {}), + objects: parseMaybeJson(row?.object_permissions, {}), + fields: parseMaybeJson(row?.field_permissions, {}), + systemPermissions: parseMaybeJson(row?.system_permissions, []), + rowLevelSecurity: parseMaybeJson(row?.row_level_security, []), + tabPermissions: parseMaybeJson(row?.tab_permissions, {}), + ...(adminScope ? { adminScope } : {}), + }; + const keys = permissionSpecBodyKeys(); + const body: Record = {}; + for (const [key, value] of Object.entries(candidate)) { + if (keys.has(key)) body[key] = value; + } + return body; +} diff --git a/packages/core/src/security/position-row-definition.test.ts b/packages/core/src/security/position-row-definition.test.ts new file mode 100644 index 00000000000..aaaf23c74b1 --- /dev/null +++ b/packages/core/src/security/position-row-definition.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D10] The pin of `positionDefinitionFromRow` — the mapping the + * upgrade ceremony applies to turn a Setup-created `sys_position` row into the + * definition it saves through the metadata door. + * + * The expected bodies are the outputs the module doc states, written out by + * hand: this package cannot import the security plugin's copy, so the + * byte-for-behaviour comparison with it was a one-time measurement, recorded on + * the pull request that added this file. + */ + +import { describe, expect, it } from 'vitest'; +import { PositionSchema } from '@objectstack/spec/identity'; +import { positionDefinitionFromRow } from './position-row-definition.js'; + +/** A row as `sys_position` stores it, its row state riding along. */ +const storedRow = { + id: 'pos_01', + name: 'regional_manager', + label: 'Regional Manager', + description: 'Runs one sales region', + delegatable: 1, + active: false, + is_default: true, + managed_by: 'admin', + organization_id: 'org_1', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-02T00:00:00.000Z', +}; + +describe('positionDefinitionFromRow', () => { + it('answers the four definition keys the row has columns for, in order', () => { + const body = positionDefinitionFromRow(storedRow); + expect(body).toStrictEqual({ + name: 'regional_manager', + label: 'Regional Manager', + description: 'Runs one sales region', + delegatable: true, + }); + expect(Object.keys(body)).toEqual(['name', 'label', 'description', 'delegatable']); + }); + + it('every key it emits is one PositionSchema declares, and a well-formed row answers a body the schema accepts', () => { + const declared = Object.keys((PositionSchema as unknown as { shape: Record }).shape); + const body = positionDefinitionFromRow(storedRow); + for (const key of Object.keys(body)) expect(declared, key).toContain(key); + const parsed = PositionSchema.safeParse(body); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('never carries a row state, provenance or identity column', () => { + const body = positionDefinitionFromRow(storedRow); + for (const column of ['id', 'active', 'is_default', 'managed_by', 'organization_id', 'created_at', 'updated_at']) { + expect(body, column).not.toHaveProperty(column); + } + }); + + it('the label falls back to the name unless it is a non-empty string', () => { + for (const label of [undefined, null, '', 42]) { + expect(positionDefinitionFromRow({ name: 'auditor', label }).label, String(label)).toBe('auditor'); + } + expect(positionDefinitionFromRow({ name: 'auditor', label: 'Auditor' }).label).toBe('Auditor'); + }); + + it('the description travels only as a non-empty string — never as null', () => { + for (const description of [undefined, null, '', 7]) { + expect(positionDefinitionFromRow({ name: 'auditor', description }), String(description)).not.toHaveProperty('description'); + } + expect(positionDefinitionFromRow({ name: 'auditor', description: 'Reads all' }).description).toBe('Reads all'); + }); + + it('delegatable is always present: true for true, 1, "1" and "true", false for everything else', () => { + for (const stored of [true, 1, '1', 'true']) { + expect(positionDefinitionFromRow({ name: 'p', delegatable: stored }).delegatable, String(stored)).toBe(true); + } + for (const stored of [undefined, null, false, 0, '0', 'false', '', 'TRUE', 'yes', 2]) { + expect(positionDefinitionFromRow({ name: 'p', delegatable: stored }).delegatable, String(stored)).toBe(false); + } + }); + + it('two rows of one name answer equal serializations exactly when their definitions agree', () => { + const serialized = (row: Parameters[0]) => JSON.stringify(positionDefinitionFromRow(row)); + // Row state and organization differ; the definition does not. + expect(serialized({ ...storedRow, id: 'pos_02', organization_id: 'org_2', active: true, delegatable: 'true' })) + .toBe(serialized(storedRow)); + // An empty description is the same definition as a null one. + expect(serialized({ name: 'p', label: 'P', description: '' })).toBe(serialized({ name: 'p', label: 'P', description: null })); + // A different label is a different definition. + expect(serialized({ ...storedRow, label: 'Area Manager' })).not.toBe(serialized(storedRow)); + }); + + it('does not judge the name: a name the metadata door refuses still answers a body', () => { + expect(positionDefinitionFromRow({ name: 'Regional Manager' })).toStrictEqual({ + name: 'Regional Manager', + label: 'Regional Manager', + delegatable: false, + }); + }); + + it('is pure: the row is read, never written, and the same row answers the same body', () => { + const row = Object.freeze({ ...storedRow }); + const first = positionDefinitionFromRow(row); + expect(positionDefinitionFromRow(row)).toStrictEqual(first); + expect(row).toStrictEqual(storedRow); + }); +}); diff --git a/packages/core/src/security/position-row-definition.ts b/packages/core/src/security/position-row-definition.ts new file mode 100644 index 00000000000..2f23f68e0a4 --- /dev/null +++ b/packages/core/src/security/position-row-definition.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D10] A `sys_position` row, turned into the position DEFINITION + * it carries. + * + * The security catalog's one home is environment metadata (ADR-0131 D3). A + * position an administrator created in Setup before that move is a row and + * nothing else, and the upgrade ceremony (`os migrate`, C7b) converts it: it + * reads the row, calls this function, and saves the answer through the + * metadata door. ⛔ Never at boot: a conversion that ran on every start would be + * the dual source ADR-0131 D10 retires. + * + * The function is pure: one row in, one body out — no engine, no I/O. It is + * the mapping the security plugin's row-only position backfill applies today + * (`positionBodyFromRow`, beside its position write-through), held here so the + * ceremony and that plugin read one mapping; the plugin's copy is deleted when + * its boot module is. + * + * The definition is the four `PositionSchema` keys the row has columns for, in + * this key order: + * + * - `name` → `name`, as stored; + * - `label` → `label` when it is a non-empty string, else the name; + * - `description` → `description` only when it is a non-empty string: the + * schema declares it optional, not nullable, so a `null` or empty column is + * absent, never `null`; + * - `delegatable` → `delegatable`, always present: `true` for a stored + * `true`, `1`, `'1'` or `'true'`, and `false` for anything else, an absent + * column included. + * + * `active` and `is_default` are row state — no `PositionSchema` key carries + * them — and never travel; neither does any provenance or timestamp column. + * The position's `permissionSets` are not a column either: the junction rows + * that bound them convert through `convertPositionBindingRows`. + * + * ⛔ It is not validation, and it does not judge the name. A name outside the + * metadata door's item-name grammar still answers a body; the door's own + * validation refuses it, and reporting that refusal is the ceremony's. Nor + * does it reconcile rows: where two organizations' rows of one name answer + * different definitions, which one the environment keeps is the ceremony's to + * report, never this function's to pick. + */ + +/** + * One `sys_position` row, as the mapping reads it. Every other column a row + * carries is accepted and ignored. + */ +export interface PositionRowForDefinition { + readonly name?: unknown; + readonly label?: unknown; + readonly description?: unknown; + readonly delegatable?: unknown; + readonly [column: string]: unknown; +} + +/** + * The position definition one `sys_position` row carries (module doc). + * Deterministic: the same row answers the same body, key for key and in the + * same key order, so two rows' bodies compare equal exactly when their + * serializations do. + */ +export function positionDefinitionFromRow(row: PositionRowForDefinition): Record { + const body: Record = { + name: row.name, + label: typeof row.label === 'string' && row.label !== '' ? row.label : row.name, + }; + if (typeof row.description === 'string' && row.description !== '') body.description = row.description; + body.delegatable = + row.delegatable === true || row.delegatable === 1 || row.delegatable === '1' || row.delegatable === 'true'; + return body; +}