diff --git a/.changeset/authz-a4-managed-by-tristate.md b/.changeset/authz-a4-managed-by-tristate.md new file mode 100644 index 0000000000..4530f9c91f --- /dev/null +++ b/.changeset/authz-a4-managed-by-tristate.md @@ -0,0 +1,35 @@ +--- +"@objectstack/plugin-security": minor +--- + +feat(plugin-security): A4 — managed_by tri-state unification + listView exposure (#2920) + +Unifies the record-level provenance vocabulary across the three RBAC catalogs +(`sys_capability`, `sys_permission_set`, `sys_position`) onto a single tri-state +— **platform / package / admin** — so an administrator reads one vocabulary for +"who owns this" everywhere. + +- **`sys_permission_set.managed_by`** and **`sys_position.managed_by`** converted + from free `text` to a constrained `select` matching `sys_capability` (options + `platform` / `package` / `admin`, `defaultValue: 'admin'`, `readonly`). +- **Writers re-stamped to canonical vocab:** built-in identity/anchor positions + now seed `managed_by: 'platform'` (was `'system'`); env/Studio-authored + permission sets project as `managed_by: 'admin'` (was `'user'`). Declared + package sets (`'package'`) and platform capabilities (`'platform'`) were + already canonical. +- **`sys_position` list views** (`active` / `default_positions` / `custom` / + `all_positions`) now surface the `managed_by` column, matching the capability + and permission-set views. +- **Back-compat, no destructive migration.** No runtime path branches on the + legacy values — every access decision keys on `'package'` / `'platform'` + (both unchanged) — so the rename never changes an authorization outcome. + Built-in positions and declared sets self-heal on their next bootstrap upsert; + a new idempotent `kernel:ready` reconciler (`normalizeManagedByVocab`) rewrites + the residual legacy values (`system`→`platform`, `config`→`package`, + `user`→`admin`) on existing `sys_position` / `sys_permission_set` rows. +- **i18n:** `managed_by` field + option labels (`platform` / `package` / `admin`) + added for `sys_capability` / `sys_permission_set` / `sys_position` across + en / zh-CN / ja-JP / es-ES. + +Pairs with objectui `feat(app-shell): A4 — provenance tri-state badge` +(framework#2920). diff --git a/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts b/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts index d7dbd3387d..e4b4d9155e 100644 --- a/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts +++ b/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts @@ -59,7 +59,7 @@ describe('sys_permission_set pure projection (ADR-0094)', () => { // env-owned, not forged package provenance. const row = await findSet(NAME); expect(row, 'record projected synchronously with the create').toBeTruthy(); - expect(row.managed_by).toBe('user'); + expect(row.managed_by).toBe('admin'); expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); // …and the authoritative store is the metadata overlay, not the row. @@ -163,7 +163,7 @@ describe('sys_permission_set pure projection (ADR-0094)', () => { // the projector, not left invisible as before ADR-0094). const row = await findSet(NAME); expect(row, 'Studio-authored env set appears in Setup').toBeTruthy(); - expect(row.managed_by).toBe('user'); + expect(row.managed_by).toBe('admin'); expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); }); }); diff --git a/packages/plugins/plugin-security/src/audience-anchors.test.ts b/packages/plugins/plugin-security/src/audience-anchors.test.ts index b8ea85d118..bffc1dbe1a 100644 --- a/packages/plugins/plugin-security/src/audience-anchors.test.ts +++ b/packages/plugins/plugin-security/src/audience-anchors.test.ts @@ -39,8 +39,8 @@ describe('audience anchors (ADR-0090 D5/D9)', () => { expect.arrayContaining(['platform_admin', 'org_owner', 'org_admin', 'org_member', 'everyone', 'guest']), ); expect(res.seeded).toBe(6); - // system-managed, undeletable posture - for (const r of ql.tables.sys_position) expect(r.managed_by).toBe('system'); + // platform-managed, undeletable posture (A4 #2920 unified vocab; formerly 'system') + for (const r of ql.tables.sys_position) expect(r.managed_by).toBe('platform'); }); it('re-seed is idempotent (updates, no duplicates)', async () => { diff --git a/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts b/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts index d7daf1398d..9d53040d74 100644 --- a/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts @@ -12,9 +12,10 @@ * `sys_member.role` for the org_* roles and the unscoped `admin_full_access` * grant for platform_admin — are NEVER changed by this seed. * - * Idempotent upsert-by-name, no prune. Rows are stamped `managed_by = 'system'` - * so tenants can see (but not repurpose) them. Runs on `kernel:ready` alongside - * the platform-admin and declared-role bootstraps. + * Idempotent upsert-by-name, no prune. Rows are stamped `managed_by = 'platform'` + * (A4 #2920 unified vocab; formerly 'system') so tenants can see (but not + * repurpose) them. Runs on `kernel:ready` alongside the platform-admin and + * declared-role bootstraps. */ import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA, EVERYONE_POSITION, GUEST_POSITION } from '@objectstack/spec'; @@ -77,7 +78,10 @@ export async function bootstrapBuiltinRoles( ...Object.entries(AUDIENCE_ANCHOR_METADATA), ]; for (const [name, meta] of rows) { - const fields = { label: meta.label, description: meta.description, managed_by: 'system' }; + // [A4 #2920] Unified provenance vocab: built-in identity/anchor positions are + // PLATFORM-shipped (formerly stamped 'system'). Re-upserted every boot, so + // legacy 'system' rows self-heal to 'platform' on the next kernel:ready. + const fields = { label: meta.label, description: meta.description, managed_by: 'platform' }; const existing = await tryFind(ql, 'sys_position', { name }, 1); if (existing[0]?.id) { if (await tryUpdate(ql, 'sys_position', { id: existing[0].id, ...fields })) updated += 1; diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 0e4346c392..3442a3814b 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -45,6 +45,7 @@ export type { export { cleanupPackagePermissions } from './cleanup-package-permissions.js'; export type { PackagePermissionCleanupOutcome } from './cleanup-package-permissions.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; +export { normalizeManagedByVocab } from './normalize-managed-by.js'; export { appDefaultPermissionSetName } from './app-default-permission-set.js'; export { DelegatedAdminGate, isTenantAdmin } from './delegated-admin-gate.js'; export { explainAccess, buildContextForUser } from './explain-engine.js'; diff --git a/packages/plugins/plugin-security/src/normalize-managed-by.test.ts b/packages/plugins/plugin-security/src/normalize-managed-by.test.ts new file mode 100644 index 0000000000..17a86b3949 --- /dev/null +++ b/packages/plugins/plugin-security/src/normalize-managed-by.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { normalizeManagedByVocab } from './normalize-managed-by.js'; + +/** + * A4 #2920 — the boot reconciler that heals legacy `managed_by` values on the + * RBAC catalogs onto the unified platform/package/admin vocabulary. + */ +function makeQl() { + const tables: Record = { + sys_position: [], + sys_permission_set: [], + }; + return { + tables, + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + return (tables[object] ?? []).filter((r) => + Object.entries(where).every(([k, v]) => r[k] === v), + ); + }, + async update(object: string, data: any) { + const row = (tables[object] ?? []).find((r) => r.id === data.id); + if (row) Object.assign(row, data); + return row; + }, + }; +} + +describe('normalizeManagedByVocab (A4 #2920)', () => { + it('rewrites legacy position values system/config/user -> platform/package/admin', async () => { + const ql = makeQl(); + ql.tables.sys_position.push( + { id: 'p1', managed_by: 'system' }, + { id: 'p2', managed_by: 'config' }, + { id: 'p3', managed_by: 'user' }, + { id: 'p4', managed_by: 'platform' }, // already canonical + ); + const res = await normalizeManagedByVocab(ql); + expect(res.positions).toBe(3); + expect(ql.tables.sys_position.map((r) => r.managed_by).sort()).toEqual([ + 'admin', + 'package', + 'platform', + 'platform', + ]); + }); + + it('rewrites legacy permission-set value user -> admin, leaving platform/package untouched', async () => { + const ql = makeQl(); + ql.tables.sys_permission_set.push( + { id: 's1', managed_by: 'user' }, + { id: 's2', managed_by: 'package' }, + { id: 's3', managed_by: 'platform' }, + { id: 's4', managed_by: 'admin' }, + ); + const res = await normalizeManagedByVocab(ql); + expect(res.permissionSets).toBe(1); + expect(ql.tables.sys_permission_set.find((r) => r.id === 's1')!.managed_by).toBe('admin'); + expect(ql.tables.sys_permission_set.find((r) => r.id === 's2')!.managed_by).toBe('package'); + }); + + it('is idempotent — a second run is a no-op', async () => { + const ql = makeQl(); + ql.tables.sys_position.push({ id: 'p1', managed_by: 'system' }); + ql.tables.sys_permission_set.push({ id: 's1', managed_by: 'user' }); + await normalizeManagedByVocab(ql); + const res2 = await normalizeManagedByVocab(ql); + expect(res2).toEqual({ positions: 0, permissionSets: 0 }); + }); + + it('tolerates a ql without find/update', async () => { + expect(await normalizeManagedByVocab(null as any)).toEqual({ positions: 0, permissionSets: 0 }); + expect(await normalizeManagedByVocab({} as any)).toEqual({ positions: 0, permissionSets: 0 }); + }); +}); diff --git a/packages/plugins/plugin-security/src/normalize-managed-by.ts b/packages/plugins/plugin-security/src/normalize-managed-by.ts new file mode 100644 index 0000000000..e4c696f5f8 --- /dev/null +++ b/packages/plugins/plugin-security/src/normalize-managed-by.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * normalizeManagedByVocab — heal legacy `managed_by` values on the RBAC + * catalogs to the unified tri-state vocabulary (A4 #2920). + * + * The three RBAC catalogs (`sys_capability`, `sys_permission_set`, + * `sys_position`) historically spoke three different provenance dialects: + * - capability: platform / package / admin (already canonical) + * - permission set: platform / package / user + * - position: system / config / user + * + * A4 unifies all three on **platform / package / admin**. New rows are written + * canonically by the seeders/projector; this reconciler rewrites the residual + * legacy values on rows those writers do NOT re-touch — env-authored permission + * sets stamped `'user'`, and older tenant positions stamped `'system'` / + * `'config'` / `'user'`. Built-in position rows and declared package sets + * self-heal on their own bootstrap upsert, so this only mops up the rest. + * + * Safe by construction: NO runtime path branches on the legacy values (every + * read keys on `'package'` or `'platform'`, both unchanged by the rename), so + * this is a pure display-vocabulary migration — it never changes an access + * decision. Idempotent: canonical rows are skipped, so a re-run is a no-op. + * Best-effort and non-fatal, like the sibling boot reconcilers. + * + * Runs on `kernel:ready` after the seeders, as `isSystem` (the field is + * `readonly`, so only a system write may set it). + */ + +const SYSTEM_CTX = { isSystem: true }; + +/** legacy value -> canonical value, per object. */ +const POSITION_MAP: Record = { + system: 'platform', + config: 'package', + user: 'admin', +}; +const PERMISSION_SET_MAP: Record = { + user: 'admin', +}; + +interface NormalizeOptions { + logger?: { + info: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + }; +} + +async function tryFind(ql: any, object: string, where: any): Promise { + try { + const rows = await ql.find(object, { where, limit: 10_000, fields: ['id', 'managed_by'] }, { context: SYSTEM_CTX }); + if (Array.isArray(rows)) return rows; + if (Array.isArray(rows?.records)) return rows.records; + return []; + } catch { + return []; + } +} + +async function normalizeObject( + ql: any, + object: string, + map: Record, + logger?: NormalizeOptions['logger'], +): Promise { + let updated = 0; + for (const [legacy, canonical] of Object.entries(map)) { + // Narrow equality scan per legacy value keeps the where-clause + // driver-portable (no IN / OR predicate). + const rows = await tryFind(ql, object, { managed_by: legacy }); + for (const row of rows) { + if (!row?.id) continue; + try { + await ql.update(object, { id: row.id, managed_by: canonical }, { context: SYSTEM_CTX }); + updated += 1; + } catch (e) { + logger?.warn?.(`[security] managed_by normalize failed for ${object}:${row.id}`, { + error: (e as Error).message, + }); + } + } + } + return updated; +} + +/** + * Rewrite legacy `managed_by` values on `sys_permission_set` and `sys_position` + * to the unified tri-state vocab. Returns a per-object count of rows healed. + */ +export async function normalizeManagedByVocab( + ql: any, + options: NormalizeOptions = {}, +): Promise<{ permissionSets: number; positions: number }> { + if (!ql || typeof ql.find !== 'function' || typeof ql.update !== 'function') { + return { permissionSets: 0, positions: 0 }; + } + const positions = await normalizeObject(ql, 'sys_position', POSITION_MAP, options.logger); + const permissionSets = await normalizeObject(ql, 'sys_permission_set', PERMISSION_SET_MAP, options.logger); + const total = positions + permissionSets; + if (total > 0) { + options.logger?.info?.('[security] managed_by vocab normalized to platform/package/admin (A4 #2920)', { + positions, + permissionSets, + }); + } + return { permissionSets, positions }; +} diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index 8a8e8d68ad..6292be2f96 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -146,6 +146,38 @@ describe('RBAC object canonical names + row actions', () => { expect(names).toEqual(['activate_permission_set', 'clone_permission_set', 'deactivate_permission_set']); }); + it('[A4 #2920] SysPermissionSet.managed_by is a select on the unified platform/package/admin vocab', () => { + const f: any = (SysPermissionSet.fields as any).managed_by; + expect(f, 'managed_by field exists').toBeDefined(); + expect(f.type).toBe('select'); + expect(f.readonly).toBe(true); + const opts = (f.options ?? []).map((o: any) => o.value).sort(); + expect(opts).toEqual(['admin', 'package', 'platform']); + }); + + it('[A4 #2920] SysPosition.managed_by is a select on the unified vocab and appears in every list view', () => { + const f: any = (SysPosition.fields as any).managed_by; + expect(f, 'managed_by field exists').toBeDefined(); + expect(f.type).toBe('select'); + expect(f.readonly).toBe(true); + const opts = (f.options ?? []).map((o: any) => o.value).sort(); + expect(opts).toEqual(['admin', 'package', 'platform']); + // The provenance column is surfaced in all four position list views. + const views: any = SysPosition.listViews; + for (const v of ['active', 'default_positions', 'custom', 'all_positions']) { + expect(views[v].columns, `${v} view shows managed_by`).toContain('managed_by'); + } + }); + + it('[A4 #2920] all three RBAC catalogs share the identical managed_by vocabulary', () => { + const vocab = (schema: any) => + ((schema.fields.managed_by.options ?? []) as any[]).map((o) => o.value).sort(); + const cap = vocab(SysCapability); + expect(cap).toEqual(['admin', 'package', 'platform']); + expect(vocab(SysPermissionSet)).toEqual(cap); + expect(vocab(SysPosition)).toEqual(cap); + }); + it('[ADR-0094] declares a readonly `customized` provenance flag surfaced in the All list view', () => { const f: any = (SysPermissionSet.fields as any).customized; expect(f, 'customized field exists').toBeDefined(); diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index 4453ae2ef9..987be4e43d 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -225,15 +225,29 @@ export const SysPermissionSet = ObjectSchema.create({ group: 'Provenance', }), - managed_by: Field.text({ + // [A4 #2920] Unified provenance tri-state — platform / package / admin — + // shared verbatim with sys_capability and sys_position so an admin reads one + // vocabulary across all three RBAC catalogs. Converted from free `text` to a + // constrained `select` so the value is a labelled, i18n-able badge. + // Back-compat: legacy env rows carry 'user' (== admin). No runtime path + // branches on 'user' (every read keys on 'package' / 'platform', both + // unchanged), so those rows stay semantically correct; the boot normalizer + // (`normalizeManagedByVocab`) rewrites them to 'admin' idempotently. + managed_by: Field.select({ label: 'Managed By', required: false, readonly: true, - maxLength: 16, + defaultValue: 'admin', description: - "Record provenance: 'package' = versioned package metadata (re-seeded on upgrade, " + - "read-mostly for admins); 'platform'/'user' = environment config (live-edited, never " + - 'touched by package seeding). Absent on legacy rows.', + "Record provenance (unified tri-state, A4 #2920): 'platform' = shipped by the " + + "platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly " + + "for admins); 'admin' = created/owned in this environment by an administrator " + + "(live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).", + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'package', label: 'Package' }, + { value: 'admin', label: 'Admin' }, + ], group: 'Provenance', }), diff --git a/packages/plugins/plugin-security/src/objects/sys-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-position.object.ts index 89b4badb5f..1aacee626b 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position.object.ts @@ -24,8 +24,9 @@ export const SysPosition = ObjectSchema.create({ // ADR-0068 D3: position-DEFINITION authority follows the isolation boundary. // Framework-reserved built-in identities (platform_admin / org_*) and the // ADR-0090 D9 audience anchors (everyone / guest) are seeded with - // `managed_by = 'system'` and MUST NOT be repurposed by a tenant; ad-hoc - // position definitions in a shared cross-tenant kernel namespace are forbidden. + // `managed_by = 'platform'` (A4 #2920 unified vocab; formerly 'system') and + // MUST NOT be repurposed by a tenant; ad-hoc position definitions in a shared + // cross-tenant kernel namespace are forbidden. protection: { lock: 'no-overlay', reason: 'RBAC schema is platform-defined — see ADR-0010.', @@ -35,7 +36,7 @@ export const SysPosition = ObjectSchema.create({ displayNameField: 'label', nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', - highlightFields: ['label', 'name', 'active', 'is_default'], + highlightFields: ['label', 'name', 'managed_by', 'active', 'is_default'], // Custom actions — positions drive capability distribution and are edited // rarely but require the four high-frequency sysadmin affordances every IdP @@ -121,7 +122,7 @@ export const SysPosition = ObjectSchema.create({ name: 'active', label: 'Active', data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'is_default', 'updated_at'], + columns: ['label', 'name', 'managed_by', 'is_default', 'updated_at'], filter: [{ field: 'active', operator: 'equals', value: true }], sort: [{ field: 'label', order: 'asc' }], pagination: { pageSize: 50 }, @@ -131,7 +132,7 @@ export const SysPosition = ObjectSchema.create({ name: 'default_positions', label: 'Default', data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'description', 'active'], + columns: ['label', 'name', 'managed_by', 'description', 'active'], filter: [{ field: 'is_default', operator: 'equals', value: true }], sort: [{ field: 'label', order: 'asc' }], pagination: { pageSize: 50 }, @@ -141,7 +142,7 @@ export const SysPosition = ObjectSchema.create({ name: 'custom', label: 'Custom', data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'active', 'updated_at'], + columns: ['label', 'name', 'managed_by', 'active', 'updated_at'], filter: [{ field: 'is_default', operator: 'equals', value: false }], sort: [{ field: 'label', order: 'asc' }], pagination: { pageSize: 50 }, @@ -151,7 +152,7 @@ export const SysPosition = ObjectSchema.create({ name: 'all_positions', label: 'All', data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'active', 'is_default', 'updated_at'], + columns: ['label', 'name', 'managed_by', 'active', 'is_default', 'updated_at'], sort: [{ field: 'label', order: 'asc' }], pagination: { pageSize: 50 }, }, @@ -217,13 +218,28 @@ export const SysPosition = ObjectSchema.create({ }), // ── System ─────────────────────────────────────────────────── - // ADR-0068 D2/D3 — provenance of this row. `system` = a framework-reserved - // built-in identity position (seeded by bootstrapBuiltinPositions); `config` = - // stack-declared; null / `user` = tenant-created. Built-in rows are read-only. - managed_by: Field.text({ + // [A4 #2920] Unified provenance tri-state — platform / package / admin — + // shared verbatim with sys_capability and sys_permission_set. Converted from + // free `text` to a constrained `select`. `platform` = a framework-reserved + // built-in identity position (seeded by bootstrapBuiltinRoles, read-only); + // `package` = stack/package-declared; `admin` = tenant-created in Setup. + // Back-compat: legacy rows may carry system (== platform) / config (== package) + // / user (== admin); no runtime path branches on those (every read keys on + // 'package' / 'platform'), and the boot normalizer heals them to the canonical + // vocab. Built-in (`platform`) rows self-heal on the next bootstrap upsert. + managed_by: Field.select({ label: 'Managed By', readonly: true, - description: 'Provenance: system (built-in) / config (declared) / user (tenant)', + defaultValue: 'admin', + description: + 'Record provenance (unified tri-state, A4 #2920): platform = framework built-in ' + + '(read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows ' + + 'may carry system (== platform) / config (== package) / user (== admin).', + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'package', label: 'Package' }, + { value: 'admin', label: 'Admin' }, + ], group: 'System', }), diff --git a/packages/plugins/plugin-security/src/permission-set-projection.test.ts b/packages/plugins/plugin-security/src/permission-set-projection.test.ts index 44c8de0d12..654d95d890 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -164,13 +164,14 @@ describe('permissionSetBodyFromRow / permissionSetRowFields (round-trip)', () => }); describe('upsertEnvPermissionSet (ADR-0094 — record is a pure projection)', () => { - it('CREATES a missing record (managed_by user) — Studio-authored sets appear in Setup', async () => { + it('CREATES a missing record (managed_by admin) — Studio-authored sets appear in Setup', async () => { const ql = makeQl(); const r = await upsertEnvPermissionSet(ql, envBody()); expect(r.seeded).toBe(1); const row = ql.permRows[0]; expect(row.name).toBe('organization_admin'); - expect(row.managed_by).toBe('user'); + // [A4 #2920] env/Studio-authored sets are ADMIN-owned (formerly 'user'). + expect(row.managed_by).toBe('admin'); expect(row.active).toBe(true); expect(JSON.parse(row.object_permissions)).toEqual(envBody().objects); }); @@ -218,7 +219,7 @@ describe('projectPermissionMutation (the awaited projector)', () => { const metadata = makeMetadataFacade(); const r = await projectPermissionMutation(protocol, { ql, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' }); expect(r?.seeded).toBe(1); - expect(ql.permRows[0].managed_by).toBe('user'); + expect(ql.permRows[0].managed_by).toBe('admin'); // evaluator's registry-first list('permission') now resolves the same body, // marked as a projection echo so it can never masquerade as an artifact const entry = metadata.registry.get('permission/organization_admin'); @@ -394,7 +395,7 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => expect(protocol.saves[0].actor).toBe('usr_admin'); expect(protocol.saves[0].item.objects).toEqual({ ticket: { allowRead: true } }); expect(ql.permRows.length).toBe(1); - expect(ql.permRows[0].managed_by).toBe('user'); + expect(ql.permRows[0].managed_by).toBe('admin'); expect(opCtx.result?.name).toBe('support_agent'); }); @@ -556,7 +557,7 @@ describe('reconcilePermissionSetProjection', () => { const out = await reconcilePermissionSetProjection(protocol, { ql }); expect(out.projectedFromMetadata).toBe(1); expect(ql.permRows[0]?.name).toBe('organization_admin'); - expect(ql.permRows[0]?.managed_by).toBe('user'); + expect(ql.permRows[0]?.managed_by).toBe('admin'); }); it('backfills a legacy data-door-only record into the metadata store ONCE', async () => { diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index b8626417b8..ffcf287b76 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -190,7 +190,8 @@ function hasSchemaRegistry(ql: any): boolean { * ENVIRONMENT side. * * [ADR-0094] The record is a pure projection, so a missing row is CREATED - * (`managed_by:'user'` — a Studio-authored set appears in Setup, where the + * (`managed_by:'admin'` — A4 #2920 unified vocab, formerly 'user'; a + * Studio-authored set appears in Setup, where the * #2867 band-aid declined to create). A PACKAGE-OWNED row is also projected — * an env-scope overlay is the platform's standard customization of a packaged * definition (ADR-0005; direction confirmed 2026-07-14, reversing the earlier @@ -222,7 +223,10 @@ export async function upsertEnvPermissionSet( name: ps.name, ...permissionSetRowFields(ps), active: ps.active != null ? asBool(ps.active) : true, - managed_by: 'user', + // [A4 #2920] Unified provenance vocab: an env/Studio-authored set is + // ADMIN-owned (formerly stamped 'user'). No runtime path branches on the + // value except the 'package' guard, so this is a pure vocab rename. + managed_by: 'admin', ...(customized !== undefined ? { customized: !!customized } : {}), }); if (created) out.seeded += 1; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 4f3d41bfee..8e6c44410f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -31,6 +31,7 @@ import { import { cleanupPackagePermissions } from './cleanup-package-permissions.js'; import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions.js'; import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js'; +import { normalizeManagedByVocab } from './normalize-managed-by.js'; import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { computeTenantLayer0Filter, andComposeLayers } from './tenant-layer.js'; @@ -1322,6 +1323,15 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] capability seeding failed', { error: (e as Error).message }); } + // [A4 #2920] Heal residual legacy `managed_by` values (env sets stamped + // 'user'; older positions stamped system/config/user) onto the unified + // platform/package/admin vocab. Runs after the seeders so their canonical + // writes land first; idempotent and non-fatal. + try { + await normalizeManagedByVocab(ql, { logger: ctx.logger }); + } catch (e) { + ctx.logger.warn('[security] managed_by vocab normalization failed (non-fatal)', { error: (e as Error).message }); + } bootstrapRanOnce = true; ctx.logger.info('[security] platform bootstrap complete', report); return report; diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index c549cb80a7..052c8fe55c 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -35,6 +35,15 @@ export const enObjects: NonNullable = { label: "Default Position", help: "Automatically assigned to new users" }, + managed_by: { + label: "Managed By", + help: "Record provenance: platform (built-in) / package (declared) / admin (tenant-created).", + options: { + platform: "Platform", + package: "Package", + admin: "Admin" + } + }, id: { label: "Position ID" }, @@ -80,6 +89,29 @@ export const enObjects: NonNullable = { } } }, + sys_capability: { + label: "Capability", + pluralLabel: "Capabilities", + description: "Authorization capability definitions referenced by name from permission sets and resource requirements.", + fields: { + managed_by: { + label: "Managed By", + help: "Record provenance: platform / package (shipped, not user-deletable) / admin (created in Setup).", + options: { + platform: "Platform", + package: "Package", + admin: "Admin" + } + }, + scope: { + label: "Scope", + options: { + platform: "Platform", + org: "Organization" + } + } + } + }, sys_permission_set: { label: "Permission Set", pluralLabel: "Permission Sets", @@ -118,6 +150,15 @@ export const enObjects: NonNullable = { active: { label: "Active" }, + managed_by: { + label: "Managed By", + help: "Record provenance: platform (shipped) / package (packaged) / admin (env-authored).", + options: { + platform: "Platform", + package: "Package", + admin: "Admin" + } + }, id: { label: "Permission Set ID" }, diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 49c49872d2..d0ff03efaa 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -35,6 +35,15 @@ export const esESObjects: NonNullable = { label: "Puesto predeterminado", help: "Se asigna automáticamente a los nuevos usuarios." }, + managed_by: { + label: "Gestionado por", + help: "Procedencia del registro: platform (integrado) / package (declarado) / admin (creado por el inquilino).", + options: { + platform: "Plataforma", + package: "Paquete", + admin: "Administrador" + } + }, id: { label: "ID de rol" }, @@ -80,6 +89,29 @@ export const esESObjects: NonNullable = { } } }, + sys_capability: { + label: "Capacidad", + pluralLabel: "Capacidades", + description: "Definiciones de capacidades de autorización referenciadas por nombre desde conjuntos de permisos y requisitos de recursos.", + fields: { + managed_by: { + label: "Gestionado por", + help: "Procedencia del registro: platform / package (distribuido, no eliminable por el usuario) / admin (creado en Configuración).", + options: { + platform: "Plataforma", + package: "Paquete", + admin: "Administrador" + } + }, + scope: { + label: "Ámbito", + options: { + platform: "Plataforma", + org: "Organización" + } + } + } + }, sys_permission_set: { label: "Conjunto de permisos", pluralLabel: "Conjuntos de permisos", @@ -118,6 +150,15 @@ export const esESObjects: NonNullable = { active: { label: "Activo" }, + managed_by: { + label: "Gestionado por", + help: "Procedencia del registro: platform (distribuido) / package (empaquetado) / admin (creado en el entorno).", + options: { + platform: "Plataforma", + package: "Paquete", + admin: "Administrador" + } + }, id: { label: "ID de conjunto de permisos" }, diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index 6679ac5607..6aa68f5a7a 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -35,6 +35,15 @@ export const jaJPObjects: NonNullable = { label: "デフォルトポジション", help: "新規ユーザーに自動的に割り当てられます" }, + managed_by: { + label: "管理元", + help: "レコードの出所:platform(組み込み)/ package(宣言済み)/ admin(テナント作成)。", + options: { + platform: "プラットフォーム", + package: "パッケージ", + admin: "管理者" + } + }, id: { label: "ポジション ID" }, @@ -80,6 +89,29 @@ export const jaJPObjects: NonNullable = { } } }, + sys_capability: { + label: "ケイパビリティ", + pluralLabel: "ケイパビリティ", + description: "権限セットおよびリソース要件から名前で参照される認可ケイパビリティ定義。", + fields: { + managed_by: { + label: "管理元", + help: "レコードの出所:platform / package(配布物、ユーザー削除不可)/ admin(設定で作成)。", + options: { + platform: "プラットフォーム", + package: "パッケージ", + admin: "管理者" + } + }, + scope: { + label: "スコープ", + options: { + platform: "プラットフォーム", + org: "組織" + } + } + } + }, sys_permission_set: { label: "権限セット", pluralLabel: "権限セット", @@ -118,6 +150,15 @@ export const jaJPObjects: NonNullable = { active: { label: "有効" }, + managed_by: { + label: "管理元", + help: "レコードの出所:platform(配布)/ package(パッケージ)/ admin(環境作成)。", + options: { + platform: "プラットフォーム", + package: "パッケージ", + admin: "管理者" + } + }, id: { label: "権限セット ID" }, diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index ba968e5ebd..b8a9b9b102 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -35,6 +35,15 @@ export const zhCNObjects: NonNullable = { label: "默认岗位", help: "自动分配给新用户" }, + managed_by: { + label: "管理来源", + help: "记录来源:platform(平台内置)/ package(应用包声明)/ admin(租户创建)。", + options: { + platform: "平台", + package: "应用包", + admin: "管理员" + } + }, id: { label: "岗位 ID" }, @@ -80,6 +89,29 @@ export const zhCNObjects: NonNullable = { } } }, + sys_capability: { + label: "能力", + pluralLabel: "能力", + description: "授权能力定义,由权限集和资源需求按名称引用。", + fields: { + managed_by: { + label: "管理来源", + help: "记录来源:platform / package(随包发布,不可用户删除)/ admin(在设置中创建)。", + options: { + platform: "平台", + package: "应用包", + admin: "管理员" + } + }, + scope: { + label: "范围", + options: { + platform: "平台", + org: "组织" + } + } + } + }, sys_permission_set: { label: "权限集", pluralLabel: "权限集", @@ -118,6 +150,15 @@ export const zhCNObjects: NonNullable = { active: { label: "启用" }, + managed_by: { + label: "管理来源", + help: "记录来源:platform(平台发布)/ package(应用包发布)/ admin(环境自建)。", + options: { + platform: "平台", + package: "应用包", + admin: "管理员" + } + }, id: { label: "权限集 ID" },