From 19596fbbbaac0bc176772847d4fad08e0ffdf410 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Thu, 9 Apr 2026 09:34:00 +0000 Subject: [PATCH] Implement dashboard permissions validation and add end-to-end tests for dashboard access --- .../use-cases/find-all-dashboards.use.case.ts | 18 +- backend/src/guards/dashboard-read.guard.ts | 18 + .../saas-dashboard-permissions-e2e.test.ts | 345 ++++++++++++++++++ 3 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 backend/test/ava-tests/saas-tests/saas-dashboard-permissions-e2e.test.ts diff --git a/backend/src/entities/visualizations/dashboard/use-cases/find-all-dashboards.use.case.ts b/backend/src/entities/visualizations/dashboard/use-cases/find-all-dashboards.use.case.ts index c3fa85a50..1ebda4df1 100644 --- a/backend/src/entities/visualizations/dashboard/use-cases/find-all-dashboards.use.case.ts +++ b/backend/src/entities/visualizations/dashboard/use-cases/find-all-dashboards.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 { FindAllDashboardsDs } from '../data-structures/find-all-dashboards.ds.js'; import { FoundDashboardDto } from '../dto/found-dashboard.dto.js'; @@ -16,12 +18,13 @@ export class FindAllDashboardsUseCase constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, + private readonly cedarAuthService: CedarAuthorizationService, ) { super(); } public async implementation(inputData: FindAllDashboardsDs): Promise { - const { connectionId, masterPassword } = inputData; + const { connectionId, masterPassword, userId } = inputData; const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( connectionId, @@ -35,6 +38,17 @@ export class FindAllDashboardsUseCase const dashboards = await this._dbContext.dashboardRepository.findAllDashboardsWithWidgetsByConnectionId(connectionId); - return dashboards.map(buildFoundDashboardDto); + const accessChecks = await Promise.all( + dashboards.map((dashboard) => + this.cedarAuthService.validate({ + userId, + action: CedarAction.DashboardRead, + connectionId, + dashboardId: dashboard.id, + }), + ), + ); + + return dashboards.filter((_, index) => accessChecks[index]).map(buildFoundDashboardDto); } } diff --git a/backend/src/guards/dashboard-read.guard.ts b/backend/src/guards/dashboard-read.guard.ts index 04ab1a5bf..a11af4df9 100644 --- a/backend/src/guards/dashboard-read.guard.ts +++ b/backend/src/guards/dashboard-read.guard.ts @@ -3,10 +3,13 @@ import { 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'; @@ -17,6 +20,8 @@ import { validateUuidByRegex } from './utils/validate-uuid-by-regex.js'; export class DashboardReadGuard implements CanActivate { constructor( private readonly cedarAuthService: CedarAuthorizationService, + @Inject(BaseType.GLOBAL_DB_CONTEXT) + private readonly _dbContext: IGlobalDatabaseContext, ) {} canActivate(context: ExecutionContext): boolean | Promise | Observable { @@ -35,6 +40,19 @@ export class DashboardReadGuard implements CanActivate { const dashboardId: string = request.params?.dashboardId; try { + if (!dashboardId) { + 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.DashboardRead, 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 new file mode 100644 index 000000000..4a55e9539 --- /dev/null +++ b/backend/test/ava-tests/saas-tests/saas-dashboard-permissions-e2e.test.ts @@ -0,0 +1,345 @@ +/* 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 { 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 { Messages } from '../../../src/exceptions/text/messages.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 { MockFactory } from '../../mock.factory.js'; +import { TestUtils } from '../../utils/test.utils.js'; +import { createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions } from '../../utils/user-with-different-permissions-utils.js'; + +let app: INestApplication; +let _testUtils: TestUtils; +let currentTest: string; + +const mockFactory = new MockFactory(); + +test.before(async () => { + process.env.CEDAR_AUTHORIZATION_ENABLED = 'true'; + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + app = moduleFixture.createNestApplication(); + _testUtils = moduleFixture.get(TestUtils); + + 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 () => { + try { + await Cacher.clearAllCache(); + delete process.env.CEDAR_AUTHORIZATION_ENABLED; + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +//****************************** DASHBOARD PERMISSIONS ****************************** + +currentTest = 'Dashboard permissions'; + +test.serial( + `${currentTest} should allow listing dashboards when user has read access to a specific dashboard via cedar policy`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + // Admin creates a dashboard + const createDashboard = await request(app.getHttpServer()) + .post(`/dashboards/${connectionId}`) + .send({ name: 'Test Dashboard', description: 'Test description' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createDashboard.status, 201); + const dashboardId = createDashboard.body.id; + + // Save cedar policy granting read access to the specific dashboard only (no __new__ sentinel) + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"dashboard:read",\n resource == RocketAdmin::Dashboard::"${connectionId}/${dashboardId}"\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 dashboards — should succeed and return only the permitted dashboard + const listDashboards = await request(app.getHttpServer()) + .get(`/dashboards/${connectionId}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(listDashboards.status, 200); + const dashboards = listDashboards.body; + t.is(Array.isArray(dashboards), true); + t.is(dashboards.length, 1); + t.is(dashboards[0].id, dashboardId); + t.is(dashboards[0].name, 'Test Dashboard'); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should return only dashboards the user has read access to, not all dashboards`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + // Admin creates two dashboards + const createDashboard1 = await request(app.getHttpServer()) + .post(`/dashboards/${connectionId}`) + .send({ name: 'Allowed Dashboard' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createDashboard1.status, 201); + const allowedDashboardId = createDashboard1.body.id; + + const createDashboard2 = await request(app.getHttpServer()) + .post(`/dashboards/${connectionId}`) + .send({ name: 'Forbidden Dashboard' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createDashboard2.status, 201); + + // Save cedar policy granting read access only to the first dashboard + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"dashboard:read",\n resource == RocketAdmin::Dashboard::"${connectionId}/${allowedDashboardId}"\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 dashboards — should only see the allowed one + const listDashboards = await request(app.getHttpServer()) + .get(`/dashboards/${connectionId}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(listDashboards.status, 200); + const dashboards = listDashboards.body; + t.is(dashboards.length, 1); + t.is(dashboards[0].id, allowedDashboardId); + t.is(dashboards[0].name, 'Allowed Dashboard'); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should return empty array when user has no dashboard read permissions`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + // Admin creates a dashboard + const createDashboard = await request(app.getHttpServer()) + .post(`/dashboards/${connectionId}`) + .send({ name: 'Hidden Dashboard' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createDashboard.status, 201); + + // Save cedar policy with connection read only, no dashboard 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 dashboards — should get empty array + const listDashboards = await request(app.getHttpServer()) + .get(`/dashboards/${connectionId}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(listDashboards.status, 200); + t.is(listDashboards.body.length, 0); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should allow reading a specific dashboard 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 dashboard + const createDashboard = await request(app.getHttpServer()) + .post(`/dashboards/${connectionId}`) + .send({ name: 'Readable Dashboard' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createDashboard.status, 201); + const dashboardId = createDashboard.body.id; + + // Save cedar policy granting read access to the dashboard + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"dashboard:read",\n resource == RocketAdmin::Dashboard::"${connectionId}/${dashboardId}"\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 dashboard + const getDashboard = await request(app.getHttpServer()) + .get(`/dashboard/${dashboardId}/${connectionId}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getDashboard.status, 200); + t.is(getDashboard.body.id, dashboardId); + t.is(getDashboard.body.name, 'Readable Dashboard'); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should return 403 when user reads a specific dashboard without permission`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + // Admin creates two dashboards + const createDashboard1 = await request(app.getHttpServer()) + .post(`/dashboards/${connectionId}`) + .send({ name: 'Allowed Dashboard' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createDashboard1.status, 201); + const allowedDashboardId = createDashboard1.body.id; + + const createDashboard2 = await request(app.getHttpServer()) + .post(`/dashboards/${connectionId}`) + .send({ name: 'Forbidden Dashboard' }) + .set('Cookie', testData.users.adminUserToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createDashboard2.status, 201); + const forbiddenDashboardId = createDashboard2.body.id; + + // Save cedar policy granting read access only to the first dashboard + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"dashboard:read",\n resource == RocketAdmin::Dashboard::"${connectionId}/${allowedDashboardId}"\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 dashboard — should get 403 + const getDashboard = await request(app.getHttpServer()) + .get(`/dashboard/${forbiddenDashboardId}/${connectionId}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getDashboard.status, 403); + t.is(getDashboard.body.message, Messages.DONT_HAVE_PERMISSIONS); + } catch (error) { + console.error(error); + throw error; + } + }, +);