From f88798983287339ec262310dd34a0877023de36e Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Thu, 9 Apr 2026 12:55:33 +0000 Subject: [PATCH] Add panel permissions and guards, update authorization logic, and enhance tests - Introduced new panel-related actions in CedarAction and CedarResourceType enums. - Updated CedarValidationRequest to include panelId for validation. - Enhanced CedarAuthorizationService to handle panel permissions. - Modified buildCedarEntities to include panel entities. - Updated policy generation and parsing to support panel permissions. - Created PanelEditGuard and PanelReadGuard for panel access control. - Updated panel controller to use new guards for CRUD operations. - Enhanced FindAllPanels use case to check permissions before returning results. - Added comprehensive end-to-end tests for panel permissions, ensuring correct access control. --- .../cedar-authorization/cedar-action-map.ts | 6 + .../cedar-authorization.service.ts | 29 +- .../cedar-entity-builder.ts | 9 + .../cedar-policy-generator.ts | 40 +++ .../cedar-policy-parser.ts | 60 ++++ .../cedar-authorization/cedar-schema.ts | 33 ++ .../permission/permission.interface.ts | 13 + .../panel-position.controller.ts | 7 +- .../data-structures/find-all-panels.ds.ts | 1 + .../visualizations/panel/panel.controller.ts | 17 +- .../use-cases/find-all-panels.use.case.ts | 18 +- backend/src/guards/panel-edit.guard.ts | 63 ++++ backend/src/guards/panel-read.guard.ts | 74 ++++ .../saas-dashboard-permissions-e2e.test.ts | 324 ++++++++++++++++++ 14 files changed, 678 insertions(+), 16 deletions(-) create mode 100644 backend/src/guards/panel-edit.guard.ts create mode 100644 backend/src/guards/panel-read.guard.ts diff --git a/backend/src/entities/cedar-authorization/cedar-action-map.ts b/backend/src/entities/cedar-authorization/cedar-action-map.ts index 2794c7b9b..cbea8ebb5 100644 --- a/backend/src/entities/cedar-authorization/cedar-action-map.ts +++ b/backend/src/entities/cedar-authorization/cedar-action-map.ts @@ -11,6 +11,10 @@ export enum CedarAction { DashboardCreate = 'dashboard:create', DashboardEdit = 'dashboard:edit', DashboardDelete = 'dashboard:delete', + PanelRead = 'panel:read', + PanelCreate = 'panel:create', + PanelEdit = 'panel:edit', + PanelDelete = 'panel:delete', } export enum CedarResourceType { @@ -18,6 +22,7 @@ export enum CedarResourceType { Group = 'RocketAdmin::Group', Table = 'RocketAdmin::Table', Dashboard = 'RocketAdmin::Dashboard', + Panel = 'RocketAdmin::Panel', } export const CEDAR_ACTION_TYPE = 'RocketAdmin::Action'; @@ -31,4 +36,5 @@ export interface CedarValidationRequest { groupId?: string; tableName?: string; dashboardId?: string; + panelId?: string; } diff --git a/backend/src/entities/cedar-authorization/cedar-authorization.service.ts b/backend/src/entities/cedar-authorization/cedar-authorization.service.ts index 7edce909b..013d8d468 100644 --- a/backend/src/entities/cedar-authorization/cedar-authorization.service.ts +++ b/backend/src/entities/cedar-authorization/cedar-authorization.service.ts @@ -34,7 +34,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On } async validate(request: CedarValidationRequest): Promise { - const { userId, action, groupId, tableName, dashboardId } = request; + const { userId, action, groupId, tableName, dashboardId, panelId } = request; let { connectionId } = request; const actionPrefix = action.split(':')[0]; @@ -61,13 +61,20 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On const needsSentinel = action === CedarAction.DashboardCreate || !dashboardId; const effectiveDashboardId = needsSentinel ? '__new__' : dashboardId; resourceId = `${connectionId}/${effectiveDashboardId}`; - return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, effectiveDashboardId); + return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, effectiveDashboardId, undefined); + } + case 'panel': { + resourceType = CedarResourceType.Panel; + const needsSentinel = action === CedarAction.PanelCreate || !panelId; + const effectivePanelId = needsSentinel ? '__new__' : panelId; + resourceId = `${connectionId}/${effectivePanelId}`; + return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, undefined, effectivePanelId); } default: return false; } - return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, dashboardId); + return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, dashboardId, undefined); } invalidatePolicyCacheForConnection(connectionId: string): void { @@ -169,6 +176,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On resourceId: string, tableName?: string, dashboardId?: string, + panelId?: string, ): Promise { await this.assertUserNotSuspended(userId); @@ -178,7 +186,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On const groupPolicies = this.loadPoliciesPerGroup(userGroups); if (groupPolicies.length === 0) return false; - const entities = buildCedarEntities(userId, userGroups, connectionId, tableName, dashboardId); + const entities = buildCedarEntities(userId, userGroups, connectionId, tableName, dashboardId, panelId); for (const policy of groupPolicies) { const call = { @@ -303,6 +311,19 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On ); } } + + const panelResourceIds = [...cedarPolicy.matchAll(/resource\s*==\s*RocketAdmin::Panel::"([^"]+)"/g)].map( + (m) => m[1], + ); + + for (const panelRef of panelResourceIds) { + if (!panelRef.startsWith(`${connectionId}/`)) { + throw new HttpException( + { message: Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION }, + HttpStatus.BAD_REQUEST, + ); + } + } } } diff --git a/backend/src/entities/cedar-authorization/cedar-entity-builder.ts b/backend/src/entities/cedar-authorization/cedar-entity-builder.ts index b675ec8ca..08f8d53b3 100644 --- a/backend/src/entities/cedar-authorization/cedar-entity-builder.ts +++ b/backend/src/entities/cedar-authorization/cedar-entity-builder.ts @@ -12,6 +12,7 @@ export function buildCedarEntities( connectionId: string, tableName?: string, dashboardId?: string, + panelId?: string, ): Array { const entities: Array = []; @@ -58,5 +59,13 @@ export function buildCedarEntities( }); } + if (panelId) { + entities.push({ + uid: { type: 'RocketAdmin::Panel', id: `${connectionId}/${panelId}` }, + attrs: { connectionId: connectionId }, + parents: [{ type: 'RocketAdmin::Connection', id: connectionId }], + }); + } + return entities; } diff --git a/backend/src/entities/cedar-authorization/cedar-policy-generator.ts b/backend/src/entities/cedar-authorization/cedar-policy-generator.ts index 3839446a9..1d9095884 100644 --- a/backend/src/entities/cedar-authorization/cedar-policy-generator.ts +++ b/backend/src/entities/cedar-authorization/cedar-policy-generator.ts @@ -87,6 +87,46 @@ export function generateCedarPolicyForGroup( } } + if (permissions.panels) { + let hasPanelCreatePermission = false; + let hasPanelReadPermission = false; + for (const panel of permissions.panels) { + const panelRef = `RocketAdmin::Panel::"${connectionId}/${panel.panelId}"`; + const access = panel.accessLevel; + + if (access.read) { + hasPanelReadPermission = true; + policies.push( + `permit(\n principal,\n action == RocketAdmin::Action::"panel:read",\n resource == ${panelRef}\n);`, + ); + } + if (access.create) { + hasPanelCreatePermission = true; + } + if (access.edit) { + policies.push( + `permit(\n principal,\n action == RocketAdmin::Action::"panel:edit",\n resource == ${panelRef}\n);`, + ); + } + if (access.delete) { + policies.push( + `permit(\n principal,\n action == RocketAdmin::Action::"panel:delete",\n resource == ${panelRef}\n);`, + ); + } + } + const newPanelRef = `RocketAdmin::Panel::"${connectionId}/__new__"`; + if (hasPanelReadPermission) { + policies.push( + `permit(\n principal,\n action == RocketAdmin::Action::"panel:read",\n resource == ${newPanelRef}\n);`, + ); + } + if (hasPanelCreatePermission) { + policies.push( + `permit(\n principal,\n action == RocketAdmin::Action::"panel:create",\n resource == ${newPanelRef}\n);`, + ); + } + } + for (const table of permissions.tables) { const tableRef = `RocketAdmin::Table::"${connectionId}/${table.tableName}"`; const access = table.accessLevel; diff --git a/backend/src/entities/cedar-authorization/cedar-policy-parser.ts b/backend/src/entities/cedar-authorization/cedar-policy-parser.ts index 0f7519733..64c6dcffa 100644 --- a/backend/src/entities/cedar-authorization/cedar-policy-parser.ts +++ b/backend/src/entities/cedar-authorization/cedar-policy-parser.ts @@ -2,6 +2,7 @@ import { AccessLevelEnum } from '../../enums/index.js'; import { IComplexPermission, IDashboardPermissionData, + IPanelPermissionData, ITablePermissionData, } from '../permission/permission.interface.js'; @@ -24,10 +25,12 @@ export function parseCedarPolicyToClassicalPermissions( group: { groupId, accessLevel: AccessLevelEnum.none }, tables: [], dashboards: [], + panels: [], }; const tableMap = new Map(); const dashboardMap = new Map(); + const panelMap = new Map(); for (const permit of permits) { if (permit.isWildcard) { @@ -75,6 +78,16 @@ export function parseCedarPolicyToClassicalPermissions( applyDashboardAction(dashboardEntry, permit.action); break; } + case 'panel:read': + case 'panel:create': + case 'panel:edit': + case 'panel:delete': { + const panelId = extractPanelId(permit.resourceId, connectionId); + if (!panelId) break; + const panelEntry = getOrCreatePanelEntry(panelMap, panelId); + applyPanelAction(panelEntry, permit.action); + break; + } } } @@ -84,6 +97,7 @@ export function parseCedarPolicyToClassicalPermissions( a.readonly = a.visibility && !a.add && !a.edit && !a.delete; } result.dashboards = Array.from(dashboardMap.values()); + result.panels = Array.from(panelMap.values()); return result; } @@ -268,3 +282,49 @@ function applyDashboardAction(entry: IDashboardPermissionData, action: string): break; } } + +function extractPanelId(resourceId: string | null, connectionId: string): string | null { + if (!resourceId) return null; + const prefix = `${connectionId}/`; + if (resourceId.startsWith(prefix)) { + return resourceId.slice(prefix.length); + } + return resourceId; +} + +function getOrCreatePanelEntry( + map: Map, + panelId: string, +): IPanelPermissionData { + let entry = map.get(panelId); + if (!entry) { + entry = { + panelId, + accessLevel: { + read: false, + create: false, + edit: false, + delete: false, + }, + }; + map.set(panelId, entry); + } + return entry; +} + +function applyPanelAction(entry: IPanelPermissionData, action: string): void { + switch (action) { + case 'panel:read': + entry.accessLevel.read = true; + break; + case 'panel:create': + entry.accessLevel.create = true; + break; + case 'panel:edit': + entry.accessLevel.edit = true; + break; + case 'panel:delete': + entry.accessLevel.delete = true; + break; + } +} diff --git a/backend/src/entities/cedar-authorization/cedar-schema.ts b/backend/src/entities/cedar-authorization/cedar-schema.ts index 032290985..393f6842a 100644 --- a/backend/src/entities/cedar-authorization/cedar-schema.ts +++ b/backend/src/entities/cedar-authorization/cedar-schema.ts @@ -45,6 +45,15 @@ export const CEDAR_SCHEMA = { }, }, }, + Panel: { + memberOfTypes: ['Connection'], + shape: { + type: 'Record', + attributes: { + connectionId: { type: 'String' }, + }, + }, + }, }, actions: { 'connection:read': { @@ -119,6 +128,30 @@ export const CEDAR_SCHEMA = { resourceTypes: ['Dashboard'], }, }, + 'panel:read': { + appliesTo: { + principalTypes: ['User'], + resourceTypes: ['Panel'], + }, + }, + 'panel:create': { + appliesTo: { + principalTypes: ['User'], + resourceTypes: ['Panel'], + }, + }, + 'panel:edit': { + appliesTo: { + principalTypes: ['User'], + resourceTypes: ['Panel'], + }, + }, + 'panel:delete': { + appliesTo: { + principalTypes: ['User'], + resourceTypes: ['Panel'], + }, + }, }, }, }; diff --git a/backend/src/entities/permission/permission.interface.ts b/backend/src/entities/permission/permission.interface.ts index d8e38a5d7..54334e638 100644 --- a/backend/src/entities/permission/permission.interface.ts +++ b/backend/src/entities/permission/permission.interface.ts @@ -5,6 +5,7 @@ export interface IComplexPermission { group: IGroupPermissionData; tables: Array; dashboards?: Array; + panels?: Array; } export interface IConnectionPermissionData { @@ -45,3 +46,15 @@ export interface IDashboardPermissionData { dashboardId: string; accessLevel: IDashboardAccessLevel; } + +export interface IPanelAccessLevel { + read: boolean; + create: boolean; + edit: boolean; + delete: boolean; +} + +export interface IPanelPermissionData { + panelId: string; + accessLevel: IPanelAccessLevel; +} diff --git a/backend/src/entities/visualizations/panel-position/panel-position.controller.ts b/backend/src/entities/visualizations/panel-position/panel-position.controller.ts index 9ad6f4f76..2e9bf607e 100644 --- a/backend/src/entities/visualizations/panel-position/panel-position.controller.ts +++ b/backend/src/entities/visualizations/panel-position/panel-position.controller.ts @@ -18,6 +18,7 @@ import { Timeout, TimeoutDefaults } from '../../../decorators/timeout.decorator. import { UserId } from '../../../decorators/user-id.decorator.js'; import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; import { ConnectionEditGuard } from '../../../guards/connection-edit.guard.js'; +import { DashboardEditGuard } from '../../../guards/dashboard-edit.guard.js'; import { SentryInterceptor } from '../../../interceptors/sentry.interceptor.js'; import { CreatePanelPositionDs } from './data-structures/create-panel-position.ds.js'; import { DeletePanelPositionDs } from './data-structures/delete-panel-position.ds.js'; @@ -67,7 +68,7 @@ export class DashboardWidgetController { @ApiBody({ type: CreatePanelPositionDto }) @ApiParam({ name: 'dashboardId', required: true }) @ApiParam({ name: 'connectionId', required: true }) - @UseGuards(ConnectionEditGuard) + @UseGuards(DashboardEditGuard) @Post('/dashboard/:dashboardId/widget/:connectionId') async createWidget( @SlugUuid('connectionId') connectionId: string, @@ -100,7 +101,7 @@ export class DashboardWidgetController { @ApiParam({ name: 'dashboardId', required: true }) @ApiParam({ name: 'widgetId', required: true }) @ApiParam({ name: 'connectionId', required: true }) - @UseGuards(ConnectionEditGuard) + @UseGuards(DashboardEditGuard) @Put('/dashboard/:dashboardId/widget/:widgetId/:connectionId') async updateWidget( @SlugUuid('connectionId') connectionId: string, @@ -134,7 +135,7 @@ export class DashboardWidgetController { @ApiParam({ name: 'dashboardId', required: true }) @ApiParam({ name: 'widgetId', required: true }) @ApiParam({ name: 'connectionId', required: true }) - @UseGuards(ConnectionEditGuard) + @UseGuards(DashboardEditGuard) @Delete('/dashboard/:dashboardId/widget/:widgetId/:connectionId') async deleteWidget( @SlugUuid('connectionId') connectionId: string, diff --git a/backend/src/entities/visualizations/panel/data-structures/find-all-panels.ds.ts b/backend/src/entities/visualizations/panel/data-structures/find-all-panels.ds.ts index fd61907c4..c4a39c782 100644 --- a/backend/src/entities/visualizations/panel/data-structures/find-all-panels.ds.ts +++ b/backend/src/entities/visualizations/panel/data-structures/find-all-panels.ds.ts @@ -1,4 +1,5 @@ export class FindAllPanelsDs { connectionId: string; masterPassword: string; + userId: string; } diff --git a/backend/src/entities/visualizations/panel/panel.controller.ts b/backend/src/entities/visualizations/panel/panel.controller.ts index 67b177d7e..c3e23dacd 100644 --- a/backend/src/entities/visualizations/panel/panel.controller.ts +++ b/backend/src/entities/visualizations/panel/panel.controller.ts @@ -22,8 +22,9 @@ import { Timeout } from '../../../decorators/timeout.decorator.js'; import { UserId } from '../../../decorators/user-id.decorator.js'; import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; import { Messages } from '../../../exceptions/text/messages.js'; -import { ConnectionEditGuard } from '../../../guards/connection-edit.guard.js'; import { ConnectionReadGuard } from '../../../guards/connection-read.guard.js'; +import { PanelEditGuard } from '../../../guards/panel-edit.guard.js'; +import { PanelReadGuard } from '../../../guards/panel-read.guard.js'; import { SentryInterceptor } from '../../../interceptors/sentry.interceptor.js'; import { CreatePanelDs } from './data-structures/create-panel.ds.js'; import { ExecuteSavedDbQueryDs } from './data-structures/execute-saved-db-query.ds.js'; @@ -79,11 +80,12 @@ export class SavedDbQueryController { isArray: true, }) @ApiParam({ name: 'connectionId', required: true }) - @UseGuards(ConnectionReadGuard) + @UseGuards(PanelReadGuard) @Get('/connection/:connectionId/saved-queries') async findAll( @SlugUuid('connectionId') connectionId: string, @MasterPassword() masterPwd: string, + @UserId() userId: string, ): Promise { if (!connectionId) { throw new HttpException( @@ -96,6 +98,7 @@ export class SavedDbQueryController { const inputData: FindAllPanelsDs = { connectionId, masterPassword: masterPwd, + userId, }; return await this.findAllSavedDbQueriesUseCase.execute(inputData, InTransactionEnum.OFF); } @@ -108,7 +111,7 @@ export class SavedDbQueryController { }) @ApiParam({ name: 'connectionId', required: true }) @ApiParam({ name: 'queryId', required: true }) - @UseGuards(ConnectionReadGuard) + @UseGuards(PanelReadGuard) @Get('/connection/:connectionId/saved-query/:queryId') async findOne( @SlugUuid('connectionId') connectionId: string, @@ -139,7 +142,7 @@ export class SavedDbQueryController { }) @ApiBody({ type: CreateSavedDbQueryDto }) @ApiParam({ name: 'connectionId', required: true }) - @UseGuards(ConnectionEditGuard) + @UseGuards(PanelEditGuard) @Post('/connection/:connectionId/saved-query') async create( @SlugUuid('connectionId') connectionId: string, @@ -176,7 +179,7 @@ export class SavedDbQueryController { @ApiBody({ type: UpdateSavedDbQueryDto }) @ApiParam({ name: 'connectionId', required: true }) @ApiParam({ name: 'queryId', required: true }) - @UseGuards(ConnectionEditGuard) + @UseGuards(PanelEditGuard) @Put('/connection/:connectionId/saved-query/:queryId') async update( @SlugUuid('connectionId') connectionId: string, @@ -214,7 +217,7 @@ export class SavedDbQueryController { }) @ApiParam({ name: 'connectionId', required: true }) @ApiParam({ name: 'queryId', required: true }) - @UseGuards(ConnectionEditGuard) + @UseGuards(PanelEditGuard) @Delete('/connection/:connectionId/saved-query/:queryId') async delete( @SlugUuid('connectionId') connectionId: string, @@ -245,7 +248,7 @@ export class SavedDbQueryController { }) @ApiParam({ name: 'connectionId', required: true }) @ApiParam({ name: 'queryId', required: true }) - @UseGuards(ConnectionReadGuard) + @UseGuards(PanelReadGuard) @Post('/connection/:connectionId/saved-query/:queryId/execute') async execute( @SlugUuid('connectionId') connectionId: string, diff --git a/backend/src/entities/visualizations/panel/use-cases/find-all-panels.use.case.ts b/backend/src/entities/visualizations/panel/use-cases/find-all-panels.use.case.ts index 969a1de49..8b1f5ab8f 100644 --- a/backend/src/entities/visualizations/panel/use-cases/find-all-panels.use.case.ts +++ b/backend/src/entities/visualizations/panel/use-cases/find-all-panels.use.case.ts @@ -2,6 +2,8 @@ import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; import AbstractUseCase from '../../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../../common/data-injection.tokens.js'; +import { CedarAction } from '../../../cedar-authorization/cedar-action-map.js'; +import { CedarAuthorizationService } from '../../../cedar-authorization/cedar-authorization.service.js'; import { Messages } from '../../../../exceptions/text/messages.js'; import { FindAllPanelsDs } from '../data-structures/find-all-panels.ds.js'; import { FoundPanelDto } from '../dto/found-saved-db-query.dto.js'; @@ -16,12 +18,13 @@ export class FindAllSavedDbQueriesUseCase constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, + private readonly cedarAuthService: CedarAuthorizationService, ) { super(); } public async implementation(inputData: FindAllPanelsDs): Promise { - const { connectionId, masterPassword } = inputData; + const { connectionId, masterPassword, userId } = inputData; const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( connectionId, @@ -34,6 +37,17 @@ export class FindAllSavedDbQueriesUseCase const foundQueries = await this._dbContext.panelRepository.findAllQueriesByConnectionId(connectionId); - return foundQueries.map((query) => buildFoundPanelDto(query)); + const accessChecks = await Promise.all( + foundQueries.map((query) => + this.cedarAuthService.validate({ + userId, + action: CedarAction.PanelRead, + connectionId, + panelId: query.id, + }), + ), + ); + + return foundQueries.filter((_, index) => accessChecks[index]).map((query) => buildFoundPanelDto(query)); } } diff --git a/backend/src/guards/panel-edit.guard.ts b/backend/src/guards/panel-edit.guard.ts new file mode 100644 index 000000000..12fd43e53 --- /dev/null +++ b/backend/src/guards/panel-edit.guard.ts @@ -0,0 +1,63 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { IRequestWithCognitoInfo } from '../authorization/index.js'; +import { CedarAction } from '../entities/cedar-authorization/cedar-action-map.js'; +import { CedarAuthorizationService } from '../entities/cedar-authorization/cedar-authorization.service.js'; +import { Messages } from '../exceptions/text/messages.js'; +import { ValidationHelper } from '../helpers/validators/validation-helper.js'; +import { validateUuidByRegex } from './utils/validate-uuid-by-regex.js'; + +@Injectable() +export class PanelEditGuard implements CanActivate { + constructor( + private readonly cedarAuthService: CedarAuthorizationService, + ) {} + + canActivate(context: ExecutionContext): boolean | Promise | Observable { + return new Promise(async (resolve, reject) => { + const request: IRequestWithCognitoInfo = context.switchToHttp().getRequest(); + const cognitoUserName = request.decoded.sub; + let connectionId: string = request.params?.slug || request.params?.connectionId; + if (!connectionId || (!validateUuidByRegex(connectionId) && !ValidationHelper.isValidNanoId(connectionId))) { + connectionId = request.query.connectionId; + } + if (!connectionId || (!validateUuidByRegex(connectionId) && !ValidationHelper.isValidNanoId(connectionId))) { + reject(new BadRequestException(Messages.CONNECTION_ID_MISSING)); + return; + } + + const panelId: string = request.params?.queryId; + let action: CedarAction; + + if (request.method === 'DELETE') { + action = CedarAction.PanelDelete; + } else if (request.method === 'POST' && !panelId) { + action = CedarAction.PanelCreate; + } else { + action = CedarAction.PanelEdit; + } + + try { + const allowed = await this.cedarAuthService.validate({ + userId: cognitoUserName, + action, + connectionId, + panelId, + }); + if (allowed) { + resolve(true); + return; + } + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + } catch (e) { + reject(e); + } + }); + } +} diff --git a/backend/src/guards/panel-read.guard.ts b/backend/src/guards/panel-read.guard.ts new file mode 100644 index 000000000..5686b1a32 --- /dev/null +++ b/backend/src/guards/panel-read.guard.ts @@ -0,0 +1,74 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Inject, + Injectable, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { IRequestWithCognitoInfo } from '../authorization/index.js'; +import { IGlobalDatabaseContext } from '../common/application/global-database-context.interface.js'; +import { BaseType } from '../common/data-injection.tokens.js'; +import { CedarAction } from '../entities/cedar-authorization/cedar-action-map.js'; +import { CedarAuthorizationService } from '../entities/cedar-authorization/cedar-authorization.service.js'; +import { Messages } from '../exceptions/text/messages.js'; +import { ValidationHelper } from '../helpers/validators/validation-helper.js'; +import { validateUuidByRegex } from './utils/validate-uuid-by-regex.js'; + +@Injectable() +export class PanelReadGuard implements CanActivate { + constructor( + private readonly cedarAuthService: CedarAuthorizationService, + @Inject(BaseType.GLOBAL_DB_CONTEXT) + private readonly _dbContext: IGlobalDatabaseContext, + ) {} + + canActivate(context: ExecutionContext): boolean | Promise | Observable { + return new Promise(async (resolve, reject) => { + const request: IRequestWithCognitoInfo = context.switchToHttp().getRequest(); + const cognitoUserName = request.decoded.sub; + let connectionId: string = request.params?.slug || request.params?.connectionId; + if (!connectionId || (!validateUuidByRegex(connectionId) && !ValidationHelper.isValidNanoId(connectionId))) { + connectionId = request.query.connectionId; + } + if (!connectionId || (!validateUuidByRegex(connectionId) && !ValidationHelper.isValidNanoId(connectionId))) { + reject(new BadRequestException(Messages.CONNECTION_ID_MISSING)); + return; + } + + const panelId: string = request.params?.queryId; + + try { + // For list-all requests (no panelId), verify user belongs to the connection. + // The use case will filter results based on per-panel permissions. + if (!panelId) { + const isUserInConnection = await this._dbContext.connectionRepository.isUserFromConnection( + cognitoUserName, + connectionId, + ); + if (isUserInConnection) { + resolve(true); + return; + } + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + + const allowed = await this.cedarAuthService.validate({ + userId: cognitoUserName, + action: CedarAction.PanelRead, + connectionId, + panelId, + }); + if (allowed) { + resolve(true); + return; + } + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + } catch (e) { + reject(e); + } + }); + } +} diff --git a/backend/test/ava-tests/saas-tests/saas-dashboard-permissions-e2e.test.ts b/backend/test/ava-tests/saas-tests/saas-dashboard-permissions-e2e.test.ts index 4a55e9539..75801ae24 100644 --- a/backend/test/ava-tests/saas-tests/saas-dashboard-permissions-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/saas-dashboard-permissions-e2e.test.ts @@ -281,6 +281,330 @@ test.serial( }, ); +//****************************** PANEL (SAVED QUERY) PERMISSIONS ****************************** + +test.serial( + `${currentTest} should allow listing panels when user has read access to a specific panel via cedar policy`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + // Admin creates a saved query + const createPanel = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/saved-query`) + .send({ name: 'Test Query', query_text: 'SELECT 1', widget_type: 'table' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createPanel.status, 201); + const panelId = createPanel.body.id; + + // Save cedar policy granting panel read access + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"panel:read",\n resource == RocketAdmin::Panel::"${connectionId}/${panelId}"\n);`, + ].join('\n\n'); + + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(savePolicyResponse.status, 201); + + // Simple user lists panels — should succeed and return only the permitted panel + const listPanels = await request(app.getHttpServer()) + .get(`/connection/${connectionId}/saved-queries`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(listPanels.status, 200); + const panels = listPanels.body; + t.is(Array.isArray(panels), true); + t.is(panels.length, 1); + t.is(panels[0].id, panelId); + t.is(panels[0].name, 'Test Query'); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should return only panels the user has read access to, not all panels`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + // Admin creates two saved queries + const createPanel1 = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/saved-query`) + .send({ name: 'Allowed Query', query_text: 'SELECT 1', widget_type: 'table' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createPanel1.status, 201); + const allowedPanelId = createPanel1.body.id; + + const createPanel2 = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/saved-query`) + .send({ name: 'Forbidden Query', query_text: 'SELECT 2', widget_type: 'table' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createPanel2.status, 201); + + // Save cedar policy granting read access only to the first panel + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"panel:read",\n resource == RocketAdmin::Panel::"${connectionId}/${allowedPanelId}"\n);`, + ].join('\n\n'); + + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(savePolicyResponse.status, 201); + + // Simple user lists panels — should only see the allowed one + const listPanels = await request(app.getHttpServer()) + .get(`/connection/${connectionId}/saved-queries`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(listPanels.status, 200); + t.is(listPanels.body.length, 1); + t.is(listPanels.body[0].id, allowedPanelId); + t.is(listPanels.body[0].name, 'Allowed Query'); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should return empty array when user has no panel read permissions`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + // Admin creates a saved query + const createPanel = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/saved-query`) + .send({ name: 'Hidden Query', query_text: 'SELECT 1', widget_type: 'table' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createPanel.status, 201); + + // Save cedar policy with connection read only, no panel permissions + const cedarPolicy = `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`; + + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(savePolicyResponse.status, 201); + + // Simple user lists panels — should get empty array + const listPanels = await request(app.getHttpServer()) + .get(`/connection/${connectionId}/saved-queries`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(listPanels.status, 200); + t.is(listPanels.body.length, 0); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should allow reading a specific panel when user has read access to it`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + // Admin creates a saved query + const createPanel = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/saved-query`) + .send({ name: 'Readable Query', query_text: 'SELECT 1', widget_type: 'table' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createPanel.status, 201); + const panelId = createPanel.body.id; + + // Save cedar policy granting panel read + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"panel:read",\n resource == RocketAdmin::Panel::"${connectionId}/${panelId}"\n);`, + ].join('\n\n'); + + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(savePolicyResponse.status, 201); + + // Simple user reads the specific panel + const getPanel = await request(app.getHttpServer()) + .get(`/connection/${connectionId}/saved-query/${panelId}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getPanel.status, 200); + t.is(getPanel.body.id, panelId); + t.is(getPanel.body.name, 'Readable Query'); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should return 403 when user reads a specific panel without permission`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + // Admin creates two panels + const createPanel1 = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/saved-query`) + .send({ name: 'Allowed Query', query_text: 'SELECT 1', widget_type: 'table' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createPanel1.status, 201); + const allowedPanelId = createPanel1.body.id; + + const createPanel2 = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/saved-query`) + .send({ name: 'Forbidden Query', query_text: 'SELECT 2', widget_type: 'table' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createPanel2.status, 201); + const forbiddenPanelId = createPanel2.body.id; + + // Save cedar policy granting read only to first panel + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"panel:read",\n resource == RocketAdmin::Panel::"${connectionId}/${allowedPanelId}"\n);`, + ].join('\n\n'); + + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(savePolicyResponse.status, 201); + + // Simple user tries to read the forbidden panel — should get 403 + const getPanel = await request(app.getHttpServer()) + .get(`/connection/${connectionId}/saved-query/${forbiddenPanelId}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getPanel.status, 403); + t.is(getPanel.body.message, Messages.DONT_HAVE_PERMISSIONS); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should return 403 when user creates a panel without panel create permission`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + // Save cedar policy with connection read only, no panel create + const cedarPolicy = `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`; + + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(savePolicyResponse.status, 201); + + // Simple user tries to create a panel — should get 403 + const createPanel = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/saved-query`) + .send({ name: 'New Query', query_text: 'SELECT 1', widget_type: 'table' }) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createPanel.status, 403); + t.is(createPanel.body.message, Messages.DONT_HAVE_PERMISSIONS); + } catch (error) { + console.error(error); + throw error; + } + }, +); + test.serial( `${currentTest} should return 403 when user reads a specific dashboard without permission`, async (t) => {