From 07a158398d668ad996f8d7e6f9841ad2b5ae7786 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Tue, 5 May 2026 10:37:11 +0000 Subject: [PATCH] feat: add connection diagram action and permissions to Cedar authorization --- .../cedar-authorization/cedar-action-map.ts | 1 + .../cedar-policy-generator.ts | 3 + .../cedar-policy-parser.ts | 5 + .../cedar-authorization/cedar-schema.json | 6 + .../cedar-authorization/cedar-schema.ts | 6 + .../connection/connection.controller.ts | 3 +- .../src/guards/connection-diagram.guard.ts | 43 +++ .../non-saas-cedar-policy-generator.test.ts | 5 +- ...-connection-diagram-permission-e2e.test.ts | 289 ++++++++++++++++++ 9 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 backend/src/guards/connection-diagram.guard.ts create mode 100644 backend/test/ava-tests/saas-tests/saas-cedar-connection-diagram-permission-e2e.test.ts diff --git a/backend/src/entities/cedar-authorization/cedar-action-map.ts b/backend/src/entities/cedar-authorization/cedar-action-map.ts index cbea8ebb5..e30e075fa 100644 --- a/backend/src/entities/cedar-authorization/cedar-action-map.ts +++ b/backend/src/entities/cedar-authorization/cedar-action-map.ts @@ -1,6 +1,7 @@ export enum CedarAction { ConnectionRead = 'connection:read', ConnectionEdit = 'connection:edit', + ConnectionDiagram = 'connection:diagram', GroupRead = 'group:read', GroupEdit = 'group:edit', TableRead = 'table:read', diff --git a/backend/src/entities/cedar-authorization/cedar-policy-generator.ts b/backend/src/entities/cedar-authorization/cedar-policy-generator.ts index 6e97123a8..3cc9402bc 100644 --- a/backend/src/entities/cedar-authorization/cedar-policy-generator.ts +++ b/backend/src/entities/cedar-authorization/cedar-policy-generator.ts @@ -23,6 +23,9 @@ export function generateCedarPolicyForGroup( policies.push( `permit(\n principal,\n action == RocketAdmin::Action::"connection:edit",\n resource == ${connectionRef}\n);`, ); + policies.push( + `permit(\n principal,\n action == RocketAdmin::Action::"connection:diagram",\n resource == ${connectionRef}\n);`, + ); } else if (connAccess === AccessLevelEnum.readonly) { policies.push( `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == ${connectionRef}\n);`, diff --git a/backend/src/entities/cedar-authorization/cedar-policy-parser.ts b/backend/src/entities/cedar-authorization/cedar-policy-parser.ts index d7c7e5a6a..df5a25f16 100644 --- a/backend/src/entities/cedar-authorization/cedar-policy-parser.ts +++ b/backend/src/entities/cedar-authorization/cedar-policy-parser.ts @@ -50,6 +50,11 @@ export function parseCedarPolicyToClassicalPermissions( case 'connection:edit': result.connection.accessLevel = AccessLevelEnum.edit; break; + case 'connection:diagram': + if (result.connection.accessLevel === AccessLevelEnum.none) { + result.connection.accessLevel = AccessLevelEnum.readonly; + } + break; case 'group:read': if (result.group.accessLevel === AccessLevelEnum.none) { result.group.accessLevel = AccessLevelEnum.readonly; diff --git a/backend/src/entities/cedar-authorization/cedar-schema.json b/backend/src/entities/cedar-authorization/cedar-schema.json index 5cc4738dc..c60cd6e65 100644 --- a/backend/src/entities/cedar-authorization/cedar-schema.json +++ b/backend/src/entities/cedar-authorization/cedar-schema.json @@ -59,6 +59,12 @@ "resourceTypes": ["Connection"] } }, + "connection:diagram": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Connection"] + } + }, "group:read": { "appliesTo": { "principalTypes": ["User"], diff --git a/backend/src/entities/cedar-authorization/cedar-schema.ts b/backend/src/entities/cedar-authorization/cedar-schema.ts index 393f6842a..1fbe139e8 100644 --- a/backend/src/entities/cedar-authorization/cedar-schema.ts +++ b/backend/src/entities/cedar-authorization/cedar-schema.ts @@ -68,6 +68,12 @@ export const CEDAR_SCHEMA = { resourceTypes: ['Connection'], }, }, + 'connection:diagram': { + appliesTo: { + principalTypes: ['User'], + resourceTypes: ['Connection'], + }, + }, 'group:read': { appliesTo: { principalTypes: ['User'], diff --git a/backend/src/entities/connection/connection.controller.ts b/backend/src/entities/connection/connection.controller.ts index f157661b8..db99885fa 100644 --- a/backend/src/entities/connection/connection.controller.ts +++ b/backend/src/entities/connection/connection.controller.ts @@ -27,6 +27,7 @@ import { AmplitudeEventTypeEnum } from '../../enums/amplitude-event-type.enum.js import { InTransactionEnum } from '../../enums/in-transaction.enum.js'; import { Messages } from '../../exceptions/text/messages.js'; import { processExceptionMessage } from '../../exceptions/utils/process-exception-message.js'; +import { ConnectionDiagramGuard } from '../../guards/connection-diagram.guard.js'; import { ConnectionEditGuard } from '../../guards/connection-edit.guard.js'; import { ConnectionReadGuard } from '../../guards/connection-read.guard.js'; import { isConnectionTypeAgent } from '../../helpers/is-connection-entity-agent.js'; @@ -735,7 +736,7 @@ export class ConnectionController { status: 200, type: ConnectionDiagramResponseDTO, }) - @UseGuards(ConnectionEditGuard) + @UseGuards(ConnectionDiagramGuard) @Get('/connection/diagram/:connectionId') async getConnectionDiagram( @SlugUuid('connectionId') connectionId: string, diff --git a/backend/src/guards/connection-diagram.guard.ts b/backend/src/guards/connection-diagram.guard.ts new file mode 100644 index 000000000..6e84ff5ab --- /dev/null +++ b/backend/src/guards/connection-diagram.guard.ts @@ -0,0 +1,43 @@ +import { BadRequestException, CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { IRequestWithCognitoInfo } from '../authorization/cognito-decoded.interface.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 ConnectionDiagramGuard 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.query.connectionId; + if (!connectionId || (!validateUuidByRegex(connectionId) && !ValidationHelper.isValidNanoId(connectionId))) { + connectionId = request.params?.slug || request.params?.connectionId; + } + if (!connectionId || (!validateUuidByRegex(connectionId) && !ValidationHelper.isValidNanoId(connectionId))) { + reject(new BadRequestException(Messages.CONNECTION_ID_MISSING)); + return; + } + + try { + const allowed = await this.cedarAuthService.validate({ + userId: cognitoUserName, + action: CedarAction.ConnectionDiagram, + connectionId, + }); + if (allowed) { + resolve(true); + return; + } + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + } catch (e) { + reject(e); + } + }); + } +} diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts index 1a01f4fe0..433fece21 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts @@ -25,7 +25,7 @@ test('isMain=true generates a single wildcard permit', (t) => { t.is(permits.length, 1); }); -test('connection:edit generates ONLY connection:read + connection:edit (not wildcard)', (t) => { +test('connection:edit generates ONLY connection:read + connection:edit + connection:diagram (not wildcard)', (t) => { const result = generateCedarPolicyForGroup( connectionId, false, @@ -35,6 +35,7 @@ test('connection:edit generates ONLY connection:read + connection:edit (not wild ); t.true(result.includes('action == RocketAdmin::Action::"connection:read"')); t.true(result.includes('action == RocketAdmin::Action::"connection:edit"')); + t.true(result.includes('action == RocketAdmin::Action::"connection:diagram"')); // Must NOT contain wildcard `action,` on its own line (which would grant table access) t.false(result.includes('action,\n resource\n')); // Must NOT contain table actions @@ -43,7 +44,7 @@ test('connection:edit generates ONLY connection:read + connection:edit (not wild t.false(result.includes('table:edit')); t.false(result.includes('table:delete')); const permits = result.match(/permit\(/g); - t.is(permits.length, 2); + t.is(permits.length, 3); }); test('connection:readonly generates only connection:read', (t) => { diff --git a/backend/test/ava-tests/saas-tests/saas-cedar-connection-diagram-permission-e2e.test.ts b/backend/test/ava-tests/saas-tests/saas-cedar-connection-diagram-permission-e2e.test.ts new file mode 100644 index 000000000..b93f408c1 --- /dev/null +++ b/backend/test/ava-tests/saas-tests/saas-cedar-connection-diagram-permission-e2e.test.ts @@ -0,0 +1,289 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { faker } from '@faker-js/faker'; +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 { TestUtils } from '../../utils/test.utils.js'; +import { + createConnectionAndInviteUserWithConnectionEditOnly, + createConnectionsAndInviteNewUserInAdminGroupOfFirstConnection, + createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions, +} from '../../utils/user-with-different-permissions-utils.js'; + +let app: INestApplication; +let _testUtils: TestUtils; +let currentTest: string; + +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); + } +}); + +//****************************** GET /connection/diagram/:connectionId — separate Cedar permission ****************************** + +currentTest = 'GET /connection/diagram/:connectionId'; + +test.serial( + `${currentTest} should allow admin group user (wildcard policy) to fetch the diagram via Cedar`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInAdminGroupOfFirstConnection(app); + const { + connections, + users: { simpleUserToken }, + } = testData; + + const diagramResponse = await request(app.getHttpServer()) + .get(`/connection/diagram/${connections.firstId}`) + .set('Cookie', simpleUserToken) + .set('Accept', 'application/json'); + + t.is(diagramResponse.status, 200); + t.is(diagramResponse.body.connectionId, connections.firstId); + t.is(typeof diagramResponse.body.diagram, 'string'); + t.true(diagramResponse.body.diagram.startsWith('erDiagram')); + } catch (e) { + console.error(e); + throw e; + } + }, +); + +test.serial( + `${currentTest} should allow user with connection edit access to fetch the diagram via Cedar`, + async (t) => { + try { + const testData = await createConnectionAndInviteUserWithConnectionEditOnly(app); + const { + connections, + users: { simpleUserToken }, + } = testData; + + const diagramResponse = await request(app.getHttpServer()) + .get(`/connection/diagram/${connections.firstId}`) + .set('Cookie', simpleUserToken) + .set('Accept', 'application/json'); + + t.is(diagramResponse.status, 200); + t.is(diagramResponse.body.connectionId, connections.firstId); + t.is(typeof diagramResponse.body.diagram, 'string'); + } catch (e) { + console.error(e); + throw e; + } + }, +); + +test.serial( + `${currentTest} should reject readonly group user (no diagram permission) with 403 via Cedar`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const { + connections, + users: { simpleUserToken }, + } = testData; + + const diagramResponse = await request(app.getHttpServer()) + .get(`/connection/diagram/${connections.firstId}`) + .set('Cookie', simpleUserToken) + .set('Accept', 'application/json'); + + t.is(diagramResponse.status, 403); + t.is(JSON.parse(diagramResponse.text).message, Messages.DONT_HAVE_PERMISSIONS); + } catch (e) { + console.error(e); + throw e; + } + }, +); + +test.serial(`${currentTest} should reject user not in connection's groups with 403 via Cedar`, async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const { + connections, + users: { simpleUserToken }, + } = testData; + + // User is only in first connection's group, NOT in second connection + const diagramResponse = await request(app.getHttpServer()) + .get(`/connection/diagram/${connections.secondId}`) + .set('Cookie', simpleUserToken) + .set('Accept', 'application/json'); + + t.is(diagramResponse.status, 403); + t.is(JSON.parse(diagramResponse.text).message, Messages.DONT_HAVE_PERMISSIONS); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should reject unauthenticated requests with 401`, async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInAdminGroupOfFirstConnection(app); + const { connections } = testData; + + const diagramResponse = await request(app.getHttpServer()) + .get(`/connection/diagram/${connections.firstId}`) + .set('Accept', 'application/json'); + + t.is(diagramResponse.status, 401); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should reject requests with a non-existent connection id with 403`, async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInAdminGroupOfFirstConnection(app); + const { + users: { simpleUserToken }, + } = testData; + + const fakeConnectionId = faker.string.uuid(); + const diagramResponse = await request(app.getHttpServer()) + .get(`/connection/diagram/${fakeConnectionId}`) + .set('Cookie', simpleUserToken) + .set('Accept', 'application/json'); + + t.is(diagramResponse.status, 403); + t.is(JSON.parse(diagramResponse.text).message, Messages.DONT_HAVE_PERMISSIONS); + } catch (e) { + console.error(e); + throw e; + } +}); + +//****************************** Raw Cedar policy: connection:diagram is separate from connection:edit ****************************** + +test.serial( + `${currentTest} should allow access when raw Cedar policy grants only connection:diagram (no connection:edit) via Cedar`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + const cedarPolicy = `permit(\n principal,\n action == RocketAdmin::Action::"connection:diagram",\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); + + // User can fetch diagram with only connection:diagram permission + const diagramResponse = await request(app.getHttpServer()) + .get(`/connection/diagram/${connectionId}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Accept', 'application/json'); + + t.is(diagramResponse.status, 200); + t.is(diagramResponse.body.connectionId, connectionId); + + // But the same user cannot edit the connection (no connection:edit) + const updateConnectionDto = { + type: 'postgres', + host: faker.internet.domainName(), + port: 5432, + username: 'updated', + password: 'updated', + database: 'updated', + title: 'updated', + }; + const updateConnectionResponse = await request(app.getHttpServer()) + .put(`/connection/${connectionId}`) + .send(updateConnectionDto) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(updateConnectionResponse.status, 403); + t.is(JSON.parse(updateConnectionResponse.text).message, Messages.DONT_HAVE_PERMISSIONS); + } catch (e) { + console.error(e); + throw e; + } + }, +); + +test.serial( + `${currentTest} should reject access when raw Cedar policy grants only connection:read (no connection:diagram) via Cedar`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + 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); + + const diagramResponse = await request(app.getHttpServer()) + .get(`/connection/diagram/${connectionId}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Accept', 'application/json'); + + t.is(diagramResponse.status, 403); + t.is(JSON.parse(diagramResponse.text).message, Messages.DONT_HAVE_PERMISSIONS); + } catch (e) { + console.error(e); + throw e; + } + }, +);