Skip to content

Commit 15390c4

Browse files
guguclaude
andcommitted
permissions: flatten catalog response and derive groups on frontend
Backend now returns { actions: [...] } instead of pre-grouped categories. Group names ('Connection', 'ActionEvent', etc.) and group ordering live in permission-display.ts on the frontend, where groupNameForAction(value) derives them from the action prefix. UsersService computes availablePermissionGroups from the flat list at read time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 02c4786 commit 15390c4

7 files changed

Lines changed: 66 additions & 70 deletions

File tree

backend/src/entities/permission/application/data-structures/available-permissions.ds.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,7 @@ export class AvailablePermissionDs {
88
resource?: string;
99
}
1010

11-
export class AvailablePermissionGroupDs {
12-
@ApiProperty()
13-
group: string;
14-
11+
export class AvailablePermissionsResponseDs {
1512
@ApiProperty({ isArray: true, type: AvailablePermissionDs })
1613
actions: Array<AvailablePermissionDs>;
1714
}
18-
19-
export class AvailablePermissionsResponseDs {
20-
@ApiProperty({ isArray: true, type: AvailablePermissionGroupDs })
21-
groups: Array<AvailablePermissionGroupDs>;
22-
}

backend/src/entities/permission/permission-catalog.builder.ts

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,11 @@
11
import { CEDAR_SCHEMA } from '../cedar-authorization/cedar-schema.js';
2-
import {
3-
AvailablePermissionDs,
4-
AvailablePermissionGroupDs,
5-
} from './application/data-structures/available-permissions.ds.js';
2+
import { AvailablePermissionDs } from './application/data-structures/available-permissions.ds.js';
63

7-
const PREFIX_TO_GROUP: Record<string, string> = {
8-
connection: 'Connection',
9-
group: 'Group',
10-
table: 'Table',
11-
actionEvent: 'ActionEvent',
12-
dashboard: 'Dashboard',
13-
panel: 'Panel',
14-
};
15-
16-
const GROUP_ORDER = ['Connection', 'Group', 'Table', 'ActionEvent', 'Dashboard', 'Panel'];
17-
18-
export function buildPermissionCatalog(): Array<AvailablePermissionGroupDs> {
4+
export function buildPermissionCatalog(): Array<AvailablePermissionDs> {
195
const schemaActions = (CEDAR_SCHEMA as SchemaShape).RocketAdmin.actions;
20-
const grouped = new Map<string, Array<AvailablePermissionDs>>();
21-
22-
for (const [value, definition] of Object.entries(schemaActions)) {
23-
const action = buildAction(value, definition.appliesTo.resourceTypes);
24-
const groupName = resolveGroupName(value);
25-
const list = grouped.get(groupName);
26-
if (list) {
27-
list.push(action);
28-
} else {
29-
grouped.set(groupName, [action]);
30-
}
31-
}
32-
33-
return GROUP_ORDER.filter((name) => grouped.has(name)).map((name) => ({
34-
group: name,
35-
actions: grouped.get(name)!,
36-
}));
6+
return Object.entries(schemaActions).map(([value, definition]) =>
7+
buildAction(value, definition.appliesTo.resourceTypes),
8+
);
379
}
3810

3911
function buildAction(value: string, resourceTypes: Array<string>): AvailablePermissionDs {
@@ -51,11 +23,6 @@ function deriveResource(resourceTypes: Array<string>): string | undefined {
5123
return first.charAt(0).toLowerCase() + first.slice(1);
5224
}
5325

54-
function resolveGroupName(actionValue: string): string {
55-
const prefix = actionValue.split(':')[0];
56-
return PREFIX_TO_GROUP[prefix] ?? prefix.charAt(0).toUpperCase() + prefix.slice(1);
57-
}
58-
5926
type SchemaShape = {
6027
RocketAdmin: {
6128
actions: Record<string, { appliesTo: { principalTypes: Array<string>; resourceTypes: Array<string> } }>;

backend/src/entities/permission/permission.controller.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,15 @@ export class PermissionController {
3737
private readonly createOrUpdatePermissionsUseCase: ICreateOrUpdatePermissions,
3838
) {}
3939

40-
@ApiOperation({ summary: 'List available permissions with display metadata' })
40+
@ApiOperation({ summary: 'List available permissions derived from the Cedar schema' })
4141
@ApiResponse({
4242
status: 200,
43-
description: 'Catalog of permissions grouped by category.',
43+
description: 'Flat list of permissions with their resource scope.',
4444
type: AvailablePermissionsResponseDs,
4545
})
4646
@Get('permissions/available')
4747
async getAvailablePermissions(): Promise<AvailablePermissionsResponseDs> {
48-
return { groups: buildPermissionCatalog() };
48+
return { actions: buildPermissionCatalog() };
4949
}
5050

5151
@ApiOperation({ summary: 'Create or update permissions in group' })

backend/test/ava-tests/non-saas-tests/non-saas-permission-catalog-e2e.test.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,13 @@ test.serial('GET /permissions/available returns catalog covering every CedarActi
5757
t.is(response.status, 200);
5858

5959
const body = response.body as {
60-
groups: Array<{
61-
group: string;
62-
actions: Array<{ value: string; resource?: string }>;
63-
}>;
60+
actions: Array<{ value: string; resource?: string }>;
6461
};
6562

66-
t.true(Array.isArray(body.groups));
67-
t.true(body.groups.length > 0);
63+
t.true(Array.isArray(body.actions));
64+
t.true(body.actions.length > 0);
6865

69-
const flatActions = body.groups.flatMap((g) => g.actions);
70-
const values = new Set(flatActions.map((a) => a.value));
66+
const values = new Set(body.actions.map((a) => a.value));
7167

7268
for (const cedarValue of Object.values(CedarAction)) {
7369
t.true(values.has(cedarValue), `catalog missing CedarAction ${cedarValue}`);
@@ -77,7 +73,7 @@ test.serial('GET /permissions/available returns catalog covering every CedarActi
7773
t.false(values.has('table:*'), 'catalog must NOT include synthesized wildcards');
7874
t.false(values.has('dashboard:*'), 'catalog must NOT include synthesized wildcards');
7975

80-
const byValue = new Map(flatActions.map((a) => [a.value, a]));
76+
const byValue = new Map(body.actions.map((a) => [a.value, a]));
8177

8278
t.is(byValue.get('connection:read')!.resource, 'connection');
8379
t.is(byValue.get('group:edit')!.resource, 'group');
@@ -87,7 +83,7 @@ test.serial('GET /permissions/available returns catalog covering every CedarActi
8783
t.is(byValue.get('dashboard:create')!.resource, 'dashboard');
8884
t.is(byValue.get('panel:read')!.resource, 'panel');
8985

90-
for (const action of flatActions) {
86+
for (const action of body.actions) {
9187
t.is(Object.hasOwn(action, 'label'), false, `action ${action.value} should not have label`);
9288
t.is(Object.hasOwn(action, 'shortLabel'), false, `action ${action.value} should not have shortLabel`);
9389
t.is(Object.hasOwn(action, 'icon'), false, `action ${action.value} should not have icon`);

frontend/src/app/lib/permission-display.spec.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { actionIcon, actionLabel, actionShortLabel } from './permission-display';
1+
import { actionIcon, actionLabel, actionShortLabel, groupNameForAction } from './permission-display';
22

33
describe('permission-display', () => {
44
describe('actionLabel', () => {
@@ -40,6 +40,21 @@ describe('permission-display', () => {
4040
});
4141
});
4242

43+
describe('groupNameForAction', () => {
44+
it('maps known prefixes', () => {
45+
expect(groupNameForAction('connection:read')).toBe('Connection');
46+
expect(groupNameForAction('group:edit')).toBe('Group');
47+
expect(groupNameForAction('table:read')).toBe('Table');
48+
expect(groupNameForAction('actionEvent:trigger')).toBe('ActionEvent');
49+
expect(groupNameForAction('dashboard:read')).toBe('Dashboard');
50+
expect(groupNameForAction('panel:read')).toBe('Panel');
51+
});
52+
53+
it('falls back to capitalized prefix for unknown actions', () => {
54+
expect(groupNameForAction('foo:bar')).toBe('Foo');
55+
});
56+
});
57+
4358
describe('actionIcon', () => {
4459
it('maps known verbs', () => {
4560
expect(actionIcon('connection:read')).toBe('visibility');

frontend/src/app/lib/permission-display.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ const PREFIX_TO_LABEL: Record<string, string> = {
77
panel: 'Panel',
88
};
99

10+
const PREFIX_TO_GROUP_NAME: Record<string, string> = {
11+
connection: 'Connection',
12+
group: 'Group',
13+
table: 'Table',
14+
actionEvent: 'ActionEvent',
15+
dashboard: 'Dashboard',
16+
panel: 'Panel',
17+
};
18+
19+
export const PERMISSION_GROUP_ORDER = ['Connection', 'Group', 'Table', 'ActionEvent', 'Dashboard', 'Panel'];
20+
21+
export function groupNameForAction(value: string): string {
22+
const prefix = value.split(':')[0];
23+
return PREFIX_TO_GROUP_NAME[prefix] ?? capitalize(prefix);
24+
}
25+
1026
const VERB_TO_ICON: Record<string, string> = {
1127
read: 'visibility',
1228
edit: 'edit',

frontend/src/app/services/users.service.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { HttpClient, HttpResourceRef } from '@angular/common/http';
22
import { computed, Injectable, inject, signal } from '@angular/core';
33
import { catchError, EMPTY, map } from 'rxjs';
44
import { PolicyAction, PolicyActionGroup } from 'src/app/lib/cedar-policy-items';
5+
import { groupNameForAction, PERMISSION_GROUP_ORDER } from 'src/app/lib/permission-display';
56
import { GroupUser, Permissions, UserGroup, UserGroupInfo } from 'src/app/models/user';
67
import { ApiService } from './api.service';
78
import { NotificationsService } from './notifications.service';
@@ -45,18 +46,27 @@ export class UsersService {
4546
});
4647
public readonly groupsLoading = computed(() => this._groupsResource.isLoading());
4748

48-
private _availablePermissionsResource: HttpResourceRef<{ groups: PolicyActionGroup[] } | undefined> =
49-
this._api.resource<{
50-
groups: PolicyActionGroup[];
51-
}>(() => '/permissions/available');
49+
private _availablePermissionsResource: HttpResourceRef<{ actions: PolicyAction[] } | undefined> = this._api.resource<{
50+
actions: PolicyAction[];
51+
}>(() => '/permissions/available');
5252

53-
public readonly availablePermissionGroups = computed<PolicyActionGroup[]>(
54-
() => this._availablePermissionsResource.value()?.groups ?? [],
53+
public readonly availablePermissions = computed<PolicyAction[]>(
54+
() => this._availablePermissionsResource.value()?.actions ?? [],
5555
);
5656

57-
public readonly availablePermissions = computed<PolicyAction[]>(() =>
58-
this.availablePermissionGroups().flatMap((g) => g.actions),
59-
);
57+
public readonly availablePermissionGroups = computed<PolicyActionGroup[]>(() => {
58+
const byGroup = new Map<string, PolicyAction[]>();
59+
for (const action of this.availablePermissions()) {
60+
const groupName = groupNameForAction(action.value);
61+
const list = byGroup.get(groupName);
62+
if (list) list.push(action);
63+
else byGroup.set(groupName, [action]);
64+
}
65+
return PERMISSION_GROUP_ORDER.filter((name) => byGroup.has(name)).map((name) => ({
66+
group: name,
67+
actions: byGroup.get(name)!,
68+
}));
69+
});
6070

6171
// Group users - managed imperatively (per-group parallel fetch)
6272
private _groupUsers = signal<Record<string, GroupUser[] | 'empty'>>({});

0 commit comments

Comments
 (0)