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
8 changes: 8 additions & 0 deletions .changeset/permission-set-customized-flag.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
10 changes: 10 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 @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,17 @@ export async function upsertEnvPermissionSet(
ql: any,
ps: any,
_logger?: ProjectionLogger,
opts?: { customized?: boolean },
): Promise<PermissionSeedOutcome> {
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', {
Expand All @@ -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;
Expand All @@ -226,6 +234,11 @@ export async function upsertEnvPermissionSet(
// customization, and an env row keeps its user/platform/legacy provenance.
const patch: Record<string, any> = { 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;
}
Expand Down Expand Up @@ -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);
}
Expand Down