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
35 changes: 35 additions & 0 deletions .changeset/authz-a4-managed-by-tristate.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 } });
});
});
4 changes: 2 additions & 2 deletions packages/plugins/plugin-security/src/audience-anchors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-security/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
77 changes: 77 additions & 0 deletions packages/plugins/plugin-security/src/normalize-managed-by.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any[]> = {
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 });
});
});
107 changes: 107 additions & 0 deletions packages/plugins/plugin-security/src/normalize-managed-by.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
system: 'platform',
config: 'package',
user: 'admin',
};
const PERMISSION_SET_MAP: Record<string, string> = {
user: 'admin',
};

interface NormalizeOptions {
logger?: {
info: (message: string, meta?: Record<string, any>) => void;
warn: (message: string, meta?: Record<string, any>) => void;
};
}

async function tryFind(ql: any, object: string, where: any): Promise<any[]> {
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<string, string>,
logger?: NormalizeOptions['logger'],
): Promise<number> {
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 };
}
32 changes: 32 additions & 0 deletions packages/plugins/plugin-security/src/objects/rbac-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),

Expand Down
Loading