From 5ed31bc65780a52b387c01c779dae8243186c0fe Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 22 May 2026 19:47:04 +0000 Subject: [PATCH 1/3] 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) --- .../available-permissions.ds.ts | 31 +++ .../permission/permission-catalog.builder.ts | 181 ++++++++++++++++++ .../permission/permission.controller.ts | 14 ++ .../entities/permission/permission.module.ts | 7 +- .../non-saas-permission-catalog-e2e.test.ts | 100 ++++++++++ .../cedar-policy-list.component.spec.ts | 55 ++++++ .../cedar-policy-list.component.ts | 115 +++++------ frontend/src/app/lib/cedar-policy-items.ts | 50 +---- frontend/src/app/services/users.service.ts | 14 ++ 9 files changed, 457 insertions(+), 110 deletions(-) create mode 100644 backend/src/entities/permission/application/data-structures/available-permissions.ds.ts create mode 100644 backend/src/entities/permission/permission-catalog.builder.ts create mode 100644 backend/test/ava-tests/non-saas-tests/non-saas-permission-catalog-e2e.test.ts 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..bee02fb15 --- /dev/null +++ b/backend/src/entities/permission/application/data-structures/available-permissions.ds.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AvailablePermissionDs { + @ApiProperty() + value: string; + + @ApiProperty() + label: string; + + @ApiProperty() + shortLabel: string; + + @ApiProperty() + icon: string; + + @ApiProperty({ required: false }) + resource?: string; +} + +export class AvailablePermissionGroupDs { + @ApiProperty() + group: string; + + @ApiProperty({ isArray: true, type: AvailablePermissionDs }) + actions: Array; +} + +export class AvailablePermissionsResponseDs { + @ApiProperty({ isArray: true, type: AvailablePermissionGroupDs }) + groups: 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..b0b782f98 --- /dev/null +++ b/backend/src/entities/permission/permission-catalog.builder.ts @@ -0,0 +1,181 @@ +import { CEDAR_SCHEMA } from '../cedar-authorization/cedar-schema.js'; +import { + AvailablePermissionDs, + AvailablePermissionGroupDs, +} from './application/data-structures/available-permissions.ds.js'; + +type ActionMetadataOverride = { + label?: string; + shortLabel?: string; + icon?: string; + resourceOverride?: 'none' | string; +}; + +const NONE_RESOURCE_OVERRIDE = 'none'; + +// Resources for which the UI knows how to render a per-instance picker. +// Actions scoped to a resource not in this set will be exposed without `resource`, +// so they still appear in the list but without a sub-scope selector. +const UI_RENDERABLE_RESOURCES = new Set(['table', 'dashboard']); + +const ACTION_DISPLAY_METADATA: Record = { + 'connection:read': { shortLabel: 'Read', icon: 'visibility' }, + 'connection:edit': { label: 'Connection full access', shortLabel: 'Full access', icon: 'edit' }, + 'connection:diagram': { shortLabel: 'Diagram', icon: 'schema' }, + 'group:read': { shortLabel: 'Read', icon: 'visibility' }, + 'group:edit': { label: 'Group manage', shortLabel: 'Manage', icon: 'settings' }, + 'table:read': { shortLabel: 'Read', icon: 'visibility' }, + 'table:add': { shortLabel: 'Add', icon: 'add_circle' }, + 'table:edit': { shortLabel: 'Edit', icon: 'edit' }, + 'table:delete': { shortLabel: 'Delete', icon: 'delete' }, + 'table:ai-request': { label: 'Table AI request', shortLabel: 'AI request', icon: 'auto_awesome' }, + 'actionEvent:trigger': { label: 'Action event trigger', shortLabel: 'Trigger', icon: 'play_arrow' }, + 'dashboard:read': { shortLabel: 'Read', icon: 'visibility' }, + 'dashboard:create': { shortLabel: 'Create', icon: 'add_circle', resourceOverride: NONE_RESOURCE_OVERRIDE }, + 'dashboard:edit': { shortLabel: 'Edit', icon: 'edit' }, + 'dashboard:delete': { shortLabel: 'Delete', icon: 'delete' }, + 'panel:read': { shortLabel: 'Read', icon: 'visibility' }, + 'panel:create': { shortLabel: 'Create', icon: 'add_circle' }, + 'panel:edit': { shortLabel: 'Edit', icon: 'edit' }, + 'panel:delete': { shortLabel: 'Delete', icon: 'delete' }, +}; + +const PREFIX_TO_GROUP: Record = { + connection: 'Connection', + group: 'Group', + table: 'Table', + actionEvent: 'ActionEvent', + dashboard: 'Dashboard', + panel: 'Panel', +}; + +const GROUP_ORDER = ['General', 'Connection', 'Group', 'Table', 'ActionEvent', 'Dashboard', 'Panel']; + +const WILDCARD_GROUPS = new Set(['Table', 'Dashboard', 'Panel']); + +const WILDCARD_LABELS: Record = { + Table: { label: 'Full table access', shortLabel: 'Full access' }, + Dashboard: { label: 'Full dashboard access', shortLabel: 'Full access' }, + Panel: { label: 'Full panel access', shortLabel: 'Full access' }, +}; + +export function buildPermissionCatalog(): Array { + const schemaActions = (CEDAR_SCHEMA as SchemaShape).RocketAdmin.actions; + const grouped = new Map>(); + + for (const [value, definition] of Object.entries(schemaActions)) { + const action = buildAction(value, definition.appliesTo.resourceTypes); + const groupName = resolveGroupName(value); + appendAction(grouped, groupName, action); + } + + appendAction(grouped, 'General', buildWildcardAllAction()); + + for (const groupName of WILDCARD_GROUPS) { + const actions = grouped.get(groupName); + if (actions && actions.length > 1) { + const prefix = groupName.toLowerCase(); + const resource = actions.find((a) => a.resource)?.resource; + actions.unshift(buildPrefixWildcard(prefix, groupName, resource)); + } + } + + return GROUP_ORDER.filter((name) => grouped.has(name)).map((name) => ({ + group: name, + actions: grouped.get(name)!, + })); +} + +function appendAction( + target: Map>, + groupName: string, + action: AvailablePermissionDs, +): void { + const list = target.get(groupName); + if (list) { + list.push(action); + } else { + target.set(groupName, [action]); + } +} + +function buildAction(value: string, resourceTypes: Array): AvailablePermissionDs { + const meta = ACTION_DISPLAY_METADATA[value] ?? {}; + const derivedResource = deriveResource(resourceTypes); + const resource = + meta.resourceOverride === NONE_RESOURCE_OVERRIDE ? undefined : (meta.resourceOverride ?? derivedResource); + + const action: AvailablePermissionDs = { + value, + label: meta.label ?? autoLabel(value), + shortLabel: meta.shortLabel ?? autoShortLabel(value), + icon: meta.icon ?? 'help_outline', + }; + if (resource) { + action.resource = resource; + } + return action; +} + +function buildWildcardAllAction(): AvailablePermissionDs { + return { + value: '*', + label: 'Full access (all permissions)', + shortLabel: 'Full access', + icon: 'shield', + }; +} + +function buildPrefixWildcard(prefix: string, groupName: string, resource: string | undefined): AvailablePermissionDs { + const labels = WILDCARD_LABELS[groupName] ?? { label: `Full ${prefix} access`, shortLabel: 'Full access' }; + const action: AvailablePermissionDs = { + value: `${prefix}:*`, + label: labels.label, + shortLabel: labels.shortLabel, + icon: 'shield', + }; + if (resource) { + action.resource = resource; + } + return action; +} + +function deriveResource(resourceTypes: Array): string | undefined { + for (const type of resourceTypes) { + const candidate = type.charAt(0).toLowerCase() + type.slice(1); + if (UI_RENDERABLE_RESOURCES.has(candidate)) { + return candidate; + } + } + return undefined; +} + +function resolveGroupName(actionValue: string): string { + const [prefix] = actionValue.split(':'); + return PREFIX_TO_GROUP[prefix] ?? capitalize(prefix); +} + +function autoLabel(value: string): string { + const [prefix, verb = ''] = value.split(':'); + const groupName = PREFIX_TO_GROUP[prefix] ?? capitalize(prefix); + const verbWords = verb.split('-').map(capitalize).join(' '); + return verbWords ? `${groupName} ${verbWords.toLowerCase()}` : groupName; +} + +function autoShortLabel(value: string): string { + const verb = value.split(':')[1] ?? value; + return verb + .split('-') + .map((part, index) => (index === 0 ? capitalize(part) : part)) + .join(' '); +} + +function capitalize(text: string): string { + return text ? text.charAt(0).toUpperCase() + text.slice(1) : text; +} + +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..44c80ab64 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 with display metadata' }) + @ApiResponse({ + status: 200, + description: 'Catalog of permissions grouped by category.', + type: AvailablePermissionsResponseDs, + }) + @Get('permissions/available') + async getAvailablePermissions(): Promise { + return { groups: 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..14fc717b0 --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-permission-catalog-e2e.test.ts @@ -0,0 +1,100 @@ +/* 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 { + groups: Array<{ + group: string; + actions: Array<{ value: string; label: string; shortLabel: string; icon: string; resource?: string }>; + }>; + }; + + t.true(Array.isArray(body.groups)); + t.true(body.groups.length > 0); + + const flatActions = body.groups.flatMap((g) => g.actions); + const values = new Set(flatActions.map((a) => a.value)); + + for (const cedarValue of Object.values(CedarAction)) { + t.true(values.has(cedarValue), `catalog missing CedarAction ${cedarValue}`); + } + + t.true(values.has('*'), 'catalog must include the General * wildcard'); + t.true(values.has('table:*'), 'catalog must include the table:* wildcard'); + t.true(values.has('dashboard:*'), 'catalog must include the dashboard:* wildcard'); + + for (const action of flatActions) { + t.truthy(action.label, `action ${action.value} missing label`); + t.truthy(action.shortLabel, `action ${action.value} missing shortLabel`); + t.truthy(action.icon, `action ${action.value} missing icon`); + } + + const byValue = new Map(flatActions.map((a) => [a.value, a])); + + t.is(byValue.get('connection:edit')!.label, 'Connection full access'); + t.is(byValue.get('table:read')!.resource, 'table'); + t.is(byValue.get('dashboard:read')!.resource, 'dashboard'); + t.is(byValue.get('dashboard:create')!.resource, undefined); + t.is(byValue.get('*')!.resource, undefined); + t.is(byValue.get('connection:read')!.resource, undefined); +}); + +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/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..75eea422f 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,55 @@ +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: 'General', + actions: [{ value: '*', label: 'Full access (all permissions)', shortLabel: 'Full access', icon: 'shield' }], + }, + { + group: 'Connection', + actions: [ + { value: 'connection:read', label: 'Connection read', shortLabel: 'Read', icon: 'visibility' }, + { value: 'connection:edit', label: 'Connection full access', shortLabel: 'Full access', icon: 'edit' }, + ], + }, + { + group: 'Group', + actions: [ + { value: 'group:read', label: 'Group read', shortLabel: 'Read', icon: 'visibility' }, + { value: 'group:edit', label: 'Group manage', shortLabel: 'Manage', icon: 'settings' }, + ], + }, + { + group: 'Table', + actions: [ + { value: 'table:*', label: 'Full table access', shortLabel: 'Full access', icon: 'shield', resource: 'table' }, + { value: 'table:read', label: 'Table read', shortLabel: 'Read', icon: 'visibility', resource: 'table' }, + { value: 'table:edit', label: 'Table edit', shortLabel: 'Edit', icon: 'edit', resource: 'table' }, + ], + }, + { + group: 'Dashboard', + actions: [ + { + value: 'dashboard:read', + label: 'Dashboard read', + shortLabel: 'Read', + icon: 'visibility', + resource: 'dashboard', + }, + { value: 'dashboard:edit', label: 'Dashboard edit', shortLabel: 'Edit', icon: 'edit', resource: 'dashboard' }, + ], + }, +]; + +const flatActions: PolicyAction[] = fixtureGroups.flatMap((g) => g.actions); + describe('CedarPolicyListComponent', () => { let component: CedarPolicyListComponent; let fixture: ComponentFixture; @@ -18,8 +65,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); 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..8e33d6362 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,13 @@ 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 } from 'src/app/lib/cedar-policy-items'; +import { UsersService } from 'src/app/services/users.service'; import { ContentLoaderComponent } from '../../ui-components/content-loader/content-loader.component'; export interface AvailableTable { @@ -66,26 +62,28 @@ export class CedarPolicyListComponent { collapsedGroups = new Set(); - private _availableActions = POLICY_ACTIONS; + private _users = inject(UsersService); + + private availableActions = computed(() => this._users.availablePermissions()); + private availableGroups = computed(() => this._users.availablePermissionGroups()); - // 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._actionResource(this.newAction) === 'table'; } get needsDashboard(): boolean { - return this._availableActions.find((a) => a.value === this.newAction)?.needsDashboard ?? false; + return this._actionResource(this.newAction) === 'dashboard'; } get editNeedsTable(): boolean { - return this._availableActions.find((a) => a.value === this.editAction)?.needsTable ?? false; + return this._actionResource(this.editAction) === 'table'; } get editNeedsDashboard(): boolean { - return this._availableActions.find((a) => a.value === this.editAction)?.needsDashboard ?? false; + return this._actionResource(this.editAction) === 'dashboard'; } protected usedTables = computed(() => { @@ -93,7 +91,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 +103,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 +131,15 @@ export class CedarPolicyListComponent { } getActionIcon(action: string): string { - return this._actionIcons[action] || 'security'; + return this._findAction(action)?.icon || 'help_outline'; } getShortActionLabel(action: string): string { - return this._shortLabels[action] || action; + return this._findAction(action)?.shortLabel || action; } getActionLabel(action: string): string { - return this._availableActions.find((a) => a.value === action)?.label || action; + return this._findAction(action)?.label || action; } getTableDisplayName(tableName: string): string { @@ -253,6 +251,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 +265,22 @@ 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 _findAction(value: string): PolicyAction | undefined { + return this.availableActions().find((a) => a.value === value); + } + + private _actionResource(value: string): PolicyAction['resource'] | undefined { + return this._findAction(value)?.resource; + } private _computeGroupedPolicies(): PolicyGroup[] { const policies = this.policies(); @@ -315,24 +299,27 @@ export class CedarPolicyListComponent { private _buildFilteredGroups(excludeIndex: number): PolicyActionGroup[] { const policies = this.policies(); + const actions = this.availableActions(); 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 && def.resource == null; }) .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.availableGroups() + .map((group) => ({ + ...group, + actions: group.actions.filter((action) => { + if (action.resource == null) { + 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..c1d10e577 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 = 'table' | 'dashboard' | 'panel' | 'actionEvent'; + export interface CedarPolicyItem { action: string; tableName?: string; @@ -9,8 +11,9 @@ export interface CedarPolicyItem { export interface PolicyAction { value: string; label: string; - needsTable: boolean; - needsDashboard: boolean; + shortLabel: string; + icon: string; + resource?: PolicyActionResource; } export interface PolicyActionGroup { @@ -18,49 +21,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/services/users.service.ts b/frontend/src/app/services/users.service.ts index d30bb2024..408874ade 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -1,6 +1,7 @@ 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 { GroupUser, Permissions, UserGroup, UserGroupInfo } from 'src/app/models/user'; import { ApiService } from './api.service'; import { NotificationsService } from './notifications.service'; @@ -44,6 +45,19 @@ export class UsersService { }); public readonly groupsLoading = computed(() => this._groupsResource.isLoading()); + private _availablePermissionsResource: HttpResourceRef<{ groups: PolicyActionGroup[] } | undefined> = + this._api.resource<{ + groups: PolicyActionGroup[]; + }>(() => '/permissions/available'); + + public readonly availablePermissionGroups = computed( + () => this._availablePermissionsResource.value()?.groups ?? [], + ); + + public readonly availablePermissions = computed(() => + this.availablePermissionGroups().flatMap((g) => g.actions), + ); + // Group users - managed imperatively (per-group parallel fetch) private _groupUsers = signal>({}); public readonly groupUsers = this._groupUsers.asReadonly(); From 02c478668fa028c03727cc86e2287c6c2ae97ceb Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 22 May 2026 20:29:49 +0000 Subject: [PATCH 2/3] 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) --- .../available-permissions.ds.ts | 9 -- .../permission/permission-catalog.builder.ts | 144 ++---------------- .../non-saas-permission-catalog-e2e.test.ts | 29 ++-- .../cedar-policy-list.component.html | 4 +- .../cedar-policy-list.component.spec.ts | 55 ++++--- .../cedar-policy-list.component.ts | 65 +++++--- frontend/src/app/lib/cedar-policy-items.ts | 5 +- .../src/app/lib/permission-display.spec.ts | 65 ++++++++ frontend/src/app/lib/permission-display.ts | 64 ++++++++ 9 files changed, 244 insertions(+), 196 deletions(-) create mode 100644 frontend/src/app/lib/permission-display.spec.ts create mode 100644 frontend/src/app/lib/permission-display.ts 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 index bee02fb15..2b20de555 100644 --- a/backend/src/entities/permission/application/data-structures/available-permissions.ds.ts +++ b/backend/src/entities/permission/application/data-structures/available-permissions.ds.ts @@ -4,15 +4,6 @@ export class AvailablePermissionDs { @ApiProperty() value: string; - @ApiProperty() - label: string; - - @ApiProperty() - shortLabel: string; - - @ApiProperty() - icon: string; - @ApiProperty({ required: false }) resource?: string; } diff --git a/backend/src/entities/permission/permission-catalog.builder.ts b/backend/src/entities/permission/permission-catalog.builder.ts index b0b782f98..9ba28d318 100644 --- a/backend/src/entities/permission/permission-catalog.builder.ts +++ b/backend/src/entities/permission/permission-catalog.builder.ts @@ -4,42 +4,6 @@ import { AvailablePermissionGroupDs, } from './application/data-structures/available-permissions.ds.js'; -type ActionMetadataOverride = { - label?: string; - shortLabel?: string; - icon?: string; - resourceOverride?: 'none' | string; -}; - -const NONE_RESOURCE_OVERRIDE = 'none'; - -// Resources for which the UI knows how to render a per-instance picker. -// Actions scoped to a resource not in this set will be exposed without `resource`, -// so they still appear in the list but without a sub-scope selector. -const UI_RENDERABLE_RESOURCES = new Set(['table', 'dashboard']); - -const ACTION_DISPLAY_METADATA: Record = { - 'connection:read': { shortLabel: 'Read', icon: 'visibility' }, - 'connection:edit': { label: 'Connection full access', shortLabel: 'Full access', icon: 'edit' }, - 'connection:diagram': { shortLabel: 'Diagram', icon: 'schema' }, - 'group:read': { shortLabel: 'Read', icon: 'visibility' }, - 'group:edit': { label: 'Group manage', shortLabel: 'Manage', icon: 'settings' }, - 'table:read': { shortLabel: 'Read', icon: 'visibility' }, - 'table:add': { shortLabel: 'Add', icon: 'add_circle' }, - 'table:edit': { shortLabel: 'Edit', icon: 'edit' }, - 'table:delete': { shortLabel: 'Delete', icon: 'delete' }, - 'table:ai-request': { label: 'Table AI request', shortLabel: 'AI request', icon: 'auto_awesome' }, - 'actionEvent:trigger': { label: 'Action event trigger', shortLabel: 'Trigger', icon: 'play_arrow' }, - 'dashboard:read': { shortLabel: 'Read', icon: 'visibility' }, - 'dashboard:create': { shortLabel: 'Create', icon: 'add_circle', resourceOverride: NONE_RESOURCE_OVERRIDE }, - 'dashboard:edit': { shortLabel: 'Edit', icon: 'edit' }, - 'dashboard:delete': { shortLabel: 'Delete', icon: 'delete' }, - 'panel:read': { shortLabel: 'Read', icon: 'visibility' }, - 'panel:create': { shortLabel: 'Create', icon: 'add_circle' }, - 'panel:edit': { shortLabel: 'Edit', icon: 'edit' }, - 'panel:delete': { shortLabel: 'Delete', icon: 'delete' }, -}; - const PREFIX_TO_GROUP: Record = { connection: 'Connection', group: 'Group', @@ -49,15 +13,7 @@ const PREFIX_TO_GROUP: Record = { panel: 'Panel', }; -const GROUP_ORDER = ['General', 'Connection', 'Group', 'Table', 'ActionEvent', 'Dashboard', 'Panel']; - -const WILDCARD_GROUPS = new Set(['Table', 'Dashboard', 'Panel']); - -const WILDCARD_LABELS: Record = { - Table: { label: 'Full table access', shortLabel: 'Full access' }, - Dashboard: { label: 'Full dashboard access', shortLabel: 'Full access' }, - Panel: { label: 'Full panel access', shortLabel: 'Full access' }, -}; +const GROUP_ORDER = ['Connection', 'Group', 'Table', 'ActionEvent', 'Dashboard', 'Panel']; export function buildPermissionCatalog(): Array { const schemaActions = (CEDAR_SCHEMA as SchemaShape).RocketAdmin.actions; @@ -66,17 +22,11 @@ export function buildPermissionCatalog(): Array { for (const [value, definition] of Object.entries(schemaActions)) { const action = buildAction(value, definition.appliesTo.resourceTypes); const groupName = resolveGroupName(value); - appendAction(grouped, groupName, action); - } - - appendAction(grouped, 'General', buildWildcardAllAction()); - - for (const groupName of WILDCARD_GROUPS) { - const actions = grouped.get(groupName); - if (actions && actions.length > 1) { - const prefix = groupName.toLowerCase(); - const resource = actions.find((a) => a.resource)?.resource; - actions.unshift(buildPrefixWildcard(prefix, groupName, resource)); + const list = grouped.get(groupName); + if (list) { + list.push(action); + } else { + grouped.set(groupName, [action]); } } @@ -86,54 +36,9 @@ export function buildPermissionCatalog(): Array { })); } -function appendAction( - target: Map>, - groupName: string, - action: AvailablePermissionDs, -): void { - const list = target.get(groupName); - if (list) { - list.push(action); - } else { - target.set(groupName, [action]); - } -} - function buildAction(value: string, resourceTypes: Array): AvailablePermissionDs { - const meta = ACTION_DISPLAY_METADATA[value] ?? {}; - const derivedResource = deriveResource(resourceTypes); - const resource = - meta.resourceOverride === NONE_RESOURCE_OVERRIDE ? undefined : (meta.resourceOverride ?? derivedResource); - - const action: AvailablePermissionDs = { - value, - label: meta.label ?? autoLabel(value), - shortLabel: meta.shortLabel ?? autoShortLabel(value), - icon: meta.icon ?? 'help_outline', - }; - if (resource) { - action.resource = resource; - } - return action; -} - -function buildWildcardAllAction(): AvailablePermissionDs { - return { - value: '*', - label: 'Full access (all permissions)', - shortLabel: 'Full access', - icon: 'shield', - }; -} - -function buildPrefixWildcard(prefix: string, groupName: string, resource: string | undefined): AvailablePermissionDs { - const labels = WILDCARD_LABELS[groupName] ?? { label: `Full ${prefix} access`, shortLabel: 'Full access' }; - const action: AvailablePermissionDs = { - value: `${prefix}:*`, - label: labels.label, - shortLabel: labels.shortLabel, - icon: 'shield', - }; + const action: AvailablePermissionDs = { value }; + const resource = deriveResource(resourceTypes); if (resource) { action.resource = resource; } @@ -141,37 +46,14 @@ function buildPrefixWildcard(prefix: string, groupName: string, resource: string } function deriveResource(resourceTypes: Array): string | undefined { - for (const type of resourceTypes) { - const candidate = type.charAt(0).toLowerCase() + type.slice(1); - if (UI_RENDERABLE_RESOURCES.has(candidate)) { - return candidate; - } - } - return undefined; + const first = resourceTypes[0]; + if (!first) return undefined; + return first.charAt(0).toLowerCase() + first.slice(1); } function resolveGroupName(actionValue: string): string { - const [prefix] = actionValue.split(':'); - return PREFIX_TO_GROUP[prefix] ?? capitalize(prefix); -} - -function autoLabel(value: string): string { - const [prefix, verb = ''] = value.split(':'); - const groupName = PREFIX_TO_GROUP[prefix] ?? capitalize(prefix); - const verbWords = verb.split('-').map(capitalize).join(' '); - return verbWords ? `${groupName} ${verbWords.toLowerCase()}` : groupName; -} - -function autoShortLabel(value: string): string { - const verb = value.split(':')[1] ?? value; - return verb - .split('-') - .map((part, index) => (index === 0 ? capitalize(part) : part)) - .join(' '); -} - -function capitalize(text: string): string { - return text ? text.charAt(0).toUpperCase() + text.slice(1) : text; + const prefix = actionValue.split(':')[0]; + return PREFIX_TO_GROUP[prefix] ?? prefix.charAt(0).toUpperCase() + prefix.slice(1); } type SchemaShape = { 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 index 14fc717b0..c3f1fe260 100644 --- 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 @@ -59,7 +59,7 @@ test.serial('GET /permissions/available returns catalog covering every CedarActi const body = response.body as { groups: Array<{ group: string; - actions: Array<{ value: string; label: string; shortLabel: string; icon: string; resource?: string }>; + actions: Array<{ value: string; resource?: string }>; }>; }; @@ -73,24 +73,25 @@ test.serial('GET /permissions/available returns catalog covering every CedarActi t.true(values.has(cedarValue), `catalog missing CedarAction ${cedarValue}`); } - t.true(values.has('*'), 'catalog must include the General * wildcard'); - t.true(values.has('table:*'), 'catalog must include the table:* wildcard'); - t.true(values.has('dashboard:*'), 'catalog must include the dashboard:* wildcard'); - - for (const action of flatActions) { - t.truthy(action.label, `action ${action.value} missing label`); - t.truthy(action.shortLabel, `action ${action.value} missing shortLabel`); - t.truthy(action.icon, `action ${action.value} missing icon`); - } + 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(flatActions.map((a) => [a.value, a])); - t.is(byValue.get('connection:edit')!.label, 'Connection full access'); + 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, undefined); - t.is(byValue.get('*')!.resource, undefined); - t.is(byValue.get('connection:read')!.resource, undefined); + t.is(byValue.get('dashboard:create')!.resource, 'dashboard'); + t.is(byValue.get('panel:read')!.resource, 'panel'); + + for (const action of flatActions) { + 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) => { 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 75eea422f..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 @@ -7,43 +7,33 @@ import { UsersService } from 'src/app/services/users.service'; import { CedarPolicyListComponent } from './cedar-policy-list.component'; const fixtureGroups: PolicyActionGroup[] = [ - { - group: 'General', - actions: [{ value: '*', label: 'Full access (all permissions)', shortLabel: 'Full access', icon: 'shield' }], - }, { group: 'Connection', actions: [ - { value: 'connection:read', label: 'Connection read', shortLabel: 'Read', icon: 'visibility' }, - { value: 'connection:edit', label: 'Connection full access', shortLabel: 'Full access', icon: 'edit' }, + { value: 'connection:read', resource: 'connection' }, + { value: 'connection:edit', resource: 'connection' }, ], }, { group: 'Group', actions: [ - { value: 'group:read', label: 'Group read', shortLabel: 'Read', icon: 'visibility' }, - { value: 'group:edit', label: 'Group manage', shortLabel: 'Manage', icon: 'settings' }, + { value: 'group:read', resource: 'group' }, + { value: 'group:edit', resource: 'group' }, ], }, { group: 'Table', actions: [ - { value: 'table:*', label: 'Full table access', shortLabel: 'Full access', icon: 'shield', resource: 'table' }, - { value: 'table:read', label: 'Table read', shortLabel: 'Read', icon: 'visibility', resource: 'table' }, - { value: 'table:edit', label: 'Table edit', shortLabel: 'Edit', icon: 'edit', resource: 'table' }, + { value: 'table:read', resource: 'table' }, + { value: 'table:edit', resource: 'table' }, ], }, { group: 'Dashboard', actions: [ - { - value: 'dashboard:read', - label: 'Dashboard read', - shortLabel: 'Read', - icon: 'visibility', - resource: 'dashboard', - }, - { value: 'dashboard:edit', label: 'Dashboard edit', shortLabel: 'Edit', icon: 'edit', resource: 'dashboard' }, + { value: 'dashboard:read', resource: 'dashboard' }, + { value: 'dashboard:create', resource: 'dashboard' }, + { value: 'dashboard:edit', resource: 'dashboard' }, ], }, ]; @@ -265,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 8e33d6362..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 @@ -6,7 +6,8 @@ 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, PolicyAction, 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'; @@ -28,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: [ @@ -64,26 +67,26 @@ export class CedarPolicyListComponent { private _users = inject(UsersService); - private availableActions = computed(() => this._users.availablePermissions()); - private availableGroups = computed(() => this._users.availablePermissionGroups()); + private displayGroups = computed(() => this._buildDisplayGroups()); + private displayActions = computed(() => this.displayGroups().flatMap((g) => g.actions)); protected groupedPolicies = computed(() => this._computeGroupedPolicies()); protected addActionGroups = computed(() => this._buildFilteredGroups(-1)); get needsTable(): boolean { - return this._actionResource(this.newAction) === 'table'; + return this._needsTable(this.newAction); } get needsDashboard(): boolean { - return this._actionResource(this.newAction) === 'dashboard'; + return this._needsDashboard(this.newAction); } get editNeedsTable(): boolean { - return this._actionResource(this.editAction) === 'table'; + return this._needsTable(this.editAction); } get editNeedsDashboard(): boolean { - return this._actionResource(this.editAction) === 'dashboard'; + return this._needsDashboard(this.editAction); } protected usedTables = computed(() => { @@ -131,15 +134,15 @@ export class CedarPolicyListComponent { } getActionIcon(action: string): string { - return this._findAction(action)?.icon || 'help_outline'; + return actionIcon(action); } getShortActionLabel(action: string): string { - return this._findAction(action)?.shortLabel || action; + return actionShortLabel(action); } getActionLabel(action: string): string { - return this._findAction(action)?.label || action; + return actionLabel(action); } getTableDisplayName(tableName: string): string { @@ -274,14 +277,42 @@ export class CedarPolicyListComponent { }, ]; - private _findAction(value: string): PolicyAction | undefined { - return this.availableActions().find((a) => a.value === value); + private _needsTable(value: string): boolean { + return this._scopeResource(value) === 'table'; + } + + private _needsDashboard(value: string): boolean { + return this._scopeResource(value) === 'dashboard'; } - private _actionResource(value: string): PolicyAction['resource'] | undefined { + 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(); return this._groupConfig @@ -299,22 +330,22 @@ export class CedarPolicyListComponent { private _buildFilteredGroups(excludeIndex: number): PolicyActionGroup[] { const policies = this.policies(); - const actions = this.availableActions(); + const actions = this.displayActions(); const existingSimple = new Set( policies .filter((p, i) => { if (i === excludeIndex) return false; const def = actions.find((a) => a.value === p.action); - return def != null && def.resource == null; + return def != null && this._isSimpleAction(def); }) .map((p) => p.action), ); - return this.availableGroups() + return this.displayGroups() .map((group) => ({ ...group, actions: group.actions.filter((action) => { - if (action.resource == null) { + if (this._isSimpleAction(action)) { return !existingSimple.has(action.value); } return true; diff --git a/frontend/src/app/lib/cedar-policy-items.ts b/frontend/src/app/lib/cedar-policy-items.ts index c1d10e577..95ac3026d 100644 --- a/frontend/src/app/lib/cedar-policy-items.ts +++ b/frontend/src/app/lib/cedar-policy-items.ts @@ -1,6 +1,6 @@ import { AccessLevel, Permissions } from '../models/user'; -export type PolicyActionResource = 'table' | 'dashboard' | 'panel' | 'actionEvent'; +export type PolicyActionResource = 'connection' | 'group' | 'table' | 'actionEvent' | 'dashboard' | 'panel'; export interface CedarPolicyItem { action: string; @@ -10,9 +10,6 @@ export interface CedarPolicyItem { export interface PolicyAction { value: string; - label: string; - shortLabel: string; - icon: string; resource?: PolicyActionResource; } 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..09104f26e --- /dev/null +++ b/frontend/src/app/lib/permission-display.spec.ts @@ -0,0 +1,65 @@ +import { actionIcon, actionLabel, actionShortLabel } 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('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..b3a9444fc --- /dev/null +++ b/frontend/src/app/lib/permission-display.ts @@ -0,0 +1,64 @@ +const PREFIX_TO_LABEL: Record = { + connection: 'Connection', + group: 'Group', + table: 'Table', + actionEvent: 'Action event', + dashboard: 'Dashboard', + panel: 'Panel', +}; + +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; +} From 15390c413b8899c170d498ef50e805a08aee5ceb Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 22 May 2026 20:35:29 +0000 Subject: [PATCH 3/3] 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) --- .../available-permissions.ds.ts | 10 +---- .../permission/permission-catalog.builder.ts | 43 +++---------------- .../permission/permission.controller.ts | 6 +-- .../non-saas-permission-catalog-e2e.test.ts | 16 +++---- .../src/app/lib/permission-display.spec.ts | 17 +++++++- frontend/src/app/lib/permission-display.ts | 16 +++++++ frontend/src/app/services/users.service.ts | 28 ++++++++---- 7 files changed, 66 insertions(+), 70 deletions(-) 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 index 2b20de555..d8a86fd98 100644 --- a/backend/src/entities/permission/application/data-structures/available-permissions.ds.ts +++ b/backend/src/entities/permission/application/data-structures/available-permissions.ds.ts @@ -8,15 +8,7 @@ export class AvailablePermissionDs { resource?: string; } -export class AvailablePermissionGroupDs { - @ApiProperty() - group: string; - +export class AvailablePermissionsResponseDs { @ApiProperty({ isArray: true, type: AvailablePermissionDs }) actions: Array; } - -export class AvailablePermissionsResponseDs { - @ApiProperty({ isArray: true, type: AvailablePermissionGroupDs }) - groups: Array; -} diff --git a/backend/src/entities/permission/permission-catalog.builder.ts b/backend/src/entities/permission/permission-catalog.builder.ts index 9ba28d318..3cb54073d 100644 --- a/backend/src/entities/permission/permission-catalog.builder.ts +++ b/backend/src/entities/permission/permission-catalog.builder.ts @@ -1,39 +1,11 @@ import { CEDAR_SCHEMA } from '../cedar-authorization/cedar-schema.js'; -import { - AvailablePermissionDs, - AvailablePermissionGroupDs, -} from './application/data-structures/available-permissions.ds.js'; +import { AvailablePermissionDs } from './application/data-structures/available-permissions.ds.js'; -const PREFIX_TO_GROUP: Record = { - connection: 'Connection', - group: 'Group', - table: 'Table', - actionEvent: 'ActionEvent', - dashboard: 'Dashboard', - panel: 'Panel', -}; - -const GROUP_ORDER = ['Connection', 'Group', 'Table', 'ActionEvent', 'Dashboard', 'Panel']; - -export function buildPermissionCatalog(): Array { +export function buildPermissionCatalog(): Array { const schemaActions = (CEDAR_SCHEMA as SchemaShape).RocketAdmin.actions; - const grouped = new Map>(); - - for (const [value, definition] of Object.entries(schemaActions)) { - const action = buildAction(value, definition.appliesTo.resourceTypes); - const groupName = resolveGroupName(value); - const list = grouped.get(groupName); - if (list) { - list.push(action); - } else { - grouped.set(groupName, [action]); - } - } - - return GROUP_ORDER.filter((name) => grouped.has(name)).map((name) => ({ - group: name, - actions: grouped.get(name)!, - })); + return Object.entries(schemaActions).map(([value, definition]) => + buildAction(value, definition.appliesTo.resourceTypes), + ); } function buildAction(value: string, resourceTypes: Array): AvailablePermissionDs { @@ -51,11 +23,6 @@ function deriveResource(resourceTypes: Array): string | undefined { return first.charAt(0).toLowerCase() + first.slice(1); } -function resolveGroupName(actionValue: string): string { - const prefix = actionValue.split(':')[0]; - return PREFIX_TO_GROUP[prefix] ?? prefix.charAt(0).toUpperCase() + prefix.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 44c80ab64..3af1f4f57 100644 --- a/backend/src/entities/permission/permission.controller.ts +++ b/backend/src/entities/permission/permission.controller.ts @@ -37,15 +37,15 @@ export class PermissionController { private readonly createOrUpdatePermissionsUseCase: ICreateOrUpdatePermissions, ) {} - @ApiOperation({ summary: 'List available permissions with display metadata' }) + @ApiOperation({ summary: 'List available permissions derived from the Cedar schema' }) @ApiResponse({ status: 200, - description: 'Catalog of permissions grouped by category.', + description: 'Flat list of permissions with their resource scope.', type: AvailablePermissionsResponseDs, }) @Get('permissions/available') async getAvailablePermissions(): Promise { - return { groups: buildPermissionCatalog() }; + return { actions: buildPermissionCatalog() }; } @ApiOperation({ summary: 'Create or update permissions in group' }) 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 index c3f1fe260..13639acf5 100644 --- 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 @@ -57,17 +57,13 @@ test.serial('GET /permissions/available returns catalog covering every CedarActi t.is(response.status, 200); const body = response.body as { - groups: Array<{ - group: string; - actions: Array<{ value: string; resource?: string }>; - }>; + actions: Array<{ value: string; resource?: string }>; }; - t.true(Array.isArray(body.groups)); - t.true(body.groups.length > 0); + t.true(Array.isArray(body.actions)); + t.true(body.actions.length > 0); - const flatActions = body.groups.flatMap((g) => g.actions); - const values = new Set(flatActions.map((a) => a.value)); + 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}`); @@ -77,7 +73,7 @@ test.serial('GET /permissions/available returns catalog covering every CedarActi 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(flatActions.map((a) => [a.value, a])); + 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'); @@ -87,7 +83,7 @@ test.serial('GET /permissions/available returns catalog covering every CedarActi t.is(byValue.get('dashboard:create')!.resource, 'dashboard'); t.is(byValue.get('panel:read')!.resource, 'panel'); - for (const action of flatActions) { + 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`); diff --git a/frontend/src/app/lib/permission-display.spec.ts b/frontend/src/app/lib/permission-display.spec.ts index 09104f26e..a4c14a78d 100644 --- a/frontend/src/app/lib/permission-display.spec.ts +++ b/frontend/src/app/lib/permission-display.spec.ts @@ -1,4 +1,4 @@ -import { actionIcon, actionLabel, actionShortLabel } from './permission-display'; +import { actionIcon, actionLabel, actionShortLabel, groupNameForAction } from './permission-display'; describe('permission-display', () => { describe('actionLabel', () => { @@ -40,6 +40,21 @@ describe('permission-display', () => { }); }); + 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'); diff --git a/frontend/src/app/lib/permission-display.ts b/frontend/src/app/lib/permission-display.ts index b3a9444fc..8d43b5cf7 100644 --- a/frontend/src/app/lib/permission-display.ts +++ b/frontend/src/app/lib/permission-display.ts @@ -7,6 +7,22 @@ const PREFIX_TO_LABEL: Record = { 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', diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts index 408874ade..017cdadd1 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -2,6 +2,7 @@ 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'; @@ -45,18 +46,27 @@ export class UsersService { }); public readonly groupsLoading = computed(() => this._groupsResource.isLoading()); - private _availablePermissionsResource: HttpResourceRef<{ groups: PolicyActionGroup[] } | undefined> = - this._api.resource<{ - groups: PolicyActionGroup[]; - }>(() => '/permissions/available'); + private _availablePermissionsResource: HttpResourceRef<{ actions: PolicyAction[] } | undefined> = this._api.resource<{ + actions: PolicyAction[]; + }>(() => '/permissions/available'); - public readonly availablePermissionGroups = computed( - () => this._availablePermissionsResource.value()?.groups ?? [], + public readonly availablePermissions = computed( + () => this._availablePermissionsResource.value()?.actions ?? [], ); - public readonly availablePermissions = computed(() => - this.availablePermissionGroups().flatMap((g) => g.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>({});