Skip to content

Commit a7b6915

Browse files
guguclaude
andauthored
permissions: backend-driven permission catalog endpoint (#1799)
* permissions: backend-driven permission catalog endpoint Adds GET /permissions/available, derived from CEDAR_SCHEMA.RocketAdmin.actions so the catalog stays in sync with the Cedar action enum. Replaces the hardcoded POLICY_ACTION_GROUPS list in the frontend with a signal-backed httpResource on UsersService, and switches PolicyAction's needsTable/needsDashboard booleans to a single resource discriminator. New ActionEvent and Panel categories now appear in the Cedar policy editor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * permissions: simplify catalog endpoint to pure CEDAR_SCHEMA passthrough Drop labels, short labels, icons, and synthesized wildcards from the backend response. Each action is now { value, resource } only, where resource is the first appliesTo.resourceTypes entry lowercased. Wildcards (*, table:*, etc.) and display copy move to the frontend: a new lib/permission-display.ts derives labels/icons algorithmically, and CedarPolicyListComponent synthesizes the General and per-prefix wildcard entries at render time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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> * app.component.spec: isolate session-restoration setTimeout from cross-test pollution AppComponent never unsubscribes from authCast, so prior tests' component instances re-fire on cast.next and queue their own setTimeouts, overwriting the captured callback. Gate the capture behind a flag set by THIS app's mocked initializeUserSession so we only grab the timeout from this instance's restoration branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 641a0b5 commit a7b6915

14 files changed

Lines changed: 532 additions & 124 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
export class AvailablePermissionDs {
4+
@ApiProperty()
5+
value: string;
6+
7+
@ApiProperty({ required: false })
8+
resource?: string;
9+
}
10+
11+
export class AvailablePermissionsResponseDs {
12+
@ApiProperty({ isArray: true, type: AvailablePermissionDs })
13+
actions: Array<AvailablePermissionDs>;
14+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { CEDAR_SCHEMA } from '../cedar-authorization/cedar-schema.js';
2+
import { AvailablePermissionDs } from './application/data-structures/available-permissions.ds.js';
3+
4+
export function buildPermissionCatalog(): Array<AvailablePermissionDs> {
5+
const schemaActions = (CEDAR_SCHEMA as SchemaShape).RocketAdmin.actions;
6+
return Object.entries(schemaActions).map(([value, definition]) =>
7+
buildAction(value, definition.appliesTo.resourceTypes),
8+
);
9+
}
10+
11+
function buildAction(value: string, resourceTypes: Array<string>): AvailablePermissionDs {
12+
const action: AvailablePermissionDs = { value };
13+
const resource = deriveResource(resourceTypes);
14+
if (resource) {
15+
action.resource = resource;
16+
}
17+
return action;
18+
}
19+
20+
function deriveResource(resourceTypes: Array<string>): string | undefined {
21+
const first = resourceTypes[0];
22+
if (!first) return undefined;
23+
return first.charAt(0).toLowerCase() + first.slice(1);
24+
}
25+
26+
type SchemaShape = {
27+
RocketAdmin: {
28+
actions: Record<string, { appliesTo: { principalTypes: Array<string>; resourceTypes: Array<string> } }>;
29+
};
30+
};

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
Body,
33
Controller,
4+
Get,
45
HttpException,
56
HttpStatus,
67
Inject,
@@ -19,7 +20,9 @@ import { InTransactionEnum } from '../../enums/in-transaction.enum.js';
1920
import { Messages } from '../../exceptions/text/messages.js';
2021
import { ConnectionEditGuard } from '../../guards/connection-edit.guard.js';
2122
import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js';
23+
import { AvailablePermissionsResponseDs } from './application/data-structures/available-permissions.ds.js';
2224
import { ComplexPermissionDs, CreatePermissionsDs } from './application/data-structures/create-permissions.ds.js';
25+
import { buildPermissionCatalog } from './permission-catalog.builder.js';
2326
import { ICreateOrUpdatePermissions } from './use-cases/permissions-use-cases.interface.js';
2427

2528
@UseInterceptors(SentryInterceptor)
@@ -34,6 +37,17 @@ export class PermissionController {
3437
private readonly createOrUpdatePermissionsUseCase: ICreateOrUpdatePermissions,
3538
) {}
3639

40+
@ApiOperation({ summary: 'List available permissions derived from the Cedar schema' })
41+
@ApiResponse({
42+
status: 200,
43+
description: 'Flat list of permissions with their resource scope.',
44+
type: AvailablePermissionsResponseDs,
45+
})
46+
@Get('permissions/available')
47+
async getAvailablePermissions(): Promise<AvailablePermissionsResponseDs> {
48+
return { actions: buildPermissionCatalog() };
49+
}
50+
3751
@ApiOperation({ summary: 'Create or update permissions in group' })
3852
@ApiBody({ type: ComplexPermissionDs })
3953
@ApiResponse({

backend/src/entities/permission/permission.module.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ import { CreateOrUpdatePermissionsUseCase } from './use-cases/create-or-update-p
4848
})
4949
export class PermissionModule implements NestModule {
5050
public configure(consumer: MiddlewareConsumer): any {
51-
consumer.apply(AuthMiddleware).forRoutes({ path: 'permissions/:slug', method: RequestMethod.PUT });
51+
consumer
52+
.apply(AuthMiddleware)
53+
.forRoutes(
54+
{ path: 'permissions/:slug', method: RequestMethod.PUT },
55+
{ path: 'permissions/available', method: RequestMethod.GET },
56+
);
5257
}
5358
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/* eslint-disable @typescript-eslint/no-unused-vars */
2+
3+
import { INestApplication, ValidationPipe } from '@nestjs/common';
4+
import { Test } from '@nestjs/testing';
5+
import test from 'ava';
6+
import { ValidationError } from 'class-validator';
7+
import cookieParser from 'cookie-parser';
8+
import request from 'supertest';
9+
import { ApplicationModule } from '../../../src/app.module.js';
10+
import { CedarAction } from '../../../src/entities/cedar-authorization/cedar-action-map.js';
11+
import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js';
12+
import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js';
13+
import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js';
14+
import { Cacher } from '../../../src/helpers/cache/cacher.js';
15+
import { DatabaseModule } from '../../../src/shared/database/database.module.js';
16+
import { DatabaseService } from '../../../src/shared/database/database.service.js';
17+
import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js';
18+
import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js';
19+
import { TestUtils } from '../../utils/test.utils.js';
20+
21+
let app: INestApplication;
22+
23+
test.before(async () => {
24+
setSaasEnvVariable();
25+
const moduleFixture = await Test.createTestingModule({
26+
imports: [ApplicationModule, DatabaseModule],
27+
providers: [DatabaseService, TestUtils],
28+
}).compile();
29+
app = moduleFixture.createNestApplication();
30+
31+
app.use(cookieParser());
32+
app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger)));
33+
app.useGlobalPipes(
34+
new ValidationPipe({
35+
exceptionFactory(validationErrors: ValidationError[] = []) {
36+
return new ValidationException(validationErrors);
37+
},
38+
}),
39+
);
40+
await app.init();
41+
app.getHttpServer().listen(0);
42+
});
43+
44+
test.after(async () => {
45+
await Cacher.clearAllCache();
46+
await app.close();
47+
});
48+
49+
test.serial('GET /permissions/available returns catalog covering every CedarAction', async (t) => {
50+
const token = (await registerUserAndReturnUserInfo(app)).token;
51+
52+
const response = await request(app.getHttpServer())
53+
.get('/permissions/available')
54+
.set('Cookie', token)
55+
.set('Accept', 'application/json');
56+
57+
t.is(response.status, 200);
58+
59+
const body = response.body as {
60+
actions: Array<{ value: string; resource?: string }>;
61+
};
62+
63+
t.true(Array.isArray(body.actions));
64+
t.true(body.actions.length > 0);
65+
66+
const values = new Set(body.actions.map((a) => a.value));
67+
68+
for (const cedarValue of Object.values(CedarAction)) {
69+
t.true(values.has(cedarValue), `catalog missing CedarAction ${cedarValue}`);
70+
}
71+
72+
t.false(values.has('*'), 'catalog must NOT include synthesized wildcards');
73+
t.false(values.has('table:*'), 'catalog must NOT include synthesized wildcards');
74+
t.false(values.has('dashboard:*'), 'catalog must NOT include synthesized wildcards');
75+
76+
const byValue = new Map(body.actions.map((a) => [a.value, a]));
77+
78+
t.is(byValue.get('connection:read')!.resource, 'connection');
79+
t.is(byValue.get('group:edit')!.resource, 'group');
80+
t.is(byValue.get('table:read')!.resource, 'table');
81+
t.is(byValue.get('actionEvent:trigger')!.resource, 'actionEvent');
82+
t.is(byValue.get('dashboard:read')!.resource, 'dashboard');
83+
t.is(byValue.get('dashboard:create')!.resource, 'dashboard');
84+
t.is(byValue.get('panel:read')!.resource, 'panel');
85+
86+
for (const action of body.actions) {
87+
t.is(Object.hasOwn(action, 'label'), false, `action ${action.value} should not have label`);
88+
t.is(Object.hasOwn(action, 'shortLabel'), false, `action ${action.value} should not have shortLabel`);
89+
t.is(Object.hasOwn(action, 'icon'), false, `action ${action.value} should not have icon`);
90+
}
91+
});
92+
93+
test.serial('GET /permissions/available requires authentication', async (t) => {
94+
const response = await request(app.getHttpServer()).get('/permissions/available').set('Accept', 'application/json');
95+
96+
t.is(response.status, 401);
97+
});

frontend/src/app/app.component.spec.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,14 @@ describe('AppComponent', () => {
261261
});
262262

263263
it('should handle user login flow when cast emits user with expires', async () => {
264+
// AppComponent schedules a setTimeout to log the user out when the token expires. If
265+
// fixture.whenStable() waits longer than that timer (e.g. while a stray HTTP request
266+
// from a transitively-injected service settles), the callback fires and resets
267+
// userLoggedIn back to null. Swap setTimeout for a no-op so the timer can't fire.
268+
const setTimeoutSpy = vi
269+
.spyOn(window, 'setTimeout')
270+
.mockImplementation(() => 1 as unknown as ReturnType<typeof setTimeout>);
271+
264272
mockCompanyService.getWhiteLabelProperties.mockReturnValue(of({ logo: '', favicon: '' }));
265273
mockUiSettingsService.getUiSettings.mockReturnValue(
266274
of({ globalSettings: { lastFeatureNotificationId: 'old-id' } }),
@@ -285,12 +293,22 @@ describe('AppComponent', () => {
285293
expect(mockCompanyService.getWhiteLabelProperties).toHaveBeenCalledWith('company-12345678');
286294
expect(mockUiSettingsService.getUiSettings).toHaveBeenCalled();
287295
expect(app.isFeatureNotificationShown).toBe(true);
296+
297+
setTimeoutSpy.mockRestore();
288298
});
289299

290300
it('should restore session and log out after token expiration', async () => {
301+
// Lingering subscriptions from previous tests (AppComponent never unsubscribes from
302+
// authCast) also schedule setTimeouts when cast.next fires. Capture only the timeout
303+
// queued immediately after THIS app's mocked initializeUserSession, which is the one
304+
// scheduled by the session-restoration branch for this component instance.
291305
let capturedTimeoutCallback: Function | null = null;
306+
let captureNextTimeout = false;
292307
const setTimeoutSpy = vi.spyOn(window, 'setTimeout').mockImplementation((callback: Function) => {
293-
capturedTimeoutCallback = callback;
308+
if (captureNextTimeout) {
309+
capturedTimeoutCallback = callback;
310+
captureNextTimeout = false;
311+
}
294312
return 1 as unknown as ReturnType<typeof setTimeout>;
295313
});
296314

@@ -299,6 +317,7 @@ describe('AppComponent', () => {
299317

300318
vi.spyOn(app, 'initializeUserSession').mockImplementation(() => {
301319
app.userLoggedIn = true;
320+
captureNextTimeout = true;
302321
});
303322

304323
app.ngOnInit();
@@ -307,11 +326,9 @@ describe('AppComponent', () => {
307326
await fixture.whenStable();
308327

309328
expect(app.initializeUserSession).toHaveBeenCalled();
329+
expect(capturedTimeoutCallback).not.toBeNull();
310330

311-
// Execute the timeout callback that was captured
312-
if (capturedTimeoutCallback) {
313-
capturedTimeoutCallback();
314-
}
331+
capturedTimeoutCallback!();
315332

316333
expect(app.logOut).toHaveBeenCalledWith(true);
317334
expect(app.router.navigate).toHaveBeenCalledWith(['/login']);

frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,14 +148,14 @@ export class DateTimeFilterComponent extends BaseFilterFieldComponent implements
148148

149149
private _restoreBetween(value: string[]): void {
150150
if (value[0]) {
151-
const lower = new Date(value[0]);
152-
this.lowerDate = format(lower, 'yyyy-MM-dd');
153-
this.lowerTime = format(lower, 'HH:mm:ss');
151+
const iso = new Date(value[0]).toISOString();
152+
this.lowerDate = iso.slice(0, 10);
153+
this.lowerTime = iso.slice(11, 19);
154154
}
155155
if (value[1]) {
156-
const upper = new Date(value[1]);
157-
this.upperDate = format(upper, 'yyyy-MM-dd');
158-
this.upperTime = format(upper, 'HH:mm:ss');
156+
const iso = new Date(value[1]).toISOString();
157+
this.upperDate = iso.slice(0, 10);
158+
this.upperTime = iso.slice(11, 19);
159159
}
160160
}
161161

frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
<mat-optgroup [label]="actionGroup.group">
5959
@for (action of actionGroup.actions; track action.value) {
6060
<mat-option [value]="action.value">
61-
{{ action.label }}
61+
{{ getActionLabel(action.value) }}
6262
</mat-option>
6363
}
6464
</mat-optgroup>
@@ -125,7 +125,7 @@
125125
<mat-optgroup [label]="group.group">
126126
@for (action of group.actions; track action.value) {
127127
<mat-option [value]="action.value">
128-
{{ action.label }}
128+
{{ getActionLabel(action.value) }}
129129
</mat-option>
130130
}
131131
</mat-optgroup>

frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,45 @@
1+
import { signal } from '@angular/core';
12
import { ComponentFixture, TestBed } from '@angular/core/testing';
23
import { FormsModule } from '@angular/forms';
34
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
5+
import { PolicyAction, PolicyActionGroup } from 'src/app/lib/cedar-policy-items';
6+
import { UsersService } from 'src/app/services/users.service';
47
import { CedarPolicyListComponent } from './cedar-policy-list.component';
58

9+
const fixtureGroups: PolicyActionGroup[] = [
10+
{
11+
group: 'Connection',
12+
actions: [
13+
{ value: 'connection:read', resource: 'connection' },
14+
{ value: 'connection:edit', resource: 'connection' },
15+
],
16+
},
17+
{
18+
group: 'Group',
19+
actions: [
20+
{ value: 'group:read', resource: 'group' },
21+
{ value: 'group:edit', resource: 'group' },
22+
],
23+
},
24+
{
25+
group: 'Table',
26+
actions: [
27+
{ value: 'table:read', resource: 'table' },
28+
{ value: 'table:edit', resource: 'table' },
29+
],
30+
},
31+
{
32+
group: 'Dashboard',
33+
actions: [
34+
{ value: 'dashboard:read', resource: 'dashboard' },
35+
{ value: 'dashboard:create', resource: 'dashboard' },
36+
{ value: 'dashboard:edit', resource: 'dashboard' },
37+
],
38+
},
39+
];
40+
41+
const flatActions: PolicyAction[] = fixtureGroups.flatMap((g) => g.actions);
42+
643
describe('CedarPolicyListComponent', () => {
744
let component: CedarPolicyListComponent;
845
let fixture: ComponentFixture<CedarPolicyListComponent>;
@@ -18,8 +55,16 @@ describe('CedarPolicyListComponent', () => {
1855
];
1956

2057
beforeEach(async () => {
58+
const groupsSignal = signal(fixtureGroups);
59+
const actionsSignal = signal(flatActions);
60+
const mockUsersService: Partial<UsersService> = {
61+
availablePermissionGroups: groupsSignal.asReadonly() as UsersService['availablePermissionGroups'],
62+
availablePermissions: actionsSignal.asReadonly() as UsersService['availablePermissions'],
63+
};
64+
2165
await TestBed.configureTestingModule({
2266
imports: [CedarPolicyListComponent, FormsModule, BrowserAnimationsModule],
67+
providers: [{ provide: UsersService, useValue: mockUsersService }],
2368
}).compileComponents();
2469

2570
fixture = TestBed.createComponent(CedarPolicyListComponent);
@@ -210,9 +255,36 @@ describe('CedarPolicyListComponent', () => {
210255
expect(component.needsDashboard).toBe(true);
211256
});
212257

258+
it('should treat dashboard:create as scopeless', () => {
259+
component.newAction = 'dashboard:create';
260+
expect(component.needsDashboard).toBe(false);
261+
});
262+
213263
it('should return correct dashboard display names', () => {
214264
expect(component.getDashboardDisplayName('dash-1')).toBe('Sales Dashboard');
215265
expect(component.getDashboardDisplayName('unknown')).toBe('unknown');
216266
expect(component.getDashboardDisplayName('*')).toBe('All dashboards');
217267
});
268+
269+
it('should synthesize General and prefix wildcards in addActionGroups', () => {
270+
const testable = component as CedarPolicyListComponent & {
271+
addActionGroups: () => PolicyActionGroup[];
272+
};
273+
const groups = testable.addActionGroups();
274+
const general = groups.find((g) => g.group === 'General');
275+
expect(general).toBeTruthy();
276+
expect(general!.actions[0].value).toBe('*');
277+
278+
const table = groups.find((g) => g.group === 'Table');
279+
expect(table).toBeTruthy();
280+
expect(table!.actions[0].value).toBe('table:*');
281+
expect(table!.actions[0].resource).toBe('table');
282+
283+
const dashboard = groups.find((g) => g.group === 'Dashboard');
284+
expect(dashboard).toBeTruthy();
285+
expect(dashboard!.actions[0].value).toBe('dashboard:*');
286+
287+
const connection = groups.find((g) => g.group === 'Connection');
288+
expect(connection!.actions[0].value).toBe('connection:read');
289+
});
218290
});

0 commit comments

Comments
 (0)