diff --git a/backend/src/entities/permission/application/data-structures/available-permissions.ds.ts b/backend/src/entities/permission/application/data-structures/available-permissions.ds.ts new file mode 100644 index 000000000..d8a86fd98 --- /dev/null +++ b/backend/src/entities/permission/application/data-structures/available-permissions.ds.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AvailablePermissionDs { + @ApiProperty() + value: string; + + @ApiProperty({ required: false }) + resource?: string; +} + +export class AvailablePermissionsResponseDs { + @ApiProperty({ isArray: true, type: AvailablePermissionDs }) + actions: Array; +} diff --git a/backend/src/entities/permission/permission-catalog.builder.ts b/backend/src/entities/permission/permission-catalog.builder.ts new file mode 100644 index 000000000..3cb54073d --- /dev/null +++ b/backend/src/entities/permission/permission-catalog.builder.ts @@ -0,0 +1,30 @@ +import { CEDAR_SCHEMA } from '../cedar-authorization/cedar-schema.js'; +import { AvailablePermissionDs } from './application/data-structures/available-permissions.ds.js'; + +export function buildPermissionCatalog(): Array { + const schemaActions = (CEDAR_SCHEMA as SchemaShape).RocketAdmin.actions; + return Object.entries(schemaActions).map(([value, definition]) => + buildAction(value, definition.appliesTo.resourceTypes), + ); +} + +function buildAction(value: string, resourceTypes: Array): AvailablePermissionDs { + const action: AvailablePermissionDs = { value }; + const resource = deriveResource(resourceTypes); + if (resource) { + action.resource = resource; + } + return action; +} + +function deriveResource(resourceTypes: Array): string | undefined { + const first = resourceTypes[0]; + if (!first) return undefined; + return first.charAt(0).toLowerCase() + first.slice(1); +} + +type SchemaShape = { + RocketAdmin: { + actions: Record; resourceTypes: Array } }>; + }; +}; diff --git a/backend/src/entities/permission/permission.controller.ts b/backend/src/entities/permission/permission.controller.ts index d837bad33..3af1f4f57 100644 --- a/backend/src/entities/permission/permission.controller.ts +++ b/backend/src/entities/permission/permission.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Get, HttpException, HttpStatus, Inject, @@ -19,7 +20,9 @@ import { InTransactionEnum } from '../../enums/in-transaction.enum.js'; import { Messages } from '../../exceptions/text/messages.js'; import { ConnectionEditGuard } from '../../guards/connection-edit.guard.js'; import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js'; +import { AvailablePermissionsResponseDs } from './application/data-structures/available-permissions.ds.js'; import { ComplexPermissionDs, CreatePermissionsDs } from './application/data-structures/create-permissions.ds.js'; +import { buildPermissionCatalog } from './permission-catalog.builder.js'; import { ICreateOrUpdatePermissions } from './use-cases/permissions-use-cases.interface.js'; @UseInterceptors(SentryInterceptor) @@ -34,6 +37,17 @@ export class PermissionController { private readonly createOrUpdatePermissionsUseCase: ICreateOrUpdatePermissions, ) {} + @ApiOperation({ summary: 'List available permissions derived from the Cedar schema' }) + @ApiResponse({ + status: 200, + description: 'Flat list of permissions with their resource scope.', + type: AvailablePermissionsResponseDs, + }) + @Get('permissions/available') + async getAvailablePermissions(): Promise { + return { actions: buildPermissionCatalog() }; + } + @ApiOperation({ summary: 'Create or update permissions in group' }) @ApiBody({ type: ComplexPermissionDs }) @ApiResponse({ diff --git a/backend/src/entities/permission/permission.module.ts b/backend/src/entities/permission/permission.module.ts index df516629c..1ad875a4d 100644 --- a/backend/src/entities/permission/permission.module.ts +++ b/backend/src/entities/permission/permission.module.ts @@ -48,6 +48,11 @@ import { CreateOrUpdatePermissionsUseCase } from './use-cases/create-or-update-p }) export class PermissionModule implements NestModule { public configure(consumer: MiddlewareConsumer): any { - consumer.apply(AuthMiddleware).forRoutes({ path: 'permissions/:slug', method: RequestMethod.PUT }); + consumer + .apply(AuthMiddleware) + .forRoutes( + { path: 'permissions/:slug', method: RequestMethod.PUT }, + { path: 'permissions/available', method: RequestMethod.GET }, + ); } } diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-permission-catalog-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-permission-catalog-e2e.test.ts new file mode 100644 index 000000000..13639acf5 --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-permission-catalog-e2e.test.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { CedarAction } from '../../../src/entities/cedar-authorization/cedar-action-map.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js'; +import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +let app: INestApplication; + +test.before(async () => { + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + app = moduleFixture.createNestApplication(); + + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + await Cacher.clearAllCache(); + await app.close(); +}); + +test.serial('GET /permissions/available returns catalog covering every CedarAction', async (t) => { + const token = (await registerUserAndReturnUserInfo(app)).token; + + const response = await request(app.getHttpServer()) + .get('/permissions/available') + .set('Cookie', token) + .set('Accept', 'application/json'); + + t.is(response.status, 200); + + const body = response.body as { + actions: Array<{ value: string; resource?: string }>; + }; + + t.true(Array.isArray(body.actions)); + t.true(body.actions.length > 0); + + const values = new Set(body.actions.map((a) => a.value)); + + for (const cedarValue of Object.values(CedarAction)) { + t.true(values.has(cedarValue), `catalog missing CedarAction ${cedarValue}`); + } + + t.false(values.has('*'), 'catalog must NOT include synthesized wildcards'); + t.false(values.has('table:*'), 'catalog must NOT include synthesized wildcards'); + t.false(values.has('dashboard:*'), 'catalog must NOT include synthesized wildcards'); + + const byValue = new Map(body.actions.map((a) => [a.value, a])); + + t.is(byValue.get('connection:read')!.resource, 'connection'); + t.is(byValue.get('group:edit')!.resource, 'group'); + t.is(byValue.get('table:read')!.resource, 'table'); + t.is(byValue.get('actionEvent:trigger')!.resource, 'actionEvent'); + t.is(byValue.get('dashboard:read')!.resource, 'dashboard'); + t.is(byValue.get('dashboard:create')!.resource, 'dashboard'); + t.is(byValue.get('panel:read')!.resource, 'panel'); + + for (const action of body.actions) { + t.is(Object.hasOwn(action, 'label'), false, `action ${action.value} should not have label`); + t.is(Object.hasOwn(action, 'shortLabel'), false, `action ${action.value} should not have shortLabel`); + t.is(Object.hasOwn(action, 'icon'), false, `action ${action.value} should not have icon`); + } +}); + +test.serial('GET /permissions/available requires authentication', async (t) => { + const response = await request(app.getHttpServer()).get('/permissions/available').set('Accept', 'application/json'); + + t.is(response.status, 401); +}); diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts index d2ec7c173..e5913c23e 100644 --- a/frontend/src/app/app.component.spec.ts +++ b/frontend/src/app/app.component.spec.ts @@ -288,9 +288,17 @@ describe('AppComponent', () => { }); it('should restore session and log out after token expiration', async () => { + // Lingering subscriptions from previous tests (AppComponent never unsubscribes from + // authCast) also schedule setTimeouts when cast.next fires. Capture only the timeout + // queued immediately after THIS app's mocked initializeUserSession, which is the one + // scheduled by the session-restoration branch for this component instance. let capturedTimeoutCallback: Function | null = null; + let captureNextTimeout = false; const setTimeoutSpy = vi.spyOn(window, 'setTimeout').mockImplementation((callback: Function) => { - capturedTimeoutCallback = callback; + if (captureNextTimeout) { + capturedTimeoutCallback = callback; + captureNextTimeout = false; + } return 1 as unknown as ReturnType; }); @@ -299,6 +307,7 @@ describe('AppComponent', () => { vi.spyOn(app, 'initializeUserSession').mockImplementation(() => { app.userLoggedIn = true; + captureNextTimeout = true; }); app.ngOnInit(); @@ -307,11 +316,9 @@ describe('AppComponent', () => { await fixture.whenStable(); expect(app.initializeUserSession).toHaveBeenCalled(); + expect(capturedTimeoutCallback).not.toBeNull(); - // Execute the timeout callback that was captured - if (capturedTimeoutCallback) { - capturedTimeoutCallback(); - } + capturedTimeoutCallback!(); expect(app.logOut).toHaveBeenCalledWith(true); expect(app.router.navigate).toHaveBeenCalledWith(['/login']); diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html index 14b9bd5c1..03e33976b 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html @@ -58,7 +58,7 @@ @for (action of actionGroup.actions; track action.value) { - {{ action.label }} + {{ getActionLabel(action.value) }} } @@ -125,7 +125,7 @@ @for (action of group.actions; track action.value) { - {{ action.label }} + {{ getActionLabel(action.value) }} } diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts index 24e4650cc..41b4f8990 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts @@ -1,8 +1,45 @@ +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { PolicyAction, PolicyActionGroup } from 'src/app/lib/cedar-policy-items'; +import { UsersService } from 'src/app/services/users.service'; import { CedarPolicyListComponent } from './cedar-policy-list.component'; +const fixtureGroups: PolicyActionGroup[] = [ + { + group: 'Connection', + actions: [ + { value: 'connection:read', resource: 'connection' }, + { value: 'connection:edit', resource: 'connection' }, + ], + }, + { + group: 'Group', + actions: [ + { value: 'group:read', resource: 'group' }, + { value: 'group:edit', resource: 'group' }, + ], + }, + { + group: 'Table', + actions: [ + { value: 'table:read', resource: 'table' }, + { value: 'table:edit', resource: 'table' }, + ], + }, + { + group: 'Dashboard', + actions: [ + { value: 'dashboard:read', resource: 'dashboard' }, + { value: 'dashboard:create', resource: 'dashboard' }, + { value: 'dashboard:edit', resource: 'dashboard' }, + ], + }, +]; + +const flatActions: PolicyAction[] = fixtureGroups.flatMap((g) => g.actions); + describe('CedarPolicyListComponent', () => { let component: CedarPolicyListComponent; let fixture: ComponentFixture; @@ -18,8 +55,16 @@ describe('CedarPolicyListComponent', () => { ]; beforeEach(async () => { + const groupsSignal = signal(fixtureGroups); + const actionsSignal = signal(flatActions); + const mockUsersService: Partial = { + availablePermissionGroups: groupsSignal.asReadonly() as UsersService['availablePermissionGroups'], + availablePermissions: actionsSignal.asReadonly() as UsersService['availablePermissions'], + }; + await TestBed.configureTestingModule({ imports: [CedarPolicyListComponent, FormsModule, BrowserAnimationsModule], + providers: [{ provide: UsersService, useValue: mockUsersService }], }).compileComponents(); fixture = TestBed.createComponent(CedarPolicyListComponent); @@ -210,9 +255,36 @@ describe('CedarPolicyListComponent', () => { expect(component.needsDashboard).toBe(true); }); + it('should treat dashboard:create as scopeless', () => { + component.newAction = 'dashboard:create'; + expect(component.needsDashboard).toBe(false); + }); + it('should return correct dashboard display names', () => { expect(component.getDashboardDisplayName('dash-1')).toBe('Sales Dashboard'); expect(component.getDashboardDisplayName('unknown')).toBe('unknown'); expect(component.getDashboardDisplayName('*')).toBe('All dashboards'); }); + + it('should synthesize General and prefix wildcards in addActionGroups', () => { + const testable = component as CedarPolicyListComponent & { + addActionGroups: () => PolicyActionGroup[]; + }; + const groups = testable.addActionGroups(); + const general = groups.find((g) => g.group === 'General'); + expect(general).toBeTruthy(); + expect(general!.actions[0].value).toBe('*'); + + const table = groups.find((g) => g.group === 'Table'); + expect(table).toBeTruthy(); + expect(table!.actions[0].value).toBe('table:*'); + expect(table!.actions[0].resource).toBe('table'); + + const dashboard = groups.find((g) => g.group === 'Dashboard'); + expect(dashboard).toBeTruthy(); + expect(dashboard!.actions[0].value).toBe('dashboard:*'); + + const connection = groups.find((g) => g.group === 'Connection'); + expect(connection!.actions[0].value).toBe('connection:read'); + }); }); diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts index 1e0d9edb3..d15edceee 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts @@ -1,17 +1,14 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, input, output } from '@angular/core'; +import { Component, computed, inject, input, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { - CedarPolicyItem, - POLICY_ACTION_GROUPS, - POLICY_ACTIONS, - PolicyActionGroup, -} from 'src/app/lib/cedar-policy-items'; +import { CedarPolicyItem, PolicyAction, PolicyActionGroup, PolicyActionResource } from 'src/app/lib/cedar-policy-items'; +import { actionIcon, actionLabel, actionShortLabel } from 'src/app/lib/permission-display'; +import { UsersService } from 'src/app/services/users.service'; import { ContentLoaderComponent } from '../../ui-components/content-loader/content-loader.component'; export interface AvailableTable { @@ -32,6 +29,8 @@ export interface PolicyGroup { policies: { item: CedarPolicyItem; originalIndex: number }[]; } +const WILDCARD_PREFIXES: PolicyActionResource[] = ['table', 'dashboard', 'panel']; + @Component({ selector: 'app-cedar-policy-list', imports: [ @@ -66,26 +65,28 @@ export class CedarPolicyListComponent { collapsedGroups = new Set(); - private _availableActions = POLICY_ACTIONS; + private _users = inject(UsersService); + + private displayGroups = computed(() => this._buildDisplayGroups()); + private displayActions = computed(() => this.displayGroups().flatMap((g) => g.actions)); - // Computed derived views protected groupedPolicies = computed(() => this._computeGroupedPolicies()); protected addActionGroups = computed(() => this._buildFilteredGroups(-1)); get needsTable(): boolean { - return this._availableActions.find((a) => a.value === this.newAction)?.needsTable ?? false; + return this._needsTable(this.newAction); } get needsDashboard(): boolean { - return this._availableActions.find((a) => a.value === this.newAction)?.needsDashboard ?? false; + return this._needsDashboard(this.newAction); } get editNeedsTable(): boolean { - return this._availableActions.find((a) => a.value === this.editAction)?.needsTable ?? false; + return this._needsTable(this.editAction); } get editNeedsDashboard(): boolean { - return this._availableActions.find((a) => a.value === this.editAction)?.needsDashboard ?? false; + return this._needsDashboard(this.editAction); } protected usedTables = computed(() => { @@ -93,7 +94,7 @@ export class CedarPolicyListComponent { for (const p of this.policies()) { if (p.tableName) { const labels = map.get(p.tableName) || []; - labels.push(this._shortLabels[p.action] || p.action); + labels.push(this.getShortActionLabel(p.action)); map.set(p.tableName, labels); } } @@ -105,7 +106,7 @@ export class CedarPolicyListComponent { for (const p of this.policies()) { if (p.dashboardId) { const labels = map.get(p.dashboardId) || []; - labels.push(this._shortLabels[p.action] || p.action); + labels.push(this.getShortActionLabel(p.action)); map.set(p.dashboardId, labels); } } @@ -133,15 +134,15 @@ export class CedarPolicyListComponent { } getActionIcon(action: string): string { - return this._actionIcons[action] || 'security'; + return actionIcon(action); } getShortActionLabel(action: string): string { - return this._shortLabels[action] || action; + return actionShortLabel(action); } getActionLabel(action: string): string { - return this._availableActions.find((a) => a.value === action)?.label || action; + return actionLabel(action); } getTableDisplayName(tableName: string): string { @@ -253,6 +254,13 @@ export class CedarPolicyListComponent { icon: 'table_chart', colorClass: 'table', }, + { + prefix: 'actionEvent:', + label: 'ActionEvent', + description: 'Custom action event triggers', + icon: 'play_arrow', + colorClass: 'action-event', + }, { prefix: 'dashboard:', label: 'Dashboard', @@ -260,43 +268,50 @@ export class CedarPolicyListComponent { icon: 'dashboard', colorClass: 'dashboard', }, + { + prefix: 'panel:', + label: 'Panel', + description: 'Panel access', + icon: 'view_quilt', + colorClass: 'panel', + }, ]; - private _actionIcons: Record = { - '*': 'shield', - 'connection:read': 'visibility', - 'connection:edit': 'edit', - 'group:read': 'visibility', - 'group:edit': 'settings', - 'table:*': 'shield', - 'table:read': 'visibility', - 'table:add': 'add_circle', - 'table:edit': 'edit', - 'table:delete': 'delete', - 'dashboard:*': 'shield', - 'dashboard:read': 'visibility', - 'dashboard:create': 'add_circle', - 'dashboard:edit': 'edit', - 'dashboard:delete': 'delete', - }; - - private _shortLabels: Record = { - '*': 'Full access', - 'connection:read': 'Read', - 'connection:edit': 'Full access', - 'group:read': 'Read', - 'group:edit': 'Manage', - 'table:*': 'Full access', - 'table:read': 'Read', - 'table:add': 'Add', - 'table:edit': 'Edit', - 'table:delete': 'Delete', - 'dashboard:*': 'Full access', - 'dashboard:read': 'Read', - 'dashboard:create': 'Create', - 'dashboard:edit': 'Edit', - 'dashboard:delete': 'Delete', - }; + private _needsTable(value: string): boolean { + return this._scopeResource(value) === 'table'; + } + + private _needsDashboard(value: string): boolean { + return this._scopeResource(value) === 'dashboard'; + } + + private _scopeResource(value: string): PolicyActionResource | undefined { + if (value === 'dashboard:create') return undefined; + return this._findAction(value)?.resource; + } + + private _findAction(value: string): PolicyAction | undefined { + return this.displayActions().find((a) => a.value === value); + } + + private _isSimpleAction(action: PolicyAction): boolean { + if (action.value === 'dashboard:create') return true; + return action.resource !== 'table' && action.resource !== 'dashboard'; + } + + private _buildDisplayGroups(): PolicyActionGroup[] { + const general: PolicyActionGroup = { group: 'General', actions: [{ value: '*' }] }; + const groups = this._users.availablePermissionGroups().map((g) => this._withWildcardEntry(g)); + return [general, ...groups]; + } + + private _withWildcardEntry(group: PolicyActionGroup): PolicyActionGroup { + if (group.actions.length === 0) return group; + const prefix = group.actions[0].value.split(':')[0] as PolicyActionResource; + if (!WILDCARD_PREFIXES.includes(prefix)) return group; + const wildcard: PolicyAction = { value: `${prefix}:*`, resource: prefix }; + return { ...group, actions: [wildcard, ...group.actions] }; + } private _computeGroupedPolicies(): PolicyGroup[] { const policies = this.policies(); @@ -315,24 +330,27 @@ export class CedarPolicyListComponent { private _buildFilteredGroups(excludeIndex: number): PolicyActionGroup[] { const policies = this.policies(); + const actions = this.displayActions(); const existingSimple = new Set( policies .filter((p, i) => { if (i === excludeIndex) return false; - const def = this._availableActions.find((a) => a.value === p.action); - return def && !def.needsTable && !def.needsDashboard; + const def = actions.find((a) => a.value === p.action); + return def != null && this._isSimpleAction(def); }) .map((p) => p.action), ); - return POLICY_ACTION_GROUPS.map((group) => ({ - ...group, - actions: group.actions.filter((action) => { - if (!action.needsTable && !action.needsDashboard) { - return !existingSimple.has(action.value); - } - return true; - }), - })).filter((group) => group.actions.length > 0); + return this.displayGroups() + .map((group) => ({ + ...group, + actions: group.actions.filter((action) => { + if (this._isSimpleAction(action)) { + return !existingSimple.has(action.value); + } + return true; + }), + })) + .filter((group) => group.actions.length > 0); } } diff --git a/frontend/src/app/lib/cedar-policy-items.ts b/frontend/src/app/lib/cedar-policy-items.ts index e0390e338..95ac3026d 100644 --- a/frontend/src/app/lib/cedar-policy-items.ts +++ b/frontend/src/app/lib/cedar-policy-items.ts @@ -1,5 +1,7 @@ import { AccessLevel, Permissions } from '../models/user'; +export type PolicyActionResource = 'connection' | 'group' | 'table' | 'actionEvent' | 'dashboard' | 'panel'; + export interface CedarPolicyItem { action: string; tableName?: string; @@ -8,9 +10,7 @@ export interface CedarPolicyItem { export interface PolicyAction { value: string; - label: string; - needsTable: boolean; - needsDashboard: boolean; + resource?: PolicyActionResource; } export interface PolicyActionGroup { @@ -18,49 +18,6 @@ export interface PolicyActionGroup { actions: PolicyAction[]; } -export const POLICY_ACTION_GROUPS: PolicyActionGroup[] = [ - { - group: 'General', - actions: [{ value: '*', label: 'Full access (all permissions)', needsTable: false, needsDashboard: false }], - }, - { - group: 'Connection', - actions: [ - { value: 'connection:read', label: 'Connection read', needsTable: false, needsDashboard: false }, - { value: 'connection:edit', label: 'Connection full access', needsTable: false, needsDashboard: false }, - ], - }, - { - group: 'Group', - actions: [ - { value: 'group:read', label: 'Group read', needsTable: false, needsDashboard: false }, - { value: 'group:edit', label: 'Group manage', needsTable: false, needsDashboard: false }, - ], - }, - { - group: 'Table', - actions: [ - { value: 'table:*', label: 'Full table access', needsTable: true, needsDashboard: false }, - { value: 'table:read', label: 'Table read', needsTable: true, needsDashboard: false }, - { value: 'table:add', label: 'Table add', needsTable: true, needsDashboard: false }, - { value: 'table:edit', label: 'Table edit', needsTable: true, needsDashboard: false }, - { value: 'table:delete', label: 'Table delete', needsTable: true, needsDashboard: false }, - ], - }, - { - group: 'Dashboard', - actions: [ - { value: 'dashboard:*', label: 'Full dashboard access', needsTable: false, needsDashboard: true }, - { value: 'dashboard:read', label: 'Dashboard read', needsTable: false, needsDashboard: true }, - { value: 'dashboard:create', label: 'Dashboard create', needsTable: false, needsDashboard: false }, - { value: 'dashboard:edit', label: 'Dashboard edit', needsTable: false, needsDashboard: true }, - { value: 'dashboard:delete', label: 'Dashboard delete', needsTable: false, needsDashboard: true }, - ], - }, -]; - -export const POLICY_ACTIONS: PolicyAction[] = POLICY_ACTION_GROUPS.flatMap((g) => g.actions); - export function permissionsToPolicyItems(permissions: Permissions): CedarPolicyItem[] { const items: CedarPolicyItem[] = []; diff --git a/frontend/src/app/lib/permission-display.spec.ts b/frontend/src/app/lib/permission-display.spec.ts new file mode 100644 index 000000000..a4c14a78d --- /dev/null +++ b/frontend/src/app/lib/permission-display.spec.ts @@ -0,0 +1,80 @@ +import { actionIcon, actionLabel, actionShortLabel, groupNameForAction } from './permission-display'; + +describe('permission-display', () => { + describe('actionLabel', () => { + it('formats simple verbs', () => { + expect(actionLabel('connection:read')).toBe('Connection read'); + expect(actionLabel('connection:edit')).toBe('Connection edit'); + expect(actionLabel('table:add')).toBe('Table add'); + expect(actionLabel('table:delete')).toBe('Table delete'); + }); + + it('preserves acronyms in hyphenated verbs', () => { + expect(actionLabel('table:ai-request')).toBe('Table AI request'); + }); + + it('formats camelCase prefixes', () => { + expect(actionLabel('actionEvent:trigger')).toBe('Action event trigger'); + }); + + it('formats wildcards', () => { + expect(actionLabel('*')).toBe('Full access (all permissions)'); + expect(actionLabel('table:*')).toBe('Full table access'); + expect(actionLabel('dashboard:*')).toBe('Full dashboard access'); + expect(actionLabel('panel:*')).toBe('Full panel access'); + }); + }); + + describe('actionShortLabel', () => { + it('returns verb-only short labels', () => { + expect(actionShortLabel('connection:read')).toBe('Read'); + expect(actionShortLabel('table:edit')).toBe('Edit'); + expect(actionShortLabel('actionEvent:trigger')).toBe('Trigger'); + expect(actionShortLabel('table:ai-request')).toBe('AI request'); + }); + + it('returns "Full access" for wildcards', () => { + expect(actionShortLabel('*')).toBe('Full access'); + expect(actionShortLabel('table:*')).toBe('Full access'); + expect(actionShortLabel('panel:*')).toBe('Full access'); + }); + }); + + describe('groupNameForAction', () => { + it('maps known prefixes', () => { + expect(groupNameForAction('connection:read')).toBe('Connection'); + expect(groupNameForAction('group:edit')).toBe('Group'); + expect(groupNameForAction('table:read')).toBe('Table'); + expect(groupNameForAction('actionEvent:trigger')).toBe('ActionEvent'); + expect(groupNameForAction('dashboard:read')).toBe('Dashboard'); + expect(groupNameForAction('panel:read')).toBe('Panel'); + }); + + it('falls back to capitalized prefix for unknown actions', () => { + expect(groupNameForAction('foo:bar')).toBe('Foo'); + }); + }); + + describe('actionIcon', () => { + it('maps known verbs', () => { + expect(actionIcon('connection:read')).toBe('visibility'); + expect(actionIcon('table:edit')).toBe('edit'); + expect(actionIcon('table:add')).toBe('add_circle'); + expect(actionIcon('dashboard:create')).toBe('add_circle'); + expect(actionIcon('table:delete')).toBe('delete'); + expect(actionIcon('actionEvent:trigger')).toBe('play_arrow'); + expect(actionIcon('connection:diagram')).toBe('schema'); + expect(actionIcon('table:ai-request')).toBe('auto_awesome'); + }); + + it('returns shield for wildcards', () => { + expect(actionIcon('*')).toBe('shield'); + expect(actionIcon('table:*')).toBe('shield'); + expect(actionIcon('dashboard:*')).toBe('shield'); + }); + + it('falls back for unknown verbs', () => { + expect(actionIcon('foo:bar')).toBe('help_outline'); + }); + }); +}); diff --git a/frontend/src/app/lib/permission-display.ts b/frontend/src/app/lib/permission-display.ts new file mode 100644 index 000000000..8d43b5cf7 --- /dev/null +++ b/frontend/src/app/lib/permission-display.ts @@ -0,0 +1,80 @@ +const PREFIX_TO_LABEL: Record = { + connection: 'Connection', + group: 'Group', + table: 'Table', + actionEvent: 'Action event', + dashboard: 'Dashboard', + panel: 'Panel', +}; + +const PREFIX_TO_GROUP_NAME: Record = { + connection: 'Connection', + group: 'Group', + table: 'Table', + actionEvent: 'ActionEvent', + dashboard: 'Dashboard', + panel: 'Panel', +}; + +export const PERMISSION_GROUP_ORDER = ['Connection', 'Group', 'Table', 'ActionEvent', 'Dashboard', 'Panel']; + +export function groupNameForAction(value: string): string { + const prefix = value.split(':')[0]; + return PREFIX_TO_GROUP_NAME[prefix] ?? capitalize(prefix); +} + +const VERB_TO_ICON: Record = { + read: 'visibility', + edit: 'edit', + add: 'add_circle', + create: 'add_circle', + delete: 'delete', + trigger: 'play_arrow', + diagram: 'schema', + 'ai-request': 'auto_awesome', +}; + +// Verb display overrides used inside labels (lowercase context). +// e.g. `actionLabel('table:ai-request')` → 'Table AI request'. +const VERB_DISPLAY_OVERRIDE: Record = { + 'ai-request': 'AI request', +}; + +const FALLBACK_ICON = 'help_outline'; +const WILDCARD_ICON = 'shield'; + +export function actionLabel(value: string): string { + if (value === '*') return 'Full access (all permissions)'; + const [prefix, verb = ''] = value.split(':'); + const prefixLabel = PREFIX_TO_LABEL[prefix] ?? capitalize(prefix); + if (verb === '*') return `Full ${prefixLabel.toLowerCase()} access`; + if (!verb) return prefixLabel; + return `${prefixLabel} ${verbInLabel(verb)}`; +} + +export function actionShortLabel(value: string): string { + if (value === '*') return 'Full access'; + const verb = value.split(':')[1] ?? ''; + if (verb === '*' || verb === '') return 'Full access'; + return capitalize(verbInLabel(verb)); +} + +export function actionIcon(value: string): string { + if (value === '*') return WILDCARD_ICON; + const verb = value.split(':')[1] ?? ''; + if (verb === '*') return WILDCARD_ICON; + return VERB_TO_ICON[verb] ?? FALLBACK_ICON; +} + +// Returns the verb as it should appear inside a sentence-case label +// (e.g. inside "Connection read"). For short labels, capitalize the first letter +// of the result. +function verbInLabel(verb: string): string { + const override = VERB_DISPLAY_OVERRIDE[verb]; + if (override) return override; + return verb.split('-').join(' '); +} + +function capitalize(text: string): string { + return text ? text.charAt(0).toUpperCase() + text.slice(1) : text; +} diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts index d30bb2024..017cdadd1 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -1,6 +1,8 @@ import { HttpClient, HttpResourceRef } from '@angular/common/http'; import { computed, Injectable, inject, signal } from '@angular/core'; import { catchError, EMPTY, map } from 'rxjs'; +import { PolicyAction, PolicyActionGroup } from 'src/app/lib/cedar-policy-items'; +import { groupNameForAction, PERMISSION_GROUP_ORDER } from 'src/app/lib/permission-display'; import { GroupUser, Permissions, UserGroup, UserGroupInfo } from 'src/app/models/user'; import { ApiService } from './api.service'; import { NotificationsService } from './notifications.service'; @@ -44,6 +46,28 @@ export class UsersService { }); public readonly groupsLoading = computed(() => this._groupsResource.isLoading()); + private _availablePermissionsResource: HttpResourceRef<{ actions: PolicyAction[] } | undefined> = this._api.resource<{ + actions: PolicyAction[]; + }>(() => '/permissions/available'); + + public readonly availablePermissions = computed( + () => this._availablePermissionsResource.value()?.actions ?? [], + ); + + public readonly availablePermissionGroups = computed(() => { + const byGroup = new Map(); + for (const action of this.availablePermissions()) { + const groupName = groupNameForAction(action.value); + const list = byGroup.get(groupName); + if (list) list.push(action); + else byGroup.set(groupName, [action]); + } + return PERMISSION_GROUP_ORDER.filter((name) => byGroup.has(name)).map((name) => ({ + group: name, + actions: byGroup.get(name)!, + })); + }); + // Group users - managed imperatively (per-group parallel fetch) private _groupUsers = signal>({}); public readonly groupUsers = this._groupUsers.asReadonly();