Skip to content

Commit 9a33ac5

Browse files
committed
Merge PR #22959's branch (core row-definition mappers @ 9e86437) into the C3 integration tree
2 parents 8fd8791 + 9e86437 commit 9a33ac5

6 files changed

Lines changed: 483 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): two pure row → definition mappers for the ADR-0131 upgrade ceremony (`permissionSetDefinitionFromRow`, `positionDefinitionFromRow`)
6+
7+
Clause-②: yes (widening)
8+
9+
- **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.
10+
- **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.
11+
- **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.
12+
- **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.

‎packages/core/src/security/index.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,18 @@ export {
274274
type PositionBindingConversion,
275275
} from './position-binding-conversion.js';
276276

277+
// ADR-0131 D3/D10 — a Setup-authored catalog row turned into the definition it
278+
// carries, so the upgrade ceremony (`os migrate`) can save it through the
279+
// metadata door. Pure; applied by the ceremony, never at boot.
280+
export {
281+
permissionSetDefinitionFromRow,
282+
type PermissionSetRowForDefinition,
283+
} from './permission-set-row-definition.js';
284+
export {
285+
positionDefinitionFromRow,
286+
type PositionRowForDefinition,
287+
} from './position-row-definition.js';
288+
277289
// [commit f8eb73601] The measured read surface of the administrator derivation — the
278290
// single source `plugin-auth`'s break-glass standing-key lists correspond to.
279291
export {
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D3/D10] The pin of `permissionSetDefinitionFromRow` — the mapping
5+
* the upgrade ceremony applies to turn a Setup-authored `sys_permission_set`
6+
* row into the definition it saves through the metadata door.
7+
*
8+
* The expected bodies are the outputs the module doc states, written out by
9+
* hand: this package cannot import the security plugin's copy, so the
10+
* byte-for-behaviour comparison with it was a one-time measurement, recorded on
11+
* the pull request that added this file.
12+
*/
13+
14+
import { describe, expect, it } from 'vitest';
15+
import { PermissionSetSchema } from '@objectstack/spec/security';
16+
import { permissionSetDefinitionFromRow } from './permission-set-row-definition.js';
17+
18+
/** A row as `sys_permission_set` stores it: the facets are JSON text, and the state columns ride along. */
19+
const storedRow = {
20+
id: 'ps_01',
21+
name: 'sales_ops',
22+
label: 'Sales Operations',
23+
description: 'Edits accounts and reads opportunities',
24+
object_permissions: JSON.stringify({ account: { allowRead: true, allowEdit: true }, opportunity: { allowRead: true } }),
25+
field_permissions: JSON.stringify({ 'account.annual_revenue': { readable: true, editable: false } }),
26+
system_permissions: JSON.stringify(['export_reports']),
27+
row_level_security: JSON.stringify([]),
28+
tab_permissions: JSON.stringify({ accounts: 'visible' }),
29+
admin_scope: JSON.stringify({ businessUnit: 'emea' }),
30+
active: false,
31+
managed_by: 'admin',
32+
customized: true,
33+
package_id: null,
34+
organization_id: 'org_1',
35+
created_at: '2026-01-01T00:00:00.000Z',
36+
updated_at: '2026-01-02T00:00:00.000Z',
37+
};
38+
39+
/** Every body key the mapping can emit, in the order it emits them. */
40+
const EVERY_BODY_KEY = [
41+
'name', 'label', 'description', 'objects', 'fields',
42+
'systemPermissions', 'rowLevelSecurity', 'tabPermissions', 'adminScope',
43+
];
44+
45+
describe('permissionSetDefinitionFromRow', () => {
46+
it('turns the snake_case JSON facet columns into the camelCase body, key for key and in order', () => {
47+
const body = permissionSetDefinitionFromRow(storedRow);
48+
expect(body).toStrictEqual({
49+
name: 'sales_ops',
50+
label: 'Sales Operations',
51+
description: 'Edits accounts and reads opportunities',
52+
objects: { account: { allowRead: true, allowEdit: true }, opportunity: { allowRead: true } },
53+
fields: { 'account.annual_revenue': { readable: true, editable: false } },
54+
systemPermissions: ['export_reports'],
55+
rowLevelSecurity: [],
56+
tabPermissions: { accounts: 'visible' },
57+
adminScope: { businessUnit: 'emea' },
58+
});
59+
expect(Object.keys(body)).toEqual(EVERY_BODY_KEY);
60+
});
61+
62+
it('every key it emits is one PermissionSetSchema declares, and none is filtered away (a spec rename reddens this)', () => {
63+
const declared = Object.keys((PermissionSetSchema as unknown as { shape: Record<string, unknown> }).shape);
64+
for (const key of EVERY_BODY_KEY) expect(declared, key).toContain(key);
65+
expect(Object.keys(permissionSetDefinitionFromRow(storedRow))).toHaveLength(EVERY_BODY_KEY.length);
66+
});
67+
68+
it('a well-formed row answers a body the permission-set schema accepts', () => {
69+
const parsed = PermissionSetSchema.safeParse(permissionSetDefinitionFromRow(storedRow));
70+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
71+
});
72+
73+
it('never carries a row state, provenance or identity column', () => {
74+
const body = permissionSetDefinitionFromRow(storedRow);
75+
for (const column of ['id', 'active', 'managed_by', 'customized', 'package_id', 'organization_id', 'created_at', 'updated_at']) {
76+
expect(body, column).not.toHaveProperty(column);
77+
}
78+
});
79+
80+
it('takes a facet a driver already parsed as it is', () => {
81+
const body = permissionSetDefinitionFromRow({
82+
name: 'parsed',
83+
object_permissions: { account: { allowRead: true } },
84+
system_permissions: ['manage_users'],
85+
admin_scope: { businessUnit: 'apac' },
86+
});
87+
expect(body.objects).toEqual({ account: { allowRead: true } });
88+
expect(body.systemPermissions).toEqual(['manage_users']);
89+
expect(body.adminScope).toEqual({ businessUnit: 'apac' });
90+
});
91+
92+
it('an absent, null, empty, JSON-null or unparseable facet answers its default', () => {
93+
for (const stored of [undefined, null, '', 'null', '{not json']) {
94+
const body = permissionSetDefinitionFromRow({
95+
name: 'defaults',
96+
object_permissions: stored,
97+
field_permissions: stored,
98+
system_permissions: stored,
99+
row_level_security: stored,
100+
tab_permissions: stored,
101+
});
102+
expect(body, String(stored)).toStrictEqual({
103+
name: 'defaults',
104+
label: 'defaults',
105+
objects: {},
106+
fields: {},
107+
systemPermissions: [],
108+
rowLevelSecurity: [],
109+
tabPermissions: {},
110+
});
111+
}
112+
});
113+
114+
it('answers a fresh default per call, never one shared object', () => {
115+
const first = permissionSetDefinitionFromRow({ name: 'a' });
116+
const second = permissionSetDefinitionFromRow({ name: 'b' });
117+
expect(first.objects).not.toBe(second.objects);
118+
expect(first.systemPermissions).not.toBe(second.systemPermissions);
119+
});
120+
121+
it('the label falls back to the name only when the column is null or absent', () => {
122+
expect(permissionSetDefinitionFromRow({ name: 'n', label: null }).label).toBe('n');
123+
expect(permissionSetDefinitionFromRow({ name: 'n' }).label).toBe('n');
124+
expect(permissionSetDefinitionFromRow({ name: 'n', label: '' }).label).toBe('');
125+
});
126+
127+
it('the description travels unless the column is null or absent', () => {
128+
expect(permissionSetDefinitionFromRow({ name: 'n', description: null })).not.toHaveProperty('description');
129+
expect(permissionSetDefinitionFromRow({ name: 'n' })).not.toHaveProperty('description');
130+
expect(permissionSetDefinitionFromRow({ name: 'n', description: '' }).description).toBe('');
131+
});
132+
133+
it('the admin scope travels only when the column is set and reads to a value', () => {
134+
for (const stored of [undefined, null, '', 'null', 'false', '0', '{not json']) {
135+
expect(permissionSetDefinitionFromRow({ name: 'n', admin_scope: stored }), String(stored)).not.toHaveProperty('adminScope');
136+
}
137+
expect(permissionSetDefinitionFromRow({ name: 'n', admin_scope: '{"businessUnit":"emea"}' }).adminScope).toEqual({ businessUnit: 'emea' });
138+
});
139+
140+
it('a null or absent row answers the body of a row with every column absent', () => {
141+
const empty = {
142+
name: undefined,
143+
label: undefined,
144+
objects: {},
145+
fields: {},
146+
systemPermissions: [],
147+
rowLevelSecurity: [],
148+
tabPermissions: {},
149+
};
150+
expect(permissionSetDefinitionFromRow(null)).toStrictEqual(empty);
151+
expect(permissionSetDefinitionFromRow(undefined)).toStrictEqual(empty);
152+
});
153+
154+
it('is pure: the row is read, never written, and the same row answers the same body', () => {
155+
const row = Object.freeze({ ...storedRow });
156+
const first = permissionSetDefinitionFromRow(row);
157+
expect(permissionSetDefinitionFromRow(row)).toStrictEqual(first);
158+
expect(row).toStrictEqual(storedRow);
159+
});
160+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D3/D10, ADR-0094 D4] A `sys_permission_set` row, turned into the
5+
* permission-set DEFINITION it carries.
6+
*
7+
* The security catalog's one home is environment metadata (ADR-0131 D3). A
8+
* permission set an administrator authored in Setup before that move is a row
9+
* and nothing else, and the upgrade ceremony (`os migrate`, C7b) converts it:
10+
* it reads the row, calls this function, and saves the answer through the
11+
* metadata door. ⛔ Never at boot: a conversion that ran on every start would be
12+
* the dual source ADR-0131 D10 retires.
13+
*
14+
* The function is pure: one row in, one body out — no engine, no I/O. It is
15+
* the logic the security plugin's boot backfill applies today
16+
* (`permissionSetBodyFromRow`), held here so the ceremony and that plugin read
17+
* one mapping; the plugin's copy is deleted when its boot module is.
18+
*
19+
* The mapping is the inverse of the row shape the security plugin projects a
20+
* definition into: the snake_case facet columns, stored as JSON text, become
21+
* the camelCase body keys —
22+
*
23+
* - `name` → `name`; `label` → `label`, falling back to the name when the
24+
* column is `null` or absent;
25+
* - `description` → `description`, only when the column is neither `null`
26+
* nor absent;
27+
* - `object_permissions` → `objects` (default `{}`), `field_permissions` →
28+
* `fields` (`{}`), `system_permissions` → `systemPermissions` (`[]`),
29+
* `row_level_security` → `rowLevelSecurity` (`[]`), `tab_permissions` →
30+
* `tabPermissions` (`{}`). A column that is JSON text is parsed; one a
31+
* driver already parsed is taken as it is; a `null` or absent column, an
32+
* empty string, JSON `null`, or text that does not parse answers the
33+
* default;
34+
* - `admin_scope` → `adminScope` (ADR-0090 D12), only when the column is
35+
* truthy and reads to a truthy value.
36+
*
37+
* The body is built from those columns alone, so a row's state and provenance
38+
* columns (`active`, `managed_by`, `customized`, the timestamps) are not a
39+
* definition and never travel into one. It then keeps only the keys
40+
* `PermissionSetSchema` declares, read from the schema's own shape, never
41+
* transcribed, so the door never meets an unrecognized key; a spec key renamed
42+
* away would drop out here, which is why the pin asserts every key above
43+
* survives.
44+
*
45+
* ⛔ It is not validation. A row whose stored facet JSON is off-contract still
46+
* answers a body; the metadata door's validation is what refuses it, and
47+
* reporting that refusal is the ceremony's.
48+
*/
49+
50+
import { PermissionSetSchema } from '@objectstack/spec/security';
51+
52+
/**
53+
* One `sys_permission_set` row, as the mapping reads it. Every other column a
54+
* row carries is accepted and ignored.
55+
*/
56+
export interface PermissionSetRowForDefinition {
57+
readonly name?: unknown;
58+
readonly label?: unknown;
59+
readonly description?: unknown;
60+
readonly object_permissions?: unknown;
61+
readonly field_permissions?: unknown;
62+
readonly system_permissions?: unknown;
63+
readonly row_level_security?: unknown;
64+
readonly tab_permissions?: unknown;
65+
readonly admin_scope?: unknown;
66+
readonly [column: string]: unknown;
67+
}
68+
69+
/** A facet column as stored: JSON text is parsed, a parsed value is kept, anything unreadable is the default. */
70+
const parseMaybeJson = (value: unknown, fallback: unknown): unknown => {
71+
if (typeof value !== 'string') return value ?? fallback;
72+
try {
73+
const parsed = JSON.parse(value === '' ? 'null' : value);
74+
return parsed ?? fallback;
75+
} catch {
76+
return fallback;
77+
}
78+
};
79+
80+
/**
81+
* The body keys `PermissionSetSchema` declares. Resolved on first use, not at
82+
* module load: the schema is a lazy proxy, and touching `.shape` at import
83+
* would materialize it for every process that merely loads this module.
84+
*/
85+
let cachedSpecBodyKeys: ReadonlySet<string> | null = null;
86+
function permissionSpecBodyKeys(): ReadonlySet<string> {
87+
return (cachedSpecBodyKeys ??= new Set(
88+
Object.keys((PermissionSetSchema as unknown as { shape?: Record<string, unknown> }).shape ?? {}),
89+
));
90+
}
91+
92+
/**
93+
* The permission-set definition one `sys_permission_set` row carries (module
94+
* doc). Deterministic: the same row answers the same body, key for key and in
95+
* the same key order. A `null` or absent row answers the body of a row with
96+
* every column absent.
97+
*/
98+
export function permissionSetDefinitionFromRow(
99+
row: PermissionSetRowForDefinition | null | undefined,
100+
): Record<string, unknown> {
101+
const adminScope = row?.admin_scope ? parseMaybeJson(row.admin_scope, undefined) : undefined;
102+
const candidate: Record<string, unknown> = {
103+
name: row?.name,
104+
label: row?.label ?? row?.name,
105+
...(row?.description != null ? { description: row.description } : {}),
106+
objects: parseMaybeJson(row?.object_permissions, {}),
107+
fields: parseMaybeJson(row?.field_permissions, {}),
108+
systemPermissions: parseMaybeJson(row?.system_permissions, []),
109+
rowLevelSecurity: parseMaybeJson(row?.row_level_security, []),
110+
tabPermissions: parseMaybeJson(row?.tab_permissions, {}),
111+
...(adminScope ? { adminScope } : {}),
112+
};
113+
const keys = permissionSpecBodyKeys();
114+
const body: Record<string, unknown> = {};
115+
for (const [key, value] of Object.entries(candidate)) {
116+
if (keys.has(key)) body[key] = value;
117+
}
118+
return body;
119+
}

0 commit comments

Comments
 (0)