Skip to content

Commit 45d3507

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(authz): ADR-0066 D1 — capability registry (sys_capability records) (#2239)
Promotes authorization capabilities from bare strings to first-class records (layer 1 of the ADR-0066 three-way separation). New `sys_capability` system object (name/label/description/scope[platform|org]/managed_by[platform|package| admin]/active), registered + given a Setup → Access Control nav entry. Back-compat: bootstrapSystemCapabilities seeds the curated platform capabilities (manage_users, manage_org_users, manage_metadata, manage_platform_settings, setup.access, studio.access) PLUS any capability referenced by the default grants' systemPermissions — idempotently, on kernel:ready. systemPermissions[] and requiredPermissions[] keep referencing capabilities BY NAME (the strings ARE the references), so nothing migrates; the registry is the catalog/definition. Named `sys_capability` (not the ADR's loose `sys_permission`) to avoid collision with `sys_permission_set` and match ADR-0066's "capability" vocabulary. Tests: +6 (object shape + seeding idempotency/derivation). plugin-security full DTS + 139 tests green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f911e1d commit 45d3507

7 files changed

Lines changed: 426 additions & 1 deletion

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { bootstrapSystemCapabilities, KNOWN_CAPABILITIES } from './bootstrap-system-capabilities.js';
5+
6+
/** Minimal in-memory ql for sys_capability seeding. */
7+
function makeQl() {
8+
const rows: any[] = [];
9+
return {
10+
rows,
11+
async find(object: string, q: any) {
12+
if (object !== 'sys_capability') return [];
13+
const where = q?.where ?? {};
14+
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
15+
},
16+
async insert(object: string, data: any) {
17+
if (object !== 'sys_capability') return null;
18+
rows.push({ ...data });
19+
return { id: data.id };
20+
},
21+
async update(object: string, data: any) {
22+
if (object !== 'sys_capability') return;
23+
const r = rows.find((x) => x.id === data.id);
24+
if (r) Object.assign(r, data);
25+
},
26+
};
27+
}
28+
29+
describe('bootstrapSystemCapabilities (ADR-0066 D1 back-compat seed)', () => {
30+
it('seeds the curated platform capabilities as records (idempotent)', async () => {
31+
const ql = makeQl();
32+
const r1 = await bootstrapSystemCapabilities(ql, []);
33+
expect(r1.seeded).toBe(KNOWN_CAPABILITIES.length);
34+
// every known capability is now a row with managed_by=platform + active
35+
for (const cap of KNOWN_CAPABILITIES) {
36+
const row = ql.rows.find((x) => x.name === cap.name);
37+
expect(row).toBeDefined();
38+
expect(row.managed_by).toBe('platform');
39+
expect(row.active).toBe(true);
40+
expect(row.scope).toBe(cap.scope);
41+
}
42+
// re-run → no new inserts (idempotent)
43+
const r2 = await bootstrapSystemCapabilities(ql, []);
44+
expect(r2.seeded).toBe(0);
45+
expect(ql.rows.length).toBe(KNOWN_CAPABILITIES.length);
46+
});
47+
48+
it('derives extra capabilities referenced by permission sets', async () => {
49+
const ql = makeQl();
50+
await bootstrapSystemCapabilities(ql, [{ systemPermissions: ['manage_users', 'export_data', 'approve_invoice'] }]);
51+
expect(ql.rows.find((x) => x.name === 'export_data')).toBeDefined();
52+
expect(ql.rows.find((x) => x.name === 'approve_invoice')).toBeDefined();
53+
// org-scoped known capability keeps its scope
54+
expect(ql.rows.find((x) => x.name === 'manage_org_users')?.scope).toBe('org');
55+
});
56+
57+
it('marks manage_org_users as org-scoped and the rest platform', () => {
58+
const org = KNOWN_CAPABILITIES.find((c) => c.name === 'manage_org_users');
59+
expect(org?.scope).toBe('org');
60+
expect(KNOWN_CAPABILITIES.filter((c) => c.scope === 'platform').length).toBeGreaterThanOrEqual(5);
61+
});
62+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* bootstrapSystemCapabilities — back-compat seed the capability registry
5+
* (ADR-0066 D1).
6+
*
7+
* Promotes the platform's authorization capabilities from bare strings to
8+
* first-class `sys_capability` records. Idempotently upserts (by `name`):
9+
* 1. a CURATED set of well-known platform capabilities (label/description/
10+
* scope), and
11+
* 2. any capability referenced by the seeded permission sets'
12+
* `systemPermissions[]` that isn't in the curated set (derived defaults),
13+
* so every string a default grant references resolves to a definition record
14+
* while existing string references keep working unchanged (no migration).
15+
*
16+
* Pre-launch posture: upsert only — never prune (admins may add their own
17+
* capabilities in Setup; package-declared capabilities arrive via their own
18+
* seeding). Platform-seeded rows are `managed_by: 'platform'` so they are not
19+
* presented as admin-deletable. Runs on `kernel:ready` alongside the other
20+
* security bootstraps.
21+
*/
22+
23+
const SYSTEM_CTX = { isSystem: true };
24+
25+
function genId(prefix: string): string {
26+
const rand = Math.random().toString(36).slice(2, 10);
27+
const ts = Date.now().toString(36);
28+
return `${prefix}_${ts}${rand}`;
29+
}
30+
31+
async function tryFind(ql: any, object: string, where: any, limit = 1): Promise<any[]> {
32+
try {
33+
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX });
34+
return Array.isArray(rows) ? rows : [];
35+
} catch { return []; }
36+
}
37+
async function tryInsert(ql: any, object: string, data: any): Promise<any | null> {
38+
try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch { return null; }
39+
}
40+
async function tryUpdate(ql: any, object: string, data: any): Promise<boolean> {
41+
try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } catch { return false; }
42+
}
43+
44+
interface CapabilityDef {
45+
name: string;
46+
label: string;
47+
description: string;
48+
scope: 'platform' | 'org';
49+
}
50+
51+
/**
52+
* Well-known platform capabilities. Exported so tests + tooling can assert the
53+
* back-compat set. `managed_by` is always `'platform'` for these.
54+
*/
55+
export const KNOWN_CAPABILITIES: readonly CapabilityDef[] = [
56+
{ name: 'manage_users', label: 'Manage Users', description: 'Create, edit, and deactivate users across the platform.', scope: 'platform' },
57+
{ name: 'manage_org_users', label: 'Manage Organization Users', description: 'Manage members within the caller’s organization.', scope: 'org' },
58+
{ name: 'manage_metadata', label: 'Manage Metadata', description: 'Author and publish object/view/flow and other metadata.', scope: 'platform' },
59+
{ name: 'manage_platform_settings', label: 'Manage Platform Settings', description: 'Configure global platform settings (mail, storage, AI, licensing, …) and platform-only Setup pages.', scope: 'platform' },
60+
{ name: 'setup.access', label: 'Setup Access', description: 'Enter the Setup app shell.', scope: 'platform' },
61+
{ name: 'studio.access', label: 'Studio Access', description: 'Enter the Studio metadata-design surfaces.', scope: 'platform' },
62+
];
63+
64+
function humanize(name: string): string {
65+
return name
66+
.replace(/[._]/g, ' ')
67+
.replace(/\b\w/g, (c) => c.toUpperCase());
68+
}
69+
70+
interface SeedOptions {
71+
logger?: { info?: (m: string, meta?: Record<string, any>) => void; warn?: (m: string, meta?: Record<string, any>) => void };
72+
}
73+
74+
export async function bootstrapSystemCapabilities(
75+
ql: any,
76+
permissionSets: Array<{ systemPermissions?: string[] }> = [],
77+
options: SeedOptions = {},
78+
): Promise<{ seeded: number; updated: number; total: number }> {
79+
if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') {
80+
return { seeded: 0, updated: 0, total: 0 };
81+
}
82+
83+
// Build the full definition set: curated first, then any extra capability
84+
// string referenced by the seeded permission sets (derived defaults).
85+
const byName = new Map<string, CapabilityDef>();
86+
for (const c of KNOWN_CAPABILITIES) byName.set(c.name, c);
87+
for (const ps of permissionSets) {
88+
for (const cap of ps?.systemPermissions ?? []) {
89+
if (typeof cap === 'string' && cap && !byName.has(cap)) {
90+
byName.set(cap, { name: cap, label: humanize(cap), description: `Capability ${cap}.`, scope: 'platform' });
91+
}
92+
}
93+
}
94+
95+
let seeded = 0;
96+
let updated = 0;
97+
for (const def of byName.values()) {
98+
const existing = await tryFind(ql, 'sys_capability', { name: def.name }, 1);
99+
if (existing[0]?.id) {
100+
// Keep label/description/scope fresh, but do NOT clobber admin edits to
101+
// managed_by/active — only platform-owned fields are reconciled.
102+
if (await tryUpdate(ql, 'sys_capability', { id: existing[0].id, label: def.label, description: def.description, scope: def.scope })) {
103+
updated += 1;
104+
}
105+
} else {
106+
const created = await tryInsert(ql, 'sys_capability', {
107+
id: genId('cap'),
108+
name: def.name,
109+
label: def.label,
110+
description: def.description,
111+
scope: def.scope,
112+
managed_by: 'platform',
113+
active: true,
114+
});
115+
if (created) seeded += 1;
116+
}
117+
}
118+
options.logger?.info?.('[security] system capabilities seeded into sys_capability (ADR-0066 D1)', { seeded, updated, total: byName.size });
119+
return { seeded, updated, total: byName.size };
120+
}

packages/plugins/plugin-security/src/manifest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import {
1212
SysPermissionSet,
1313
SysRole,
14+
SysCapability,
1415
SysUserPermissionSet,
1516
SysRolePermissionSet,
1617
SysUserRole,
@@ -23,6 +24,7 @@ export const SECURITY_PLUGIN_VERSION = '1.0.0';
2324
/** Security objects owned by plugin-security. */
2425
export const securityObjects = [
2526
SysRole,
27+
SysCapability,
2628
SysPermissionSet,
2729
SysUserPermissionSet,
2830
SysRolePermissionSet,

packages/plugins/plugin-security/src/objects/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111

1212
export { SysRole } from './sys-role.object.js';
13+
export { SysCapability } from './sys-capability.object.js';
1314
export { SysPermissionSet } from './sys-permission-set.object.js';
1415
export { SysUserPermissionSet } from './sys-user-permission-set.object.js';
1516
export { SysRolePermissionSet } from './sys-role-permission-set.object.js';

packages/plugins/plugin-security/src/objects/rbac-objects.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { SysRole, SysPermissionSet, defaultPermissionSets } from './index.js';
4+
import { SysRole, SysPermissionSet, SysCapability, defaultPermissionSets } from './index.js';
55

66
/**
77
* RBAC object + default-permission-set assertions. Moved here with the objects
@@ -119,3 +119,30 @@ describe('RBAC object canonical names + row actions', () => {
119119
expect(names).toEqual(['activate_permission_set', 'clone_permission_set', 'deactivate_permission_set']);
120120
});
121121
});
122+
123+
124+
describe('sys_capability — ADR-0066 D1 capability registry', () => {
125+
it('is a system config object with the canonical name', () => {
126+
expect(SysCapability.name).toBe('sys_capability');
127+
expect(SysCapability.isSystem).toBe(true);
128+
expect(SysCapability.managedBy).toBe('config');
129+
});
130+
131+
it('declares name/label/scope/managed_by fields', () => {
132+
const f: any = SysCapability.fields;
133+
expect(f.name).toBeDefined();
134+
expect(f.label).toBeDefined();
135+
expect(f.scope).toBeDefined();
136+
expect(f.managed_by).toBeDefined();
137+
// scope + managed_by are constrained selects
138+
const scopeOpts = (f.scope.options ?? []).map((o: any) => o.value).sort();
139+
expect(scopeOpts).toEqual(['org', 'platform']);
140+
const mbOpts = (f.managed_by.options ?? []).map((o: any) => o.value).sort();
141+
expect(mbOpts).toEqual(['admin', 'package', 'platform']);
142+
});
143+
144+
it('enforces a unique index on name', () => {
145+
const nameIdx = (SysCapability.indexes ?? []).find((i: any) => Array.isArray(i.fields) && i.fields.includes('name'));
146+
expect(nameIdx?.unique).toBe(true);
147+
});
148+
});

0 commit comments

Comments
 (0)