Skip to content

Commit 6e61e43

Browse files
committed
feat(plugin-security): A4 — managed_by 三态统一 + listView 露出 (#2920)
Unify the record-level provenance vocabulary across sys_capability, sys_permission_set and sys_position onto platform / package / admin. - sys_permission_set.managed_by and sys_position.managed_by: text -> select (options platform/package/admin, defaultValue admin, readonly), matching sys_capability. - Writers re-stamped: built-in positions seed managed_by:'platform' (was 'system'); env/Studio-authored permission sets project managed_by:'admin' (was 'user'). - sys_position list views (active/default_positions/custom/all_positions) now surface the managed_by column. - No destructive migration: no runtime path branches on the legacy values (every access decision keys on 'package'/'platform'), so the rename never changes an authorization outcome. Built-ins/declared sets self-heal on their bootstrap upsert; new idempotent kernel:ready reconciler normalizeManagedByVocab rewrites residual legacy rows (system->platform, config->package, user->admin). - i18n: managed_by field + option labels added for all three objects across en / zh-CN / ja-JP / es-ES. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs
1 parent 94f33ec commit 6e61e43

16 files changed

Lines changed: 495 additions & 30 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
feat(plugin-security): A4 — managed_by tri-state unification + listView exposure (#2920)
6+
7+
Unifies the record-level provenance vocabulary across the three RBAC catalogs
8+
(`sys_capability`, `sys_permission_set`, `sys_position`) onto a single tri-state
9+
**platform / package / admin** — so an administrator reads one vocabulary for
10+
"who owns this" everywhere.
11+
12+
- **`sys_permission_set.managed_by`** and **`sys_position.managed_by`** converted
13+
from free `text` to a constrained `select` matching `sys_capability` (options
14+
`platform` / `package` / `admin`, `defaultValue: 'admin'`, `readonly`).
15+
- **Writers re-stamped to canonical vocab:** built-in identity/anchor positions
16+
now seed `managed_by: 'platform'` (was `'system'`); env/Studio-authored
17+
permission sets project as `managed_by: 'admin'` (was `'user'`). Declared
18+
package sets (`'package'`) and platform capabilities (`'platform'`) were
19+
already canonical.
20+
- **`sys_position` list views** (`active` / `default_positions` / `custom` /
21+
`all_positions`) now surface the `managed_by` column, matching the capability
22+
and permission-set views.
23+
- **Back-compat, no destructive migration.** No runtime path branches on the
24+
legacy values — every access decision keys on `'package'` / `'platform'`
25+
(both unchanged) — so the rename never changes an authorization outcome.
26+
Built-in positions and declared sets self-heal on their next bootstrap upsert;
27+
a new idempotent `kernel:ready` reconciler (`normalizeManagedByVocab`) rewrites
28+
the residual legacy values (`system``platform`, `config``package`,
29+
`user``admin`) on existing `sys_position` / `sys_permission_set` rows.
30+
- **i18n:** `managed_by` field + option labels (`platform` / `package` / `admin`)
31+
added for `sys_capability` / `sys_permission_set` / `sys_position` across
32+
en / zh-CN / ja-JP / es-ES.
33+
34+
Pairs with objectui `feat(app-shell): A4 — provenance tri-state badge`
35+
(framework#2920).

packages/plugins/plugin-security/src/audience-anchors.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ describe('audience anchors (ADR-0090 D5/D9)', () => {
3939
expect.arrayContaining(['platform_admin', 'org_owner', 'org_admin', 'org_member', 'everyone', 'guest']),
4040
);
4141
expect(res.seeded).toBe(6);
42-
// system-managed, undeletable posture
43-
for (const r of ql.tables.sys_position) expect(r.managed_by).toBe('system');
42+
// platform-managed, undeletable posture (A4 #2920 unified vocab; formerly 'system')
43+
for (const r of ql.tables.sys_position) expect(r.managed_by).toBe('platform');
4444
});
4545

4646
it('re-seed is idempotent (updates, no duplicates)', async () => {

packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
* `sys_member.role` for the org_* roles and the unscoped `admin_full_access`
1313
* grant for platform_admin — are NEVER changed by this seed.
1414
*
15-
* Idempotent upsert-by-name, no prune. Rows are stamped `managed_by = 'system'`
16-
* so tenants can see (but not repurpose) them. Runs on `kernel:ready` alongside
17-
* the platform-admin and declared-role bootstraps.
15+
* Idempotent upsert-by-name, no prune. Rows are stamped `managed_by = 'platform'`
16+
* (A4 #2920 unified vocab; formerly 'system') so tenants can see (but not
17+
* repurpose) them. Runs on `kernel:ready` alongside the platform-admin and
18+
* declared-role bootstraps.
1819
*/
1920

2021
import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA, EVERYONE_POSITION, GUEST_POSITION } from '@objectstack/spec';
@@ -77,7 +78,10 @@ export async function bootstrapBuiltinRoles(
7778
...Object.entries(AUDIENCE_ANCHOR_METADATA),
7879
];
7980
for (const [name, meta] of rows) {
80-
const fields = { label: meta.label, description: meta.description, managed_by: 'system' };
81+
// [A4 #2920] Unified provenance vocab: built-in identity/anchor positions are
82+
// PLATFORM-shipped (formerly stamped 'system'). Re-upserted every boot, so
83+
// legacy 'system' rows self-heal to 'platform' on the next kernel:ready.
84+
const fields = { label: meta.label, description: meta.description, managed_by: 'platform' };
8185
const existing = await tryFind(ql, 'sys_position', { name }, 1);
8286
if (existing[0]?.id) {
8387
if (await tryUpdate(ql, 'sys_position', { id: existing[0].id, ...fields })) updated += 1;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type {
4545
export { cleanupPackagePermissions } from './cleanup-package-permissions.js';
4646
export type { PackagePermissionCleanupOutcome } from './cleanup-package-permissions.js';
4747
export { claimSeedOwnership } from './claim-seed-ownership.js';
48+
export { normalizeManagedByVocab } from './normalize-managed-by.js';
4849
export { appDefaultPermissionSetName } from './app-default-permission-set.js';
4950
export { DelegatedAdminGate, isTenantAdmin } from './delegated-admin-gate.js';
5051
export { explainAccess, buildContextForUser } from './explain-engine.js';
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { normalizeManagedByVocab } from './normalize-managed-by.js';
5+
6+
/**
7+
* A4 #2920 — the boot reconciler that heals legacy `managed_by` values on the
8+
* RBAC catalogs onto the unified platform/package/admin vocabulary.
9+
*/
10+
function makeQl() {
11+
const tables: Record<string, any[]> = {
12+
sys_position: [],
13+
sys_permission_set: [],
14+
};
15+
return {
16+
tables,
17+
async find(object: string, opts: any) {
18+
const where = opts?.where ?? {};
19+
return (tables[object] ?? []).filter((r) =>
20+
Object.entries(where).every(([k, v]) => r[k] === v),
21+
);
22+
},
23+
async update(object: string, data: any) {
24+
const row = (tables[object] ?? []).find((r) => r.id === data.id);
25+
if (row) Object.assign(row, data);
26+
return row;
27+
},
28+
};
29+
}
30+
31+
describe('normalizeManagedByVocab (A4 #2920)', () => {
32+
it('rewrites legacy position values system/config/user -> platform/package/admin', async () => {
33+
const ql = makeQl();
34+
ql.tables.sys_position.push(
35+
{ id: 'p1', managed_by: 'system' },
36+
{ id: 'p2', managed_by: 'config' },
37+
{ id: 'p3', managed_by: 'user' },
38+
{ id: 'p4', managed_by: 'platform' }, // already canonical
39+
);
40+
const res = await normalizeManagedByVocab(ql);
41+
expect(res.positions).toBe(3);
42+
expect(ql.tables.sys_position.map((r) => r.managed_by).sort()).toEqual([
43+
'admin',
44+
'package',
45+
'platform',
46+
'platform',
47+
]);
48+
});
49+
50+
it('rewrites legacy permission-set value user -> admin, leaving platform/package untouched', async () => {
51+
const ql = makeQl();
52+
ql.tables.sys_permission_set.push(
53+
{ id: 's1', managed_by: 'user' },
54+
{ id: 's2', managed_by: 'package' },
55+
{ id: 's3', managed_by: 'platform' },
56+
{ id: 's4', managed_by: 'admin' },
57+
);
58+
const res = await normalizeManagedByVocab(ql);
59+
expect(res.permissionSets).toBe(1);
60+
expect(ql.tables.sys_permission_set.find((r) => r.id === 's1')!.managed_by).toBe('admin');
61+
expect(ql.tables.sys_permission_set.find((r) => r.id === 's2')!.managed_by).toBe('package');
62+
});
63+
64+
it('is idempotent — a second run is a no-op', async () => {
65+
const ql = makeQl();
66+
ql.tables.sys_position.push({ id: 'p1', managed_by: 'system' });
67+
ql.tables.sys_permission_set.push({ id: 's1', managed_by: 'user' });
68+
await normalizeManagedByVocab(ql);
69+
const res2 = await normalizeManagedByVocab(ql);
70+
expect(res2).toEqual({ positions: 0, permissionSets: 0 });
71+
});
72+
73+
it('tolerates a ql without find/update', async () => {
74+
expect(await normalizeManagedByVocab(null as any)).toEqual({ positions: 0, permissionSets: 0 });
75+
expect(await normalizeManagedByVocab({} as any)).toEqual({ positions: 0, permissionSets: 0 });
76+
});
77+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* normalizeManagedByVocab — heal legacy `managed_by` values on the RBAC
5+
* catalogs to the unified tri-state vocabulary (A4 #2920).
6+
*
7+
* The three RBAC catalogs (`sys_capability`, `sys_permission_set`,
8+
* `sys_position`) historically spoke three different provenance dialects:
9+
* - capability: platform / package / admin (already canonical)
10+
* - permission set: platform / package / user
11+
* - position: system / config / user
12+
*
13+
* A4 unifies all three on **platform / package / admin**. New rows are written
14+
* canonically by the seeders/projector; this reconciler rewrites the residual
15+
* legacy values on rows those writers do NOT re-touch — env-authored permission
16+
* sets stamped `'user'`, and older tenant positions stamped `'system'` /
17+
* `'config'` / `'user'`. Built-in position rows and declared package sets
18+
* self-heal on their own bootstrap upsert, so this only mops up the rest.
19+
*
20+
* Safe by construction: NO runtime path branches on the legacy values (every
21+
* read keys on `'package'` or `'platform'`, both unchanged by the rename), so
22+
* this is a pure display-vocabulary migration — it never changes an access
23+
* decision. Idempotent: canonical rows are skipped, so a re-run is a no-op.
24+
* Best-effort and non-fatal, like the sibling boot reconcilers.
25+
*
26+
* Runs on `kernel:ready` after the seeders, as `isSystem` (the field is
27+
* `readonly`, so only a system write may set it).
28+
*/
29+
30+
const SYSTEM_CTX = { isSystem: true };
31+
32+
/** legacy value -> canonical value, per object. */
33+
const POSITION_MAP: Record<string, string> = {
34+
system: 'platform',
35+
config: 'package',
36+
user: 'admin',
37+
};
38+
const PERMISSION_SET_MAP: Record<string, string> = {
39+
user: 'admin',
40+
};
41+
42+
interface NormalizeOptions {
43+
logger?: {
44+
info: (message: string, meta?: Record<string, any>) => void;
45+
warn: (message: string, meta?: Record<string, any>) => void;
46+
};
47+
}
48+
49+
async function tryFind(ql: any, object: string, where: any): Promise<any[]> {
50+
try {
51+
const rows = await ql.find(object, { where, limit: 10_000, fields: ['id', 'managed_by'] }, { context: SYSTEM_CTX });
52+
if (Array.isArray(rows)) return rows;
53+
if (Array.isArray(rows?.records)) return rows.records;
54+
return [];
55+
} catch {
56+
return [];
57+
}
58+
}
59+
60+
async function normalizeObject(
61+
ql: any,
62+
object: string,
63+
map: Record<string, string>,
64+
logger?: NormalizeOptions['logger'],
65+
): Promise<number> {
66+
let updated = 0;
67+
for (const [legacy, canonical] of Object.entries(map)) {
68+
// Narrow equality scan per legacy value keeps the where-clause
69+
// driver-portable (no IN / OR predicate).
70+
const rows = await tryFind(ql, object, { managed_by: legacy });
71+
for (const row of rows) {
72+
if (!row?.id) continue;
73+
try {
74+
await ql.update(object, { id: row.id, managed_by: canonical }, { context: SYSTEM_CTX });
75+
updated += 1;
76+
} catch (e) {
77+
logger?.warn?.(`[security] managed_by normalize failed for ${object}:${row.id}`, {
78+
error: (e as Error).message,
79+
});
80+
}
81+
}
82+
}
83+
return updated;
84+
}
85+
86+
/**
87+
* Rewrite legacy `managed_by` values on `sys_permission_set` and `sys_position`
88+
* to the unified tri-state vocab. Returns a per-object count of rows healed.
89+
*/
90+
export async function normalizeManagedByVocab(
91+
ql: any,
92+
options: NormalizeOptions = {},
93+
): Promise<{ permissionSets: number; positions: number }> {
94+
if (!ql || typeof ql.find !== 'function' || typeof ql.update !== 'function') {
95+
return { permissionSets: 0, positions: 0 };
96+
}
97+
const positions = await normalizeObject(ql, 'sys_position', POSITION_MAP, options.logger);
98+
const permissionSets = await normalizeObject(ql, 'sys_permission_set', PERMISSION_SET_MAP, options.logger);
99+
const total = positions + permissionSets;
100+
if (total > 0) {
101+
options.logger?.info?.('[security] managed_by vocab normalized to platform/package/admin (A4 #2920)', {
102+
positions,
103+
permissionSets,
104+
});
105+
}
106+
return { permissionSets, positions };
107+
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,38 @@ describe('RBAC object canonical names + row actions', () => {
145145
expect(names).toEqual(['activate_permission_set', 'clone_permission_set', 'deactivate_permission_set']);
146146
});
147147

148+
it('[A4 #2920] SysPermissionSet.managed_by is a select on the unified platform/package/admin vocab', () => {
149+
const f: any = (SysPermissionSet.fields as any).managed_by;
150+
expect(f, 'managed_by field exists').toBeDefined();
151+
expect(f.type).toBe('select');
152+
expect(f.readonly).toBe(true);
153+
const opts = (f.options ?? []).map((o: any) => o.value).sort();
154+
expect(opts).toEqual(['admin', 'package', 'platform']);
155+
});
156+
157+
it('[A4 #2920] SysPosition.managed_by is a select on the unified vocab and appears in every list view', () => {
158+
const f: any = (SysPosition.fields as any).managed_by;
159+
expect(f, 'managed_by field exists').toBeDefined();
160+
expect(f.type).toBe('select');
161+
expect(f.readonly).toBe(true);
162+
const opts = (f.options ?? []).map((o: any) => o.value).sort();
163+
expect(opts).toEqual(['admin', 'package', 'platform']);
164+
// The provenance column is surfaced in all four position list views.
165+
const views: any = SysPosition.listViews;
166+
for (const v of ['active', 'default_positions', 'custom', 'all_positions']) {
167+
expect(views[v].columns, `${v} view shows managed_by`).toContain('managed_by');
168+
}
169+
});
170+
171+
it('[A4 #2920] all three RBAC catalogs share the identical managed_by vocabulary', () => {
172+
const vocab = (schema: any) =>
173+
((schema.fields.managed_by.options ?? []) as any[]).map((o) => o.value).sort();
174+
const cap = vocab(SysCapability);
175+
expect(cap).toEqual(['admin', 'package', 'platform']);
176+
expect(vocab(SysPermissionSet)).toEqual(cap);
177+
expect(vocab(SysPosition)).toEqual(cap);
178+
});
179+
148180
it('[ADR-0094] declares a readonly `customized` provenance flag surfaced in the All list view', () => {
149181
const f: any = (SysPermissionSet.fields as any).customized;
150182
expect(f, 'customized field exists').toBeDefined();

packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,15 +225,29 @@ export const SysPermissionSet = ObjectSchema.create({
225225
group: 'Provenance',
226226
}),
227227

228-
managed_by: Field.text({
228+
// [A4 #2920] Unified provenance tri-state — platform / package / admin —
229+
// shared verbatim with sys_capability and sys_position so an admin reads one
230+
// vocabulary across all three RBAC catalogs. Converted from free `text` to a
231+
// constrained `select` so the value is a labelled, i18n-able badge.
232+
// Back-compat: legacy env rows carry 'user' (== admin). No runtime path
233+
// branches on 'user' (every read keys on 'package' / 'platform', both
234+
// unchanged), so those rows stay semantically correct; the boot normalizer
235+
// (`normalizeManagedByVocab`) rewrites them to 'admin' idempotently.
236+
managed_by: Field.select({
229237
label: 'Managed By',
230238
required: false,
231239
readonly: true,
232-
maxLength: 16,
240+
defaultValue: 'admin',
233241
description:
234-
"Record provenance: 'package' = versioned package metadata (re-seeded on upgrade, " +
235-
"read-mostly for admins); 'platform'/'user' = environment config (live-edited, never " +
236-
'touched by package seeding). Absent on legacy rows.',
242+
"Record provenance (unified tri-state, A4 #2920): 'platform' = shipped by the " +
243+
"platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly " +
244+
"for admins); 'admin' = created/owned in this environment by an administrator " +
245+
"(live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).",
246+
options: [
247+
{ value: 'platform', label: 'Platform' },
248+
{ value: 'package', label: 'Package' },
249+
{ value: 'admin', label: 'Admin' },
250+
],
237251
group: 'Provenance',
238252
}),
239253

0 commit comments

Comments
 (0)