Skip to content

Commit f0acf25

Browse files
os-zhuangclaude
andauthored
feat(plugin-security): customized flag on packaged permission sets — Setup badge (ADR-0094) (#2913)
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. Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2fbf03e commit f0acf25

6 files changed

Lines changed: 58 additions & 5 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
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).
6+
7+
- 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).
8+
- 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.

packages/dogfood/test/showcase-permission-projection.dogfood.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,18 +134,22 @@ describe('sys_permission_set pure projection (ADR-0094)', () => {
134134
item: { ...baseline, label: 'Contributor (env customized)' },
135135
});
136136

137-
// Awaited projection: the record already reflects the overlay, and the
138-
// package provenance is untouched.
137+
// Awaited projection: the record already reflects the overlay, the
138+
// package provenance is untouched, and it is flagged customized so the
139+
// Setup list can badge it.
139140
const after = await findSet('showcase_contributor');
140141
expect(after.label).toBe('Contributor (env customized)');
141142
expect(after.managed_by).toBe('package');
142143
expect(after.package_id).toBe(contributor.package_id);
144+
expect(after.customized).toBe(true);
143145

144-
// Deleting the overlay resets the record to the shipped declaration.
146+
// Deleting the overlay resets the record to the shipped declaration and
147+
// clears the customized flag.
145148
await protocol.deleteMetaItem({ type: 'permission', name: 'showcase_contributor' });
146149
const reset = await findSet('showcase_contributor');
147150
expect(reset.label).toBe(contributor.label);
148151
expect(reset.managed_by).toBe('package');
152+
expect(reset.customized).toBe(false);
149153
});
150154

151155
it('a brand-new environment set authored through the metadata door appears as a Setup record', async () => {

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

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

148+
it('[ADR-0094] declares a readonly `customized` provenance flag surfaced in the All list view', () => {
149+
const f: any = (SysPermissionSet.fields as any).customized;
150+
expect(f, 'customized field exists').toBeDefined();
151+
expect(f.type).toBe('boolean');
152+
expect(f.readonly).toBe(true);
153+
const allView: any = (SysPermissionSet.listViews as any).all_permsets;
154+
expect(allView.columns).toContain('customized');
155+
expect(allView.columns).toContain('managed_by');
156+
});
157+
148158
it('[ADR-0094] locks the API name after creation (readonly on edit, editable on create)', () => {
149159
// The name is the metadata identity the record projects from — renaming
150160
// through the data door is rejected (400); this is the matching UI lock.

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ export const SysPermissionSet = ObjectSchema.create({
117117
name: 'all_permsets',
118118
label: 'All',
119119
data: { provider: 'object', object: 'sys_permission_set' },
120-
columns: ['label', 'name', 'active', 'updated_at'],
120+
// [ADR-0094] Surface provenance + the customized flag so admins can tell
121+
// a packaged set (and whether they've overlaid it) from an env set.
122+
columns: ['label', 'name', 'managed_by', 'customized', 'active', 'updated_at'],
121123
sort: [{ field: 'label', order: 'asc' }],
122124
pagination: { pageSize: 50 },
123125
},
@@ -235,6 +237,20 @@ export const SysPermissionSet = ObjectSchema.create({
235237
group: 'Provenance',
236238
}),
237239

240+
// [ADR-0094] TRUE when this package-owned set is currently shadowed by an
241+
// environment overlay (customized in this env, away from its shipped
242+
// baseline). Projector-maintained; deleting the overlay (reset) clears it.
243+
// Only meaningful on `managed_by:'package'` rows.
244+
customized: Field.boolean({
245+
label: 'Customized',
246+
defaultValue: false,
247+
readonly: true,
248+
description:
249+
'This packaged permission set has an environment customization overlay (ADR-0094). ' +
250+
'Reset it (delete through the data door) to return to the shipped baseline.',
251+
group: 'Provenance',
252+
}),
253+
238254
// ── System ───────────────────────────────────────────────────
239255
id: Field.text({
240256
label: 'Permission Set ID',

packages/plugins/plugin-security/src/permission-set-projection.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ describe('package-owned set customization lifecycle (env overlay)', () => {
323323
expect(JSON.parse(row.system_permissions)).toEqual(['customized']);
324324
expect(row.managed_by).toBe('package');
325325
expect(row.package_id).toBe('com.example.crm');
326+
expect(row.customized, 'package row now carries an overlay → flagged customized').toBe(true);
326327
});
327328

328329
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)', () => {
341342
expect(row, 'a packaged definition is never removed by an overlay reset').toBeTruthy();
342343
expect(JSON.parse(row.system_permissions)).toEqual(['pkg.baseline']);
343344
expect(row.managed_by).toBe('package');
345+
expect(row.customized, 'reset clears the customized flag').toBe(false);
344346
});
345347
});
346348

packages/plugins/plugin-security/src/permission-set-projection.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,17 @@ export async function upsertEnvPermissionSet(
204204
ql: any,
205205
ps: any,
206206
_logger?: ProjectionLogger,
207+
opts?: { customized?: boolean },
207208
): Promise<PermissionSeedOutcome> {
208209
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, skippedEnvAuthored: 0, skippedForeign: 0 };
209210
if (!ql || typeof ql.find !== 'function' || !ps?.name) return out;
210211

212+
// [ADR-0094] `customized` marks a PACKAGE-owned row that an env overlay is
213+
// currently shadowing, so the Setup list can badge it "customized" and the
214+
// reset action reads honestly. An env-authored row is not a customization of
215+
// anything (it IS the definition), so the flag only rides on package rows.
216+
const customized = opts?.customized;
217+
211218
const existing = (await tryFind(ql, 'sys_permission_set', { name: ps.name }, 1))[0];
212219
if (!existing?.id) {
213220
const created = await tryInsert(ql, 'sys_permission_set', {
@@ -216,6 +223,7 @@ export async function upsertEnvPermissionSet(
216223
...permissionSetRowFields(ps),
217224
active: ps.active != null ? asBool(ps.active) : true,
218225
managed_by: 'user',
226+
...(customized !== undefined ? { customized: !!customized } : {}),
219227
});
220228
if (created) out.seeded += 1;
221229
return out;
@@ -226,6 +234,11 @@ export async function upsertEnvPermissionSet(
226234
// customization, and an env row keeps its user/platform/legacy provenance.
227235
const patch: Record<string, any> = { id: existing.id, ...permissionSetRowFields(ps) };
228236
if (ps.active != null) patch.active = asBool(ps.active);
237+
// Only stamp `customized` on package-owned rows (an overlay of a packaged
238+
// set). For env rows the concept doesn't apply — clear any stale flag.
239+
if (customized !== undefined) {
240+
patch.customized = existing.managed_by === 'package' ? !!customized : false;
241+
}
229242
if (await tryUpdate(ql, 'sys_permission_set', patch)) {
230243
out.updated += 1;
231244
}
@@ -388,7 +401,7 @@ export async function projectPermissionMutation(
388401
await syncEvaluatorRegistry(metadata, evt.name, null, false);
389402
return retirePermissionSetRecord(ql, metadata, evt.name, logger);
390403
}
391-
const out = await upsertEnvPermissionSet(ql, body, logger);
404+
const out = await upsertEnvPermissionSet(ql, body, logger, { customized: overlayBacked });
392405
if (out.seeded + out.updated > 0) {
393406
await syncEvaluatorRegistry(metadata, evt.name, body, overlayBacked);
394407
}

0 commit comments

Comments
 (0)