Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/22937-core-row-definition-mappers.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
160 changes: 160 additions & 0 deletions packages/core/src/security/permission-set-row-definition.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }).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);
});
});
119 changes: 119 additions & 0 deletions packages/core/src/security/permission-set-row-definition.ts
Original file line number Diff line number Diff line change
@@ -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<string> | null = null;
function permissionSpecBodyKeys(): ReadonlySet<string> {
return (cachedSpecBodyKeys ??= new Set(
Object.keys((PermissionSetSchema as unknown as { shape?: Record<string, unknown> }).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<string, unknown> {
const adminScope = row?.admin_scope ? parseMaybeJson(row.admin_scope, undefined) : undefined;
const candidate: Record<string, unknown> = {
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<string, unknown> = {};
for (const [key, value] of Object.entries(candidate)) {
if (keys.has(key)) body[key] = value;
}
return body;
}
Loading
Loading