From 1b1ae8af594a303be0355ff56a61aea34027bab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 11:51:52 +0000 Subject: [PATCH] feat(plugin-security): surface a `customized` flag on packaged permission sets (ADR-0094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Setup list couldn't tell a pristine packaged permission set from one an admin has overlaid — you had to open the Studio layered diff. Stamp it on the projection: - the env projector sets `customized: true` on a managed_by:'package' row while an overlay shadows its shipped baseline, and clears it on reset (data-door delete). Env-authored rows are never flagged (they ARE the definition, not a customization of one). - new read-only boolean field on sys_permission_set + added to the "All" Setup list view alongside managed_by. Verified on the real showcase stack: showcase_contributor goes customized=false → true (data-door PATCH) → false (data-door reset), with managed_by:'package' preserved throughout. Covered by the projection dogfood proof and unit tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q --- .changeset/permission-set-customized-flag.md | 8 ++++++++ ...wcase-permission-projection.dogfood.test.ts | 10 +++++++--- .../src/objects/rbac-objects.test.ts | 10 ++++++++++ .../src/objects/sys-permission-set.object.ts | 18 +++++++++++++++++- .../src/permission-set-projection.test.ts | 2 ++ .../src/permission-set-projection.ts | 15 ++++++++++++++- 6 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 .changeset/permission-set-customized-flag.md diff --git a/.changeset/permission-set-customized-flag.md b/.changeset/permission-set-customized-flag.md new file mode 100644 index 0000000000..867b0b551a --- /dev/null +++ b/.changeset/permission-set-customized-flag.md @@ -0,0 +1,8 @@ +--- +"@objectstack/plugin-security": minor +--- + +Surface a `customized` flag on `sys_permission_set` so Setup can tell — at a glance — which packaged permission sets have an environment overlay (ADR-0094). + +- The env projector stamps `customized: true` on a `managed_by:'package'` row while an overlay shadows its shipped baseline, and clears it when the overlay is removed (the data-door "reset"). Env-authored rows are never flagged (an env set is the definition, not a customization of one). +- The new read-only boolean field is added to `sys_permission_set` and to the "All" Setup list view (alongside `managed_by`), so a packaged-but-customized set is visible without opening the Studio layered diff. diff --git a/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts b/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts index 0c5bdf6b57..d7dbd3387d 100644 --- a/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts +++ b/packages/dogfood/test/showcase-permission-projection.dogfood.test.ts @@ -134,18 +134,22 @@ describe('sys_permission_set pure projection (ADR-0094)', () => { item: { ...baseline, label: 'Contributor (env customized)' }, }); - // Awaited projection: the record already reflects the overlay, and the - // package provenance is untouched. + // Awaited projection: the record already reflects the overlay, the + // package provenance is untouched, and it is flagged customized so the + // Setup list can badge it. const after = await findSet('showcase_contributor'); expect(after.label).toBe('Contributor (env customized)'); expect(after.managed_by).toBe('package'); expect(after.package_id).toBe(contributor.package_id); + expect(after.customized).toBe(true); - // Deleting the overlay resets the record to the shipped declaration. + // Deleting the overlay resets the record to the shipped declaration and + // clears the customized flag. await protocol.deleteMetaItem({ type: 'permission', name: 'showcase_contributor' }); const reset = await findSet('showcase_contributor'); expect(reset.label).toBe(contributor.label); expect(reset.managed_by).toBe('package'); + expect(reset.customized).toBe(false); }); it('a brand-new environment set authored through the metadata door appears as a Setup record', async () => { 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 b3e9f033b4..446fab8ee2 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -145,6 +145,16 @@ describe('RBAC object canonical names + row actions', () => { expect(names).toEqual(['activate_permission_set', 'clone_permission_set', 'deactivate_permission_set']); }); + 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(); + expect(f.type).toBe('boolean'); + expect(f.readonly).toBe(true); + const allView: any = (SysPermissionSet.listViews as any).all_permsets; + expect(allView.columns).toContain('customized'); + expect(allView.columns).toContain('managed_by'); + }); + it('[ADR-0094] locks the API name after creation (readonly on edit, editable on create)', () => { // The name is the metadata identity the record projects from — renaming // through the data door is rejected (400); this is the matching UI lock. 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 a944198d90..4453ae2ef9 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 @@ -117,7 +117,9 @@ export const SysPermissionSet = ObjectSchema.create({ name: 'all_permsets', label: 'All', data: { provider: 'object', object: 'sys_permission_set' }, - columns: ['label', 'name', 'active', 'updated_at'], + // [ADR-0094] Surface provenance + the customized flag so admins can tell + // a packaged set (and whether they've overlaid it) from an env set. + columns: ['label', 'name', 'managed_by', 'customized', 'active', 'updated_at'], sort: [{ field: 'label', order: 'asc' }], pagination: { pageSize: 50 }, }, @@ -235,6 +237,20 @@ export const SysPermissionSet = ObjectSchema.create({ group: 'Provenance', }), + // [ADR-0094] TRUE when this package-owned set is currently shadowed by an + // environment overlay (customized in this env, away from its shipped + // baseline). Projector-maintained; deleting the overlay (reset) clears it. + // Only meaningful on `managed_by:'package'` rows. + customized: Field.boolean({ + label: 'Customized', + defaultValue: false, + readonly: true, + description: + 'This packaged permission set has an environment customization overlay (ADR-0094). ' + + 'Reset it (delete through the data door) to return to the shipped baseline.', + group: 'Provenance', + }), + // ── System ─────────────────────────────────────────────────── id: Field.text({ label: 'Permission Set ID', 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 0e95bfdea0..44c8de0d12 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -323,6 +323,7 @@ describe('package-owned set customization lifecycle (env overlay)', () => { expect(JSON.parse(row.system_permissions)).toEqual(['customized']); expect(row.managed_by).toBe('package'); expect(row.package_id).toBe('com.example.crm'); + expect(row.customized, 'package row now carries an overlay → flagged customized').toBe(true); }); it('deleting the overlay RESETS the package record to its declared baseline', async () => { @@ -341,6 +342,7 @@ describe('package-owned set customization lifecycle (env overlay)', () => { expect(row, 'a packaged definition is never removed by an overlay reset').toBeTruthy(); expect(JSON.parse(row.system_permissions)).toEqual(['pkg.baseline']); expect(row.managed_by).toBe('package'); + expect(row.customized, 'reset clears the customized flag').toBe(false); }); }); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 404ff7826a..b8626417b8 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -204,10 +204,17 @@ export async function upsertEnvPermissionSet( ql: any, ps: any, _logger?: ProjectionLogger, + opts?: { customized?: boolean }, ): Promise { const out: PermissionSeedOutcome = { seeded: 0, updated: 0, skippedEnvAuthored: 0, skippedForeign: 0 }; if (!ql || typeof ql.find !== 'function' || !ps?.name) return out; + // [ADR-0094] `customized` marks a PACKAGE-owned row that an env overlay is + // currently shadowing, so the Setup list can badge it "customized" and the + // reset action reads honestly. An env-authored row is not a customization of + // anything (it IS the definition), so the flag only rides on package rows. + const customized = opts?.customized; + const existing = (await tryFind(ql, 'sys_permission_set', { name: ps.name }, 1))[0]; if (!existing?.id) { const created = await tryInsert(ql, 'sys_permission_set', { @@ -216,6 +223,7 @@ export async function upsertEnvPermissionSet( ...permissionSetRowFields(ps), active: ps.active != null ? asBool(ps.active) : true, managed_by: 'user', + ...(customized !== undefined ? { customized: !!customized } : {}), }); if (created) out.seeded += 1; return out; @@ -226,6 +234,11 @@ export async function upsertEnvPermissionSet( // customization, and an env row keeps its user/platform/legacy provenance. const patch: Record = { id: existing.id, ...permissionSetRowFields(ps) }; if (ps.active != null) patch.active = asBool(ps.active); + // Only stamp `customized` on package-owned rows (an overlay of a packaged + // set). For env rows the concept doesn't apply — clear any stale flag. + if (customized !== undefined) { + patch.customized = existing.managed_by === 'package' ? !!customized : false; + } if (await tryUpdate(ql, 'sys_permission_set', patch)) { out.updated += 1; } @@ -388,7 +401,7 @@ export async function projectPermissionMutation( await syncEvaluatorRegistry(metadata, evt.name, null, false); return retirePermissionSetRecord(ql, metadata, evt.name, logger); } - const out = await upsertEnvPermissionSet(ql, body, logger); + const out = await upsertEnvPermissionSet(ql, body, logger, { customized: overlayBacked }); if (out.seeded + out.updated > 0) { await syncEvaluatorRegistry(metadata, evt.name, body, overlayBacked); }