diff --git a/backend/src/entities/cedar-authorization/cedar-action-map.ts b/backend/src/entities/cedar-authorization/cedar-action-map.ts index 9f1e99a14..2794c7b9b 100644 --- a/backend/src/entities/cedar-authorization/cedar-action-map.ts +++ b/backend/src/entities/cedar-authorization/cedar-action-map.ts @@ -7,12 +7,17 @@ export enum CedarAction { TableAdd = 'table:add', TableEdit = 'table:edit', TableDelete = 'table:delete', + DashboardRead = 'dashboard:read', + DashboardCreate = 'dashboard:create', + DashboardEdit = 'dashboard:edit', + DashboardDelete = 'dashboard:delete', } export enum CedarResourceType { Connection = 'RocketAdmin::Connection', Group = 'RocketAdmin::Group', Table = 'RocketAdmin::Table', + Dashboard = 'RocketAdmin::Dashboard', } export const CEDAR_ACTION_TYPE = 'RocketAdmin::Action'; @@ -25,4 +30,5 @@ export interface CedarValidationRequest { connectionId?: string; groupId?: string; tableName?: string; + dashboardId?: string; } diff --git a/backend/src/entities/cedar-authorization/cedar-authorization.controller.ts b/backend/src/entities/cedar-authorization/cedar-authorization.controller.ts new file mode 100644 index 000000000..b27b93fb4 --- /dev/null +++ b/backend/src/entities/cedar-authorization/cedar-authorization.controller.ts @@ -0,0 +1,88 @@ +import { + Body, + Controller, + Get, + HttpException, + HttpStatus, + Injectable, + Post, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { SlugUuid } from '../../decorators/index.js'; +import { Timeout } from '../../decorators/timeout.decorator.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 { SentryInterceptor } from '../../interceptors/index.js'; +import { IComplexPermission } from '../permission/permission.interface.js'; +import { CedarAuthorizationService } from './cedar-authorization.service.js'; +import { SaveCedarPolicyDto } from './dto/save-cedar-policy.dto.js'; +import { ValidateCedarSchemaDto } from './dto/validate-cedar-schema.dto.js'; + +@UseInterceptors(SentryInterceptor) +@Timeout() +@Controller() +@ApiBearerAuth() +@ApiTags('Cedar Authorization') +@Injectable() +export class CedarAuthorizationController { + constructor(private readonly cedarAuthService: CedarAuthorizationService) {} + + @ApiOperation({ summary: 'Get the current cedar schema used for authorization' }) + @ApiResponse({ + status: 200, + description: 'Cedar schema returned.', + }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionReadGuard) + @Get('/connection/cedar-schema/:connectionId') + async getCedarSchema( + @SlugUuid('connectionId') connectionId: string, + ): Promise<{ cedarSchema: Record }> { + if (!connectionId) { + throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST); + } + return { cedarSchema: this.cedarAuthService.getSchema() }; + } + + @ApiOperation({ summary: 'Validate a cedar schema against the Cedar engine' }) + @ApiResponse({ + status: 200, + description: 'Cedar schema is valid.', + }) + @ApiBody({ type: ValidateCedarSchemaDto }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionReadGuard) + @Post('/connection/cedar-schema/validate/:connectionId') + async validateCedarSchema( + @SlugUuid('connectionId') connectionId: string, + @Body() dto: ValidateCedarSchemaDto, + ): Promise<{ valid: boolean }> { + if (!connectionId) { + throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST); + } + this.cedarAuthService.validateCedarSchema(dto.cedarSchema); + return { valid: true }; + } + + @ApiOperation({ summary: 'Save a cedar policy for a group, generating classical permissions for backward compatibility' }) + @ApiResponse({ + status: 200, + description: 'Cedar policy saved and classical permissions generated.', + }) + @ApiBody({ type: SaveCedarPolicyDto }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionEditGuard) + @Post('/connection/cedar-policy/:connectionId') + async saveCedarPolicy( + @SlugUuid('connectionId') connectionId: string, + @Body() dto: SaveCedarPolicyDto, + ): Promise<{ cedarPolicy: string; classicalPermissions: IComplexPermission }> { + if (!connectionId) { + throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST); + } + return this.cedarAuthService.saveCedarPolicy(connectionId, dto.groupId, dto.cedarPolicy); + } +} diff --git a/backend/src/entities/cedar-authorization/cedar-authorization.module.ts b/backend/src/entities/cedar-authorization/cedar-authorization.module.ts index c69b22c2d..4bc29c781 100644 --- a/backend/src/entities/cedar-authorization/cedar-authorization.module.ts +++ b/backend/src/entities/cedar-authorization/cedar-authorization.module.ts @@ -1,9 +1,34 @@ -import { Global, Module } from '@nestjs/common'; +import { Global, MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthMiddleware } from '../../authorization/auth.middleware.js'; +import { GlobalDatabaseContext } from '../../common/application/global-database-context.js'; +import { BaseType } from '../../common/data-injection.tokens.js'; +import { LogOutEntity } from '../log-out/log-out.entity.js'; +import { UserEntity } from '../user/user.entity.js'; +import { CedarAuthorizationController } from './cedar-authorization.controller.js'; import { CedarAuthorizationService } from './cedar-authorization.service.js'; @Global() @Module({ - providers: [CedarAuthorizationService], + imports: [TypeOrmModule.forFeature([UserEntity, LogOutEntity])], + providers: [ + { + provide: BaseType.GLOBAL_DB_CONTEXT, + useClass: GlobalDatabaseContext, + }, + CedarAuthorizationService, + ], + controllers: [CedarAuthorizationController], exports: [CedarAuthorizationService], }) -export class CedarAuthorizationModule {} +export class CedarAuthorizationModule implements NestModule { + public configure(consumer: MiddlewareConsumer): void { + consumer + .apply(AuthMiddleware) + .forRoutes( + { path: '/connection/cedar-schema/:connectionId', method: RequestMethod.GET }, + { path: '/connection/cedar-schema/validate/:connectionId', method: RequestMethod.POST }, + { path: '/connection/cedar-policy/:connectionId', method: RequestMethod.POST }, + ); + } +} diff --git a/backend/src/entities/cedar-authorization/cedar-authorization.service.interface.ts b/backend/src/entities/cedar-authorization/cedar-authorization.service.interface.ts index 6f26c44c2..d98ecaea5 100644 --- a/backend/src/entities/cedar-authorization/cedar-authorization.service.interface.ts +++ b/backend/src/entities/cedar-authorization/cedar-authorization.service.interface.ts @@ -1,7 +1,15 @@ +import { IComplexPermission } from '../permission/permission.interface.js'; import { CedarValidationRequest } from './cedar-action-map.js'; export interface ICedarAuthorizationService { isFeatureEnabled(): boolean; validate(request: CedarValidationRequest): Promise; invalidatePolicyCacheForConnection(connectionId: string): void; + getSchema(): Record; + validateCedarSchema(schema: Record): void; + saveCedarPolicy( + connectionId: string, + groupId: string, + cedarPolicy: string, + ): Promise<{ cedarPolicy: string; classicalPermissions: IComplexPermission }>; } diff --git a/backend/src/entities/cedar-authorization/cedar-authorization.service.ts b/backend/src/entities/cedar-authorization/cedar-authorization.service.ts index c08782a1f..75e5d478f 100644 --- a/backend/src/entities/cedar-authorization/cedar-authorization.service.ts +++ b/backend/src/entities/cedar-authorization/cedar-authorization.service.ts @@ -1,12 +1,12 @@ import { HttpException, HttpStatus, Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { BaseType } from '../../common/data-injection.tokens.js'; +import { AccessLevelEnum, PermissionTypeEnum } from '../../enums/index.js'; import { Messages } from '../../exceptions/text/messages.js'; import { Cacher } from '../../helpers/cache/cacher.js'; +import { IGlobalDatabaseContext } from '../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../common/data-injection.tokens.js'; import { GroupEntity } from '../group/group.entity.js'; -import { groupCustomRepositoryExtension } from '../group/repository/group-custom-repository-extension.js'; -import { IGroupRepository } from '../group/repository/group.repository.interface.js'; -import { UserEntity } from '../user/user.entity.js'; +import { IComplexPermission } from '../permission/permission.interface.js'; +import { PermissionEntity } from '../permission/permission.entity.js'; import { CedarAction, CedarResourceType, @@ -16,25 +16,23 @@ import { } from './cedar-action-map.js'; import { ICedarAuthorizationService } from './cedar-authorization.service.interface.js'; import { buildCedarEntities } from './cedar-entity-builder.js'; +import { parseCedarPolicyToClassicalPermissions } from './cedar-policy-parser.js'; import { CEDAR_SCHEMA } from './cedar-schema.js'; +import * as cedarWasm from '@cedar-policy/cedar-wasm/nodejs'; @Injectable() export class CedarAuthorizationService implements ICedarAuthorizationService, OnModuleInit { - private cedarModule: typeof import('@cedar-policy/cedar-wasm/nodejs'); private schema: Record; - private groupRepository: IGroupRepository; private readonly logger = new Logger(CedarAuthorizationService.name); constructor( - @Inject(BaseType.DATA_SOURCE) - private readonly dataSource: DataSource, + @Inject(BaseType.GLOBAL_DB_CONTEXT) + private readonly globalDbContext: IGlobalDatabaseContext, ) {} async onModuleInit(): Promise { if (!this.isFeatureEnabled()) return; - this.cedarModule = await import('@cedar-policy/cedar-wasm/nodejs'); this.schema = CEDAR_SCHEMA as Record; - this.groupRepository = this.dataSource.getRepository(GroupEntity).extend(groupCustomRepositoryExtension); this.logger.log('Cedar authorization service initialized'); } @@ -43,7 +41,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On } async validate(request: CedarValidationRequest): Promise { - const { userId, action, groupId, tableName } = request; + const { userId, action, groupId, tableName, dashboardId } = request; let { connectionId } = request; const actionPrefix = action.split(':')[0]; @@ -65,17 +63,104 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On resourceType = CedarResourceType.Table; resourceId = `${connectionId}/${tableName}`; break; + case 'dashboard': + resourceType = CedarResourceType.Dashboard; + resourceId = `${connectionId}/${dashboardId}`; + break; default: return false; } - return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName); + return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, dashboardId); } invalidatePolicyCacheForConnection(connectionId: string): void { Cacher.invalidateCedarPolicyCache(connectionId); } + getSchema(): Record { + return this.schema; + } + + async saveCedarPolicy( + connectionId: string, + groupId: string, + cedarPolicy: string, + ): Promise<{ cedarPolicy: string; classicalPermissions: IComplexPermission }> { + this.validateCedarPolicyText(cedarPolicy); + + const group = await this.globalDbContext.groupRepository.findGroupWithPermissionsById(groupId); + if (!group) { + throw new HttpException({ message: Messages.GROUP_NOT_FOUND }, HttpStatus.BAD_REQUEST); + } + + const groupWithConnection = await this.globalDbContext.groupRepository.findGroupByIdWithConnectionAndUsers(groupId); + + if (groupWithConnection?.connection?.id !== connectionId) { + throw new HttpException({ message: Messages.GROUP_NOT_FROM_THIS_CONNECTION }, HttpStatus.BAD_REQUEST); + } + + if (group.isMain) { + throw new HttpException({ message: Messages.CANNOT_CHANGE_ADMIN_GROUP }, HttpStatus.BAD_REQUEST); + } + + await this.validatePolicyReferences(cedarPolicy, connectionId, groupId); + + const classicalPermissions = parseCedarPolicyToClassicalPermissions(cedarPolicy, connectionId, groupId); + + await this.syncClassicalPermissions(group, classicalPermissions); + + group.cedarPolicy = cedarPolicy; + await this.globalDbContext.groupRepository.saveNewOrUpdatedGroup(group); + Cacher.invalidateCedarPolicyCache(connectionId); + + return { cedarPolicy, classicalPermissions }; + } + + validateCedarSchema(schema: Record): void { + if (!schema || typeof schema !== 'object') { + throw new HttpException({ message: 'Cedar schema must be a valid JSON object' }, HttpStatus.BAD_REQUEST); + } + + const namespaces = Object.keys(schema); + if (namespaces.length === 0) { + throw new HttpException({ message: 'Cedar schema must contain at least one namespace' }, HttpStatus.BAD_REQUEST); + } + + for (const ns of namespaces) { + const namespace = schema[ns] as Record; + if (!namespace || typeof namespace !== 'object') { + throw new HttpException({ message: `Namespace "${ns}" must be an object` }, HttpStatus.BAD_REQUEST); + } + + if (!namespace.entityTypes || typeof namespace.entityTypes !== 'object') { + throw new HttpException( + { message: `Namespace "${ns}" must contain "entityTypes" object` }, + HttpStatus.BAD_REQUEST, + ); + } + + if (!namespace.actions || typeof namespace.actions !== 'object') { + throw new HttpException({ message: `Namespace "${ns}" must contain "actions" object` }, HttpStatus.BAD_REQUEST); + } + } + + try { + const testCall = { + principal: { type: 'RocketAdmin::User', id: 'test' }, + action: { type: 'RocketAdmin::Action', id: 'connection:read' }, + resource: { type: 'RocketAdmin::Connection', id: 'test' }, + context: {}, + policies: { staticPolicies: 'permit(principal, action, resource);' }, + entities: [], + schema: schema, + }; + cedarWasm.isAuthorized(testCall as Parameters[0]); + } catch (e) { + throw new HttpException({ message: `Invalid cedar schema: ${e.message}` }, HttpStatus.BAD_REQUEST); + } + } + private async evaluate( userId: string, connectionId: string, @@ -83,16 +168,17 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On resourceType: CedarResourceType, resourceId: string, tableName?: string, + dashboardId?: string, ): Promise { await this.assertUserNotSuspended(userId); - const userGroups = await this.groupRepository.findAllUserGroupsInConnection(connectionId, userId); + const userGroups = await this.globalDbContext.groupRepository.findAllUserGroupsInConnection(connectionId, userId); if (userGroups.length === 0) return false; const policies = await this.loadPoliciesForConnection(connectionId); if (!policies) return false; - const entities = buildCedarEntities(userId, userGroups, connectionId, tableName); + const entities = buildCedarEntities(userId, userGroups, connectionId, tableName, dashboardId); const call = { principal: { type: CEDAR_USER_TYPE, id: userId }, @@ -104,7 +190,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On schema: this.schema, }; - const result = this.cedarModule.isAuthorized(call as Parameters[0]); + const result = cedarWasm.isAuthorized(call as Parameters[0]); if (result.type === 'success') { return result.response.decision === 'allow'; } @@ -117,7 +203,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On const cached = Cacher.getCedarPolicyCache(connectionId); if (cached !== null) return cached; - const groups = await this.groupRepository.findAllGroupsInConnection(connectionId); + const groups = await this.globalDbContext.groupRepository.findAllGroupsInConnection(connectionId); const policyTexts = groups.map((g) => g.cedarPolicy).filter(Boolean); if (policyTexts.length === 0) return null; @@ -128,7 +214,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On } private async assertUserNotSuspended(userId: string): Promise { - const user = await this.dataSource.getRepository(UserEntity).findOne({ + const user = await this.globalDbContext.userRepository.findOne({ where: { id: userId }, select: ['id', 'suspended'], }); @@ -143,12 +229,176 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On } private async getConnectionIdForGroup(groupId: string): Promise { - const group = await this.dataSource - .getRepository(GroupEntity) - .createQueryBuilder('group') - .leftJoinAndSelect('group.connection', 'connection') - .where('group.id = :groupId', { groupId }) - .getOne(); + const group = await this.globalDbContext.groupRepository.findGroupByIdWithConnectionAndUsers(groupId); return group?.connection?.id ?? null; } + + private validateCedarPolicyText(policyText: string): void { + if (!policyText || typeof policyText !== 'string' || policyText.trim().length === 0) { + throw new HttpException({ message: 'Cedar policy must be a non-empty string' }, HttpStatus.BAD_REQUEST); + } + + try { + const testCall = { + principal: { type: 'RocketAdmin::User', id: 'test' }, + action: { type: 'RocketAdmin::Action', id: 'connection:read' }, + resource: { type: 'RocketAdmin::Connection', id: 'test' }, + context: {}, + policies: { staticPolicies: policyText }, + entities: [], + schema: this.schema, + }; + cedarWasm.isAuthorized(testCall as Parameters[0]); + } catch (e) { + throw new HttpException({ message: `Invalid cedar policy: ${e.message}` }, HttpStatus.BAD_REQUEST); + } + } + + private async validatePolicyReferences( + cedarPolicy: string, + connectionId: string, + groupId: string, + ): Promise { + + const principalGroupIds = [ + ...cedarPolicy.matchAll(/principal\s+in\s+RocketAdmin::Group::"([^"]+)"/g), + ].map((m) => m[1]); + + for (const principalGroupId of principalGroupIds) { + if (principalGroupId !== groupId) { + throw new HttpException( + { message: Messages.CEDAR_POLICY_REFERENCES_FOREIGN_PRINCIPAL }, + HttpStatus.BAD_REQUEST, + ); + } + } + + const connectionIds = [ + ...cedarPolicy.matchAll(/resource\s*==\s*RocketAdmin::Connection::"([^"]+)"/g), + ].map((m) => m[1]); + + for (const refConnectionId of connectionIds) { + if (refConnectionId !== connectionId) { + throw new HttpException( + { message: Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION }, + HttpStatus.BAD_REQUEST, + ); + } + } + + const groupResourceIds = [ + ...cedarPolicy.matchAll(/resource\s*==\s*RocketAdmin::Group::"([^"]+)"/g), + ].map((m) => m[1]); + + if (groupResourceIds.length > 0) { + const connectionGroups = await this.globalDbContext.groupRepository.findAllGroupsInConnection(connectionId); + const connectionGroupIds = new Set(connectionGroups.map((g) => g.id)); + + for (const refGroupId of groupResourceIds) { + if (!connectionGroupIds.has(refGroupId)) { + throw new HttpException( + { message: Messages.CEDAR_POLICY_REFERENCES_FOREIGN_GROUP }, + HttpStatus.BAD_REQUEST, + ); + } + } + } + + const tableResourceIds = [ + ...cedarPolicy.matchAll(/resource\s*==\s*RocketAdmin::Table::"([^"]+)"/g), + ].map((m) => m[1]); + + for (const tableRef of tableResourceIds) { + if (!tableRef.startsWith(`${connectionId}/`)) { + throw new HttpException( + { message: Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION }, + HttpStatus.BAD_REQUEST, + ); + } + } + + const dashboardResourceIds = [ + ...cedarPolicy.matchAll(/resource\s*==\s*RocketAdmin::Dashboard::"([^"]+)"/g), + ].map((m) => m[1]); + + for (const dashboardRef of dashboardResourceIds) { + if (!dashboardRef.startsWith(`${connectionId}/`)) { + throw new HttpException( + { message: Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION }, + HttpStatus.BAD_REQUEST, + ); + } + } + } + + private async syncClassicalPermissions(group: GroupEntity, permissions: IComplexPermission): Promise { + if (group.permissions && group.permissions.length > 0) { + for (const perm of group.permissions) { + await this.globalDbContext.permissionRepository.removePermissionEntity(perm); + } + } + group.permissions = []; + + if (permissions.connection.accessLevel !== AccessLevelEnum.none) { + const connPerm = new PermissionEntity(); + connPerm.type = PermissionTypeEnum.Connection; + connPerm.accessLevel = permissions.connection.accessLevel; + const saved = await this.globalDbContext.permissionRepository.saveNewOrUpdatedPermission(connPerm); + group.permissions.push(saved); + } + + if (permissions.group.accessLevel !== AccessLevelEnum.none) { + const groupPerm = new PermissionEntity(); + groupPerm.type = PermissionTypeEnum.Group; + groupPerm.accessLevel = permissions.group.accessLevel; + const saved = await this.globalDbContext.permissionRepository.saveNewOrUpdatedPermission(groupPerm); + group.permissions.push(saved); + } + + for (const table of permissions.tables) { + const access = table.accessLevel; + if (access.visibility) { + const perm = new PermissionEntity(); + perm.type = PermissionTypeEnum.Table; + perm.accessLevel = AccessLevelEnum.visibility; + perm.tableName = table.tableName; + const saved = await this.globalDbContext.permissionRepository.saveNewOrUpdatedPermission(perm); + group.permissions.push(saved); + } + if (access.readonly) { + const perm = new PermissionEntity(); + perm.type = PermissionTypeEnum.Table; + perm.accessLevel = AccessLevelEnum.readonly; + perm.tableName = table.tableName; + const saved = await this.globalDbContext.permissionRepository.saveNewOrUpdatedPermission(perm); + group.permissions.push(saved); + } + if (access.add) { + const perm = new PermissionEntity(); + perm.type = PermissionTypeEnum.Table; + perm.accessLevel = AccessLevelEnum.add; + perm.tableName = table.tableName; + const saved = await this.globalDbContext.permissionRepository.saveNewOrUpdatedPermission(perm); + group.permissions.push(saved); + } + if (access.edit) { + const perm = new PermissionEntity(); + perm.type = PermissionTypeEnum.Table; + perm.accessLevel = AccessLevelEnum.edit; + perm.tableName = table.tableName; + const saved = await this.globalDbContext.permissionRepository.saveNewOrUpdatedPermission(perm); + group.permissions.push(saved); + } + if (access.delete) { + const perm = new PermissionEntity(); + perm.type = PermissionTypeEnum.Table; + perm.accessLevel = AccessLevelEnum.delete; + perm.tableName = table.tableName; + const saved = await this.globalDbContext.permissionRepository.saveNewOrUpdatedPermission(perm); + group.permissions.push(saved); + } + } + + await this.globalDbContext.groupRepository.saveNewOrUpdatedGroup(group); + } } diff --git a/backend/src/entities/cedar-authorization/cedar-entity-builder.ts b/backend/src/entities/cedar-authorization/cedar-entity-builder.ts index 5200fd777..52ac35bc4 100644 --- a/backend/src/entities/cedar-authorization/cedar-entity-builder.ts +++ b/backend/src/entities/cedar-authorization/cedar-entity-builder.ts @@ -11,6 +11,7 @@ export function buildCedarEntities( userGroups: Array, connectionId: string, tableName?: string, + dashboardId?: string, ): Array { const entities: Array = []; @@ -49,5 +50,13 @@ export function buildCedarEntities( }); } + if (dashboardId) { + entities.push({ + uid: { type: 'RocketAdmin::Dashboard', id: `${connectionId}/${dashboardId}` }, + 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 9472f4ddd..51356d9aa 100644 --- a/backend/src/entities/cedar-authorization/cedar-policy-generator.ts +++ b/backend/src/entities/cedar-authorization/cedar-policy-generator.ts @@ -49,7 +49,34 @@ export function generateCedarPolicyForGroup( ); } - // Table permissions + if (permissions.dashboards) { + for (const dashboard of permissions.dashboards) { + const dashboardRef = `RocketAdmin::Dashboard::"${connectionId}/${dashboard.dashboardId}"`; + const access = dashboard.accessLevel; + + if (access.read) { + policies.push( + `permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"dashboard:read",\n resource == ${dashboardRef}\n);`, + ); + } + if (access.create) { + policies.push( + `permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"dashboard:create",\n resource == ${dashboardRef}\n);`, + ); + } + if (access.edit) { + policies.push( + `permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"dashboard:edit",\n resource == ${dashboardRef}\n);`, + ); + } + if (access.delete) { + policies.push( + `permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"dashboard:delete",\n resource == ${dashboardRef}\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 new file mode 100644 index 000000000..c13d04d7f --- /dev/null +++ b/backend/src/entities/cedar-authorization/cedar-policy-parser.ts @@ -0,0 +1,268 @@ +import { AccessLevelEnum } from '../../enums/index.js'; +import { + IComplexPermission, + IDashboardPermissionData, + ITablePermissionData, +} from '../permission/permission.interface.js'; + +interface ParsedPermitStatement { + groupId: string | null; + action: string | null; + resourceType: string | null; + resourceId: string | null; + isWildcard: boolean; +} + +export function parseCedarPolicyToClassicalPermissions( + policyText: string, + connectionId: string, + groupId: string, +): IComplexPermission { + const permits = extractPermitStatements(policyText); + + const result: IComplexPermission = { + connection: { connectionId, accessLevel: AccessLevelEnum.none }, + group: { groupId, accessLevel: AccessLevelEnum.none }, + tables: [], + dashboards: [], + }; + + const tableMap = new Map(); + const dashboardMap = new Map(); + + for (const permit of permits) { + if (permit.isWildcard) { + result.connection.accessLevel = AccessLevelEnum.edit; + result.group.accessLevel = AccessLevelEnum.edit; + continue; + } + + if (!permit.action) continue; + + switch (permit.action) { + case 'connection:read': + if (result.connection.accessLevel === AccessLevelEnum.none) { + result.connection.accessLevel = AccessLevelEnum.readonly; + } + break; + case 'connection:edit': + result.connection.accessLevel = AccessLevelEnum.edit; + break; + case 'group:read': + if (result.group.accessLevel === AccessLevelEnum.none) { + result.group.accessLevel = AccessLevelEnum.readonly; + } + break; + case 'group:edit': + result.group.accessLevel = AccessLevelEnum.edit; + break; + case 'table:read': + case 'table:add': + case 'table:edit': + case 'table:delete': { + const tableName = extractTableName(permit.resourceId, connectionId); + if (!tableName) break; + const tableEntry = getOrCreateTableEntry(tableMap, tableName); + applyTableAction(tableEntry, permit.action); + break; + } + case 'dashboard:read': + case 'dashboard:create': + case 'dashboard:edit': + case 'dashboard:delete': { + const dashboardId = extractDashboardId(permit.resourceId, connectionId); + if (!dashboardId) break; + const dashboardEntry = getOrCreateDashboardEntry(dashboardMap, dashboardId); + applyDashboardAction(dashboardEntry, permit.action); + break; + } + } + } + + result.tables = Array.from(tableMap.values()); + result.dashboards = Array.from(dashboardMap.values()); + + return result; +} + +function extractPermitStatements(policyText: string): ParsedPermitStatement[] { + const results: ParsedPermitStatement[] = []; + const permitKeyword = 'permit'; + let searchFrom = 0; + + while (searchFrom < policyText.length) { + const permitIndex = policyText.indexOf(permitKeyword, searchFrom); + if (permitIndex === -1) break; + + let i = permitIndex + permitKeyword.length; + // Skip whitespace after "permit" + while (i < policyText.length && (policyText[i] === ' ' || policyText[i] === '\t' || policyText[i] === '\n' || policyText[i] === '\r')) { + i++; + } + + if (i >= policyText.length || policyText[i] !== '(') { + searchFrom = i; + continue; + } + + // Find matching closing parenthesis (handle nesting) + let depth = 1; + const bodyStart = i + 1; + i++; + while (i < policyText.length && depth > 0) { + if (policyText[i] === '(') depth++; + else if (policyText[i] === ')') depth--; + if (depth > 0) i++; + } + + if (depth !== 0) { + searchFrom = bodyStart; + continue; + } + + const body = policyText.slice(bodyStart, i); + // Skip past ')' and optional whitespace, expect ';' + let j = i + 1; + while (j < policyText.length && (policyText[j] === ' ' || policyText[j] === '\t' || policyText[j] === '\n' || policyText[j] === '\r')) { + j++; + } + + if (j < policyText.length && policyText[j] === ';') { + results.push(parsePermitBody(body)); + searchFrom = j + 1; + } else { + searchFrom = i + 1; + } + } + + return results; +} + +function parsePermitBody(body: string): ParsedPermitStatement { + const result: ParsedPermitStatement = { + groupId: null, + action: null, + resourceType: null, + resourceId: null, + isWildcard: false, + }; + + const principalMatch = body.match(/principal\s+in\s+RocketAdmin::Group::"([^"]+)"/); + if (principalMatch) { + result.groupId = principalMatch[1]; + } + + const actionMatch = body.match(/action\s*==\s*RocketAdmin::Action::"([^"]+)"/); + if (actionMatch) { + result.action = actionMatch[1]; + } else { + const actionClause = body.match(/,\s*(action)\s*,/); + if (actionClause) { + result.isWildcard = true; + } + } + + const resourceMatch = body.match(/resource\s*==\s*(RocketAdmin::\w+)::"([^"]+)"/); + if (resourceMatch) { + result.resourceType = resourceMatch[1]; + result.resourceId = resourceMatch[2]; + } else { + const resourceClause = body.match(/,\s*(resource)\s*$/m); + if (resourceClause && !result.action) { + result.isWildcard = true; + } + } + + return result; +} + +function extractTableName(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 extractDashboardId(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 getOrCreateTableEntry(map: Map, tableName: string): ITablePermissionData { + let entry = map.get(tableName); + if (!entry) { + entry = { + tableName, + accessLevel: { + visibility: false, + readonly: false, + add: false, + delete: false, + edit: false, + }, + }; + map.set(tableName, entry); + } + return entry; +} + +function applyTableAction(entry: ITablePermissionData, action: string): void { + switch (action) { + case 'table:read': + entry.accessLevel.visibility = true; + entry.accessLevel.readonly = true; + break; + case 'table:add': + entry.accessLevel.add = true; + break; + case 'table:edit': + entry.accessLevel.edit = true; + break; + case 'table:delete': + entry.accessLevel.delete = true; + break; + } +} + +function getOrCreateDashboardEntry( + map: Map, + dashboardId: string, +): IDashboardPermissionData { + let entry = map.get(dashboardId); + if (!entry) { + entry = { + dashboardId, + accessLevel: { + read: false, + create: false, + edit: false, + delete: false, + }, + }; + map.set(dashboardId, entry); + } + return entry; +} + +function applyDashboardAction(entry: IDashboardPermissionData, action: string): void { + switch (action) { + case 'dashboard:read': + entry.accessLevel.read = true; + break; + case 'dashboard:create': + entry.accessLevel.create = true; + break; + case 'dashboard:edit': + entry.accessLevel.edit = true; + break; + case 'dashboard:delete': + entry.accessLevel.delete = true; + break; + } +} diff --git a/backend/src/entities/cedar-authorization/cedar-schema.json b/backend/src/entities/cedar-authorization/cedar-schema.json index c8aca55c7..5cc4738dc 100644 --- a/backend/src/entities/cedar-authorization/cedar-schema.json +++ b/backend/src/entities/cedar-authorization/cedar-schema.json @@ -35,6 +35,15 @@ "connectionId": { "type": "String" } } } + }, + "Dashboard": { + "memberOfTypes": ["Connection"], + "shape": { + "type": "Record", + "attributes": { + "connectionId": { "type": "String" } + } + } } }, "actions": { @@ -85,6 +94,30 @@ "principalTypes": ["User"], "resourceTypes": ["Table"] } + }, + "dashboard:read": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Dashboard"] + } + }, + "dashboard:create": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Dashboard"] + } + }, + "dashboard:edit": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Dashboard"] + } + }, + "dashboard:delete": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Dashboard"] + } } } } diff --git a/backend/src/entities/cedar-authorization/cedar-schema.ts b/backend/src/entities/cedar-authorization/cedar-schema.ts index d8452d09f..2a76064a2 100644 --- a/backend/src/entities/cedar-authorization/cedar-schema.ts +++ b/backend/src/entities/cedar-authorization/cedar-schema.ts @@ -36,6 +36,15 @@ export const CEDAR_SCHEMA = { }, }, }, + Dashboard: { + memberOfTypes: ['Connection'], + shape: { + type: 'Record', + attributes: { + connectionId: { type: 'String' }, + }, + }, + }, }, actions: { 'connection:read': { @@ -86,6 +95,30 @@ export const CEDAR_SCHEMA = { resourceTypes: ['Table'], }, }, + 'dashboard:read': { + appliesTo: { + principalTypes: ['User'], + resourceTypes: ['Dashboard'], + }, + }, + 'dashboard:create': { + appliesTo: { + principalTypes: ['User'], + resourceTypes: ['Dashboard'], + }, + }, + 'dashboard:edit': { + appliesTo: { + principalTypes: ['User'], + resourceTypes: ['Dashboard'], + }, + }, + 'dashboard:delete': { + appliesTo: { + principalTypes: ['User'], + resourceTypes: ['Dashboard'], + }, + }, }, }, }; diff --git a/backend/src/entities/cedar-authorization/dto/save-cedar-policy.dto.ts b/backend/src/entities/cedar-authorization/dto/save-cedar-policy.dto.ts new file mode 100644 index 000000000..55653e487 --- /dev/null +++ b/backend/src/entities/cedar-authorization/dto/save-cedar-policy.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; + +export class SaveCedarPolicyDto { + @ApiProperty({ + description: 'Cedar policy in Cedar Policy Language format', + example: 'permit(\n principal in RocketAdmin::Group::"group-uuid",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"conn-uuid"\n);', + }) + @IsNotEmpty() + @IsString() + cedarPolicy: string; + + @ApiProperty({ + description: 'Group ID to apply the cedar policy to', + }) + @IsNotEmpty() + @IsUUID() + groupId: string; +} diff --git a/backend/src/entities/cedar-authorization/dto/validate-cedar-schema.dto.ts b/backend/src/entities/cedar-authorization/dto/validate-cedar-schema.dto.ts new file mode 100644 index 000000000..8f33149d7 --- /dev/null +++ b/backend/src/entities/cedar-authorization/dto/validate-cedar-schema.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsObject } from 'class-validator'; + +export class ValidateCedarSchemaDto { + @ApiProperty({ + description: 'Cedar schema in JSON format', + example: { + RocketAdmin: { + entityTypes: {}, + actions: {}, + }, + }, + }) + @IsNotEmpty() + @IsObject() + cedarSchema: Record; +} diff --git a/backend/src/entities/permission/permission.interface.ts b/backend/src/entities/permission/permission.interface.ts index bcc792ef7..d8e38a5d7 100644 --- a/backend/src/entities/permission/permission.interface.ts +++ b/backend/src/entities/permission/permission.interface.ts @@ -4,6 +4,7 @@ export interface IComplexPermission { connection: IConnectionPermissionData; group: IGroupPermissionData; tables: Array; + dashboards?: Array; } export interface IConnectionPermissionData { @@ -32,3 +33,15 @@ export interface ITablePermissionData { export interface ITableAndViewPermissionData extends ITablePermissionData { isView: boolean; } + +export interface IDashboardAccessLevel { + read: boolean; + create: boolean; + edit: boolean; + delete: boolean; +} + +export interface IDashboardPermissionData { + dashboardId: string; + accessLevel: IDashboardAccessLevel; +} diff --git a/backend/src/entities/visualizations/dashboard/dashboards.controller.ts b/backend/src/entities/visualizations/dashboard/dashboards.controller.ts index e981788f5..52e93f696 100644 --- a/backend/src/entities/visualizations/dashboard/dashboards.controller.ts +++ b/backend/src/entities/visualizations/dashboard/dashboards.controller.ts @@ -21,8 +21,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 { DashboardCreateGuard } from '../../../guards/dashboard-create.guard.js'; +import { DashboardEditGuard } from '../../../guards/dashboard-edit.guard.js'; +import { DashboardReadGuard } from '../../../guards/dashboard-read.guard.js'; import { SentryInterceptor } from '../../../interceptors/sentry.interceptor.js'; import { CreateDashboardDs } from './data-structures/create-dashboard.ds.js'; import { FindAllDashboardsDs } from './data-structures/find-all-dashboards.ds.js'; @@ -67,7 +68,7 @@ export class DashboardController { isArray: true, }) @ApiParam({ name: 'connectionId', required: true }) - @UseGuards(ConnectionReadGuard) + @UseGuards(DashboardReadGuard) @Get('/dashboards/:connectionId') async findAllDashboards( @SlugUuid('connectionId') connectionId: string, @@ -98,7 +99,7 @@ export class DashboardController { }) @ApiParam({ name: 'dashboardId', required: true }) @ApiParam({ name: 'connectionId', required: true }) - @UseGuards(ConnectionReadGuard) + @UseGuards(DashboardReadGuard) @Get('/dashboard/:dashboardId/:connectionId') async findDashboard( @SlugUuid('connectionId') connectionId: string, @@ -123,7 +124,7 @@ export class DashboardController { }) @ApiBody({ type: CreateDashboardDto }) @ApiParam({ name: 'connectionId', required: true }) - @UseGuards(ConnectionEditGuard) + @UseGuards(DashboardCreateGuard) @Post('/dashboards/:connectionId') async createDashboard( @SlugUuid('connectionId') connectionId: string, @@ -158,7 +159,7 @@ export class DashboardController { @ApiBody({ type: UpdateDashboardDto }) @ApiParam({ name: 'dashboardId', required: true }) @ApiParam({ name: 'connectionId', required: true }) - @UseGuards(ConnectionEditGuard) + @UseGuards(DashboardEditGuard) @Put('/dashboard/:dashboardId/:connectionId') async updateDashboard( @SlugUuid('connectionId') connectionId: string, @@ -186,7 +187,7 @@ export class DashboardController { }) @ApiParam({ name: 'dashboardId', required: true }) @ApiParam({ name: 'connectionId', required: true }) - @UseGuards(ConnectionEditGuard) + @UseGuards(DashboardEditGuard) @Delete('/dashboard/:dashboardId/:connectionId') async deleteDashboard( @SlugUuid('connectionId') connectionId: string, diff --git a/backend/src/exceptions/text/messages.ts b/backend/src/exceptions/text/messages.ts index 23abdd172..c28400c9e 100644 --- a/backend/src/exceptions/text/messages.ts +++ b/backend/src/exceptions/text/messages.ts @@ -98,6 +98,9 @@ export const Messages = { CUSTOM_FIELD_TEXT_MISSING: 'Custom field text is missing', CUSTOM_FIELD_TYPE_INCORRECT: 'Unsupported custom field type', CUSTOM_FIELD_TYPE_MISSING: 'Custom field type is missing', + CEDAR_POLICY_REFERENCES_FOREIGN_GROUP: 'Cedar policy references a group that does not belong to this connection', + CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION: 'Cedar policy references a connection that does not match the target connection', + CEDAR_POLICY_REFERENCES_FOREIGN_PRINCIPAL: 'Cedar policy principal must reference the target group', CSV_EXPORT_FAILED: 'CSV export failed', CSV_EXPORT_DISABLED: 'CSV export is disabled', CSV_IMPORT_FAILED: 'CSV import failed', diff --git a/backend/src/guards/dashboard-create.guard.ts b/backend/src/guards/dashboard-create.guard.ts new file mode 100644 index 000000000..b5f9a8cf8 --- /dev/null +++ b/backend/src/guards/dashboard-create.guard.ts @@ -0,0 +1,85 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Inject, + Injectable, + Logger, +} 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 DashboardCreateGuard implements CanActivate { + private readonly logger = new Logger(DashboardCreateGuard.name); + + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + 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; + } + + if (this.cedarAuthService.isFeatureEnabled()) { + try { + const allowed = await this.cedarAuthService.validate({ + userId: cognitoUserName, + action: CedarAction.DashboardCreate, + connectionId, + }); + if (allowed) { + resolve(true); + return; + } + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } catch (e) { + if (e instanceof ForbiddenException || e?.status === 403) { + reject(e); + return; + } + this.logger.error(`Cedar authorization error, falling back to legacy: ${e.message}`); + } + } + + // Legacy authorization fallback - dashboards use connection-level edit access + let userConnectionEdit = false; + try { + userConnectionEdit = await this._dbContext.userAccessRepository.checkUserConnectionEdit( + cognitoUserName, + connectionId, + ); + } catch (e) { + reject(e); + return; + } + if (userConnectionEdit) { + resolve(true); + return; + } else { + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + }); + } +} diff --git a/backend/src/guards/dashboard-edit.guard.ts b/backend/src/guards/dashboard-edit.guard.ts new file mode 100644 index 000000000..71f22bcf9 --- /dev/null +++ b/backend/src/guards/dashboard-edit.guard.ts @@ -0,0 +1,90 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Inject, + Injectable, + Logger, +} 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 DashboardEditGuard implements CanActivate { + private readonly logger = new Logger(DashboardEditGuard.name); + + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + 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; + } + + const dashboardId: string = request.params?.dashboardId; + const action = request.method === 'DELETE' ? CedarAction.DashboardDelete : CedarAction.DashboardEdit; + + // Cedar-first authorization + if (this.cedarAuthService.isFeatureEnabled()) { + try { + const allowed = await this.cedarAuthService.validate({ + userId: cognitoUserName, + action, + connectionId, + dashboardId, + }); + if (allowed) { + resolve(true); + return; + } + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } catch (e) { + if (e instanceof ForbiddenException || e?.status === 403) { + reject(e); + return; + } + this.logger.error(`Cedar authorization error, falling back to legacy: ${e.message}`); + } + } + + // Legacy authorization fallback - dashboards use connection-level edit access + let userConnectionEdit = false; + try { + userConnectionEdit = await this._dbContext.userAccessRepository.checkUserConnectionEdit( + cognitoUserName, + connectionId, + ); + } catch (e) { + reject(e); + return; + } + if (userConnectionEdit) { + resolve(true); + return; + } else { + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + }); + } +} diff --git a/backend/src/guards/dashboard-read.guard.ts b/backend/src/guards/dashboard-read.guard.ts new file mode 100644 index 000000000..8636a2009 --- /dev/null +++ b/backend/src/guards/dashboard-read.guard.ts @@ -0,0 +1,89 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Inject, + Injectable, + Logger, +} 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 DashboardReadGuard implements CanActivate { + private readonly logger = new Logger(DashboardReadGuard.name); + + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + 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 dashboardId: string = request.params?.dashboardId; + + // Cedar-first authorization + if (this.cedarAuthService.isFeatureEnabled()) { + try { + const allowed = await this.cedarAuthService.validate({ + userId: cognitoUserName, + action: CedarAction.DashboardRead, + connectionId, + dashboardId, + }); + if (allowed) { + resolve(true); + return; + } + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } catch (e) { + if (e instanceof ForbiddenException || e?.status === 403) { + reject(e); + return; + } + this.logger.error(`Cedar authorization error, falling back to legacy: ${e.message}`); + } + } + + // Legacy authorization fallback - dashboards use connection-level read access + let userConnectionRead = false; + try { + userConnectionRead = await this._dbContext.userAccessRepository.checkUserConnectionRead( + cognitoUserName, + connectionId, + ); + } catch (e) { + reject(e); + return; + } + if (userConnectionRead) { + resolve(true); + return; + } else { + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + }); + } +} diff --git a/backend/src/guards/index.ts b/backend/src/guards/index.ts index 6833c7d72..cded724f3 100644 --- a/backend/src/guards/index.ts +++ b/backend/src/guards/index.ts @@ -1,5 +1,8 @@ export { ConnectionEditGuard } from './connection-edit.guard.js'; export { ConnectionReadGuard } from './connection-read.guard.js'; +export { DashboardCreateGuard } from './dashboard-create.guard.js'; +export { DashboardEditGuard } from './dashboard-edit.guard.js'; +export { DashboardReadGuard } from './dashboard-read.guard.js'; export { GroupEditGuard } from './group-edit.guard.js'; export { GroupReadGuard } from './group-read.guard.js'; export { TableAddGuard } from './table-add.guard.js'; diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-entity-builder.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-entity-builder.test.ts index dd913cef3..1a5e51ee5 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-entity-builder.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-entity-builder.test.ts @@ -83,3 +83,29 @@ test('empty groups array means user has no parents', (t) => { t.truthy(userEntity); t.deepEqual(userEntity.parents, []); }); + +test('dashboard entity created when dashboardId provided with correct id and parent', (t) => { + const dashboardId = 'dash-1'; + const entities = buildCedarEntities(userId, [makeGroup('g1', false)], connectionId, undefined, dashboardId); + const dashEntity = entities.find((e) => e.uid.type === 'RocketAdmin::Dashboard'); + t.truthy(dashEntity); + t.is(dashEntity.uid.id, `${connectionId}/${dashboardId}`); + t.is(dashEntity.attrs.connectionId, connectionId); + t.deepEqual(dashEntity.parents, [{ type: 'RocketAdmin::Connection', id: connectionId }]); +}); + +test('no dashboard entity when dashboardId omitted', (t) => { + const entities = buildCedarEntities(userId, [makeGroup('g1', false)], connectionId); + const dashEntity = entities.find((e) => e.uid.type === 'RocketAdmin::Dashboard'); + t.falsy(dashEntity); +}); + +test('both table and dashboard entities created when both provided', (t) => { + const entities = buildCedarEntities(userId, [makeGroup('g1', false)], connectionId, 'users', 'dash-1'); + const tableEntity = entities.find((e) => e.uid.type === 'RocketAdmin::Table'); + const dashEntity = entities.find((e) => e.uid.type === 'RocketAdmin::Dashboard'); + t.truthy(tableEntity); + t.truthy(dashEntity); + // 1 user + 1 group + 1 connection + 1 table + 1 dashboard = 5 + t.is(entities.length, 5); +}); 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 544aac706..5b0b59595 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 @@ -212,6 +212,85 @@ test('multiple tables generate separate policies per table with correct resource t.is(permits.length, 3); }); +test('dashboard with read=true generates only dashboard:read', (t) => { + const result = generateCedarPolicyForGroup( + groupId, + connectionId, + false, + makePermissions({ + dashboards: [ + { + dashboardId: 'dash-1', + accessLevel: { read: true, create: false, edit: false, delete: false }, + }, + ], + }), + ); + t.true(result.includes('action == RocketAdmin::Action::"dashboard:read"')); + t.false(result.includes('dashboard:create')); + t.false(result.includes('dashboard:edit')); + t.false(result.includes('dashboard:delete')); + const permits = result.match(/permit\(/g); + t.is(permits.length, 1); +}); + +test('dashboard with all flags true generates dashboard:read + dashboard:create + dashboard:edit + dashboard:delete', (t) => { + const result = generateCedarPolicyForGroup( + groupId, + connectionId, + false, + makePermissions({ + dashboards: [ + { + dashboardId: 'dash-1', + accessLevel: { read: true, create: true, edit: true, delete: true }, + }, + ], + }), + ); + t.true(result.includes('dashboard:read')); + t.true(result.includes('dashboard:create')); + t.true(result.includes('dashboard:edit')); + t.true(result.includes('dashboard:delete')); + const permits = result.match(/permit\(/g); + t.is(permits.length, 4); +}); + +test('dashboard with all flags false generates no policies for that dashboard', (t) => { + const result = generateCedarPolicyForGroup( + groupId, + connectionId, + false, + makePermissions({ + dashboards: [ + { + dashboardId: 'dash-1', + accessLevel: { read: false, create: false, edit: false, delete: false }, + }, + ], + }), + ); + t.false(result.includes('dashboard:')); + t.is(result, ''); +}); + +test('dashboard resource ref format uses connectionId/dashboardId', (t) => { + const result = generateCedarPolicyForGroup( + groupId, + connectionId, + false, + makePermissions({ + dashboards: [ + { + dashboardId: 'dash-1', + accessLevel: { read: true, create: false, edit: false, delete: false }, + }, + ], + }), + ); + t.true(result.includes(`RocketAdmin::Dashboard::"${connectionId}/dash-1"`)); +}); + test('resource ref format validation', (t) => { const result = generateCedarPolicyForGroup( groupId, diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-parser.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-parser.test.ts new file mode 100644 index 000000000..0da498da6 --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-parser.test.ts @@ -0,0 +1,146 @@ +import test from 'ava'; +import { parseCedarPolicyToClassicalPermissions } from '../../../src/entities/cedar-authorization/cedar-policy-parser.js'; +import { AccessLevelEnum } from '../../../src/enums/index.js'; + +const groupId = 'test-group-id'; +const connectionId = 'test-connection-id'; + +test('parses connection:read into readonly access', (t) => { + const policy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`; + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.connection.accessLevel, AccessLevelEnum.readonly); + t.is(result.connection.connectionId, connectionId); +}); + +test('parses connection:read + connection:edit into edit access', (t) => { + const policy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:edit",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + ].join('\n\n'); + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.connection.accessLevel, AccessLevelEnum.edit); +}); + +test('parses group:read into readonly access', (t) => { + const policy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"group:read",\n resource == RocketAdmin::Group::"${groupId}"\n);`; + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.group.accessLevel, AccessLevelEnum.readonly); + t.is(result.group.groupId, groupId); +}); + +test('parses group:read + group:edit into edit access', (t) => { + const policy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"group:read",\n resource == RocketAdmin::Group::"${groupId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"group:edit",\n resource == RocketAdmin::Group::"${groupId}"\n);`, + ].join('\n\n'); + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.group.accessLevel, AccessLevelEnum.edit); +}); + +test('parses table:read into visibility + readonly', (t) => { + const policy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/users"\n);`; + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.tables.length, 1); + t.is(result.tables[0].tableName, 'users'); + t.is(result.tables[0].accessLevel.visibility, true); + t.is(result.tables[0].accessLevel.readonly, true); + t.is(result.tables[0].accessLevel.add, false); + t.is(result.tables[0].accessLevel.edit, false); + t.is(result.tables[0].accessLevel.delete, false); +}); + +test('parses table:read + table:add + table:edit + table:delete into full access', (t) => { + const policy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/users"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:add",\n resource == RocketAdmin::Table::"${connectionId}/users"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:edit",\n resource == RocketAdmin::Table::"${connectionId}/users"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:delete",\n resource == RocketAdmin::Table::"${connectionId}/users"\n);`, + ].join('\n\n'); + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.tables.length, 1); + t.is(result.tables[0].accessLevel.visibility, true); + t.is(result.tables[0].accessLevel.readonly, true); + t.is(result.tables[0].accessLevel.add, true); + t.is(result.tables[0].accessLevel.edit, true); + t.is(result.tables[0].accessLevel.delete, true); +}); + +test('parses multiple tables separately', (t) => { + const policy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/users"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/orders"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:add",\n resource == RocketAdmin::Table::"${connectionId}/orders"\n);`, + ].join('\n\n'); + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.tables.length, 2); + + const usersTable = result.tables.find((t) => t.tableName === 'users'); + const ordersTable = result.tables.find((t) => t.tableName === 'orders'); + t.truthy(usersTable); + t.truthy(ordersTable); + t.is(usersTable.accessLevel.add, false); + t.is(ordersTable.accessLevel.add, true); +}); + +test('parses dashboard permissions', (t) => { + const policy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:read",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:create",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:edit",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:delete",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, + ].join('\n\n'); + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.dashboards.length, 1); + t.is(result.dashboards[0].dashboardId, 'dash-1'); + t.is(result.dashboards[0].accessLevel.read, true); + t.is(result.dashboards[0].accessLevel.create, true); + t.is(result.dashboards[0].accessLevel.edit, true); + t.is(result.dashboards[0].accessLevel.delete, true); +}); + +test('parses wildcard policy (isMain) into full connection + group access', (t) => { + const policy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action,\n resource\n);`; + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.connection.accessLevel, AccessLevelEnum.edit); + t.is(result.group.accessLevel, AccessLevelEnum.edit); +}); + +test('empty policy returns all none permissions', (t) => { + const result = parseCedarPolicyToClassicalPermissions('', connectionId, groupId); + t.is(result.connection.accessLevel, AccessLevelEnum.none); + t.is(result.group.accessLevel, AccessLevelEnum.none); + t.is(result.tables.length, 0); + t.is(result.dashboards.length, 0); +}); + +test('complex policy with connection + group + table + dashboard permissions', (t) => { + const policy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:edit",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"group:read",\n resource == RocketAdmin::Group::"${groupId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/users"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:add",\n resource == RocketAdmin::Table::"${connectionId}/users"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:read",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, + ].join('\n\n'); + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.connection.accessLevel, AccessLevelEnum.edit); + t.is(result.group.accessLevel, AccessLevelEnum.readonly); + t.is(result.tables.length, 1); + t.is(result.tables[0].tableName, 'users'); + t.is(result.tables[0].accessLevel.add, true); + t.is(result.tables[0].accessLevel.edit, false); + t.is(result.dashboards.length, 1); + t.is(result.dashboards[0].dashboardId, 'dash-1'); + t.is(result.dashboards[0].accessLevel.read, true); + t.is(result.dashboards[0].accessLevel.create, false); +}); diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-save-policy-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-save-policy-e2e.test.ts new file mode 100644 index 000000000..7dfe7e4e3 --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-save-policy-e2e.test.ts @@ -0,0 +1,696 @@ +/* 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 { AccessLevelEnum } from '../../../src/enums/index.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 { createInitialTestUser } 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'; +import { + createConnectionsAndInviteNewUserInAdminGroupOfFirstConnection, + createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions, +} from '../../utils/user-with-different-permissions-utils.js'; + +let app: INestApplication; +let _testUtils: TestUtils; +let currentTest: string; + +const mockFactory = new MockFactory(); + +test.before(async () => { + setSaasEnvVariable(); + 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(); + await createInitialTestUser(app); + 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); + } +}); + +//****************************** SAVE CEDAR POLICY ENDPOINT ****************************** + +currentTest = 'POST /connection/cedar-policy/:connectionId'; + +test.serial( + `${currentTest} should save cedar policy and return classical permissions for connection read + table read`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + ].join('\n\n'); + + const response = 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(response.status, 201); + const body = response.body; + t.is(body.cedarPolicy, cedarPolicy); + t.truthy(body.classicalPermissions); + t.is(body.classicalPermissions.connection.accessLevel, AccessLevelEnum.readonly); + t.is(body.classicalPermissions.group.accessLevel, AccessLevelEnum.none); + t.is(body.classicalPermissions.tables.length, 1); + t.is(body.classicalPermissions.tables[0].tableName, tableName); + t.is(body.classicalPermissions.tables[0].accessLevel.visibility, true); + t.is(body.classicalPermissions.tables[0].accessLevel.readonly, true); + t.is(body.classicalPermissions.tables[0].accessLevel.add, false); + t.is(body.classicalPermissions.tables[0].accessLevel.edit, false); + t.is(body.classicalPermissions.tables[0].accessLevel.delete, false); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should save cedar policy with connection edit + table full access and generate correct classical permissions`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:edit",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"group:read",\n resource == RocketAdmin::Group::"${groupId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"group:edit",\n resource == RocketAdmin::Group::"${groupId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:add",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:edit",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:delete",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + ].join('\n\n'); + + const response = 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(response.status, 201); + const body = response.body; + t.is(body.classicalPermissions.connection.accessLevel, AccessLevelEnum.edit); + t.is(body.classicalPermissions.group.accessLevel, AccessLevelEnum.edit); + t.is(body.classicalPermissions.tables.length, 1); + t.is(body.classicalPermissions.tables[0].tableName, tableName); + t.is(body.classicalPermissions.tables[0].accessLevel.visibility, true); + t.is(body.classicalPermissions.tables[0].accessLevel.readonly, true); + t.is(body.classicalPermissions.tables[0].accessLevel.add, true); + t.is(body.classicalPermissions.tables[0].accessLevel.edit, true); + t.is(body.classicalPermissions.tables[0].accessLevel.delete, true); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should enforce saved cedar policy - user with table:read can read table rows`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + // Save cedar policy with connection:read + table:read + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\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); + + // Verify user with these permissions can read table rows + const getTableRows = await request(app.getHttpServer()) + .get(`/table/rows/${connectionId}?tableName=${tableName}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getTableRows.status, 200); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should enforce saved cedar policy - user without table:add cannot add rows`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + const testTableColumnName = testData.firstTableInfo.testTableColumnName; + + // Save cedar policy with only connection:read + table:read (no table:add) + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\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); + + // Verify user cannot add rows (no table:add permission) + const addRowResponse = await request(app.getHttpServer()) + .post(`/table/row/${connectionId}?tableName=${tableName}`) + .send({ [testTableColumnName]: 'test_value' }) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(addRowResponse.status, 403); + t.is(JSON.parse(addRowResponse.text).message, Messages.DONT_HAVE_PERMISSIONS); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should enforce saved cedar policy - user with table:add can add rows`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + const testTableColumnName = testData.firstTableInfo.testTableColumnName; + + // Save cedar policy with connection:read + table:read + table:add + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:add",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\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); + + // Verify user can add rows + const addRowResponse = await request(app.getHttpServer()) + .post(`/table/row/${connectionId}?tableName=${tableName}`) + .send({ [testTableColumnName]: 'cedar_test_value' }) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(addRowResponse.status, 201); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should reject request when user does not have connection edit access`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`; + + // Simple user does not have connection edit access + const response = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(response.status, 403); + t.is(JSON.parse(response.text).message, Messages.DONT_HAVE_PERMISSIONS); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial(`${currentTest} should reject empty cedar policy string`, async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInAdminGroupOfFirstConnection(app); + const connectionId = testData.connections.firstId; + + const response = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy: '', groupId: testData.groups.firstAdminGroupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(response.status, 400); + } catch (error) { + console.error(error); + throw error; + } +}); + +test.serial(`${currentTest} should reject request without groupId`, async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInAdminGroupOfFirstConnection(app); + const connectionId = testData.connections.firstId; + + const cedarPolicy = `permit(\n principal,\n action,\n resource\n);`; + + const response = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(response.status, 400); + } catch (error) { + console.error(error); + throw error; + } +}); + +test.serial( + `${currentTest} should reject saving cedar policy on admin (isMain) group`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInAdminGroupOfFirstConnection(app); + const connectionId = testData.connections.firstId; + const adminGroupId = testData.groups.firstAdminGroupId; + + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${adminGroupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`; + + const response = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId: adminGroupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.CANNOT_CHANGE_ADMIN_GROUP); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should reject saving cedar policy when group does not belong to connection`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const secondConnectionId = testData.connections.secondId; + const groupId = testData.groups.createdGroupId; + + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${secondConnectionId}"\n);`; + + // Group belongs to first connection, but we're sending to second connection + const response = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${secondConnectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.GROUP_NOT_FROM_THIS_CONNECTION); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should save cedar policy with dashboard permissions and return correct classical permissions`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:read",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:create",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:edit",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, + ].join('\n\n'); + + const response = 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(response.status, 201); + const body = response.body; + t.is(body.classicalPermissions.connection.accessLevel, AccessLevelEnum.readonly); + t.truthy(body.classicalPermissions.dashboards); + t.is(body.classicalPermissions.dashboards.length, 1); + t.is(body.classicalPermissions.dashboards[0].dashboardId, 'dash-1'); + t.is(body.classicalPermissions.dashboards[0].accessLevel.read, true); + t.is(body.classicalPermissions.dashboards[0].accessLevel.create, true); + t.is(body.classicalPermissions.dashboards[0].accessLevel.edit, true); + t.is(body.classicalPermissions.dashboards[0].accessLevel.delete, false); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should overwrite previous permissions when saving new cedar policy`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + // First: save policy with full table access + const fullPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:edit",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:add",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:edit",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:delete",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + ].join('\n\n'); + + const firstResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy: fullPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(firstResponse.status, 201); + t.is(firstResponse.body.classicalPermissions.connection.accessLevel, AccessLevelEnum.edit); + t.is(firstResponse.body.classicalPermissions.tables[0].accessLevel.add, true); + + // Second: save policy with only read access (overwrite) + const readOnlyPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + ].join('\n\n'); + + const secondResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy: readOnlyPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(secondResponse.status, 201); + t.is(secondResponse.body.classicalPermissions.connection.accessLevel, AccessLevelEnum.readonly); + t.is(secondResponse.body.classicalPermissions.tables[0].accessLevel.add, false); + t.is(secondResponse.body.classicalPermissions.tables[0].accessLevel.edit, false); + t.is(secondResponse.body.classicalPermissions.tables[0].accessLevel.delete, false); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +//****************************** CEDAR POLICY REFERENCE VALIDATION TESTS ****************************** + +test.serial( + `${currentTest} should reject cedar policy that references a different group as principal`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const adminGroupId = testData.groups.firstAdminGroupId; + + // Policy references adminGroupId as principal instead of the target groupId + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${adminGroupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`; + + const response = 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(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.CEDAR_POLICY_REFERENCES_FOREIGN_PRINCIPAL); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should reject cedar policy that references a non-existent group as principal`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const fakeGroupId = '00000000-0000-4000-a000-000000000099'; + + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${fakeGroupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`; + + const response = 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(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.CEDAR_POLICY_REFERENCES_FOREIGN_PRINCIPAL); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should reject cedar policy that references a foreign connection`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const secondConnectionId = testData.connections.secondId; + const groupId = testData.groups.createdGroupId; + + // Policy references secondConnectionId as the resource + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${secondConnectionId}"\n);`; + + const response = 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(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should reject cedar policy that references a group from another connection as resource`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const secondAdminGroupId = testData.groups.secondAdminGroupId; + + // Policy grants group:edit access to a group from the second connection + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"group:edit",\n resource == RocketAdmin::Group::"${secondAdminGroupId}"\n);`, + ].join('\n\n'); + + const response = 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(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.CEDAR_POLICY_REFERENCES_FOREIGN_GROUP); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should reject cedar policy that references a table from another connection`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const secondConnectionId = testData.connections.secondId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + // Policy references a table prefixed with the second connection ID + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${secondConnectionId}/${tableName}"\n);`; + + const response = 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(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should reject cedar policy that references a dashboard from another connection`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const secondConnectionId = testData.connections.secondId; + const groupId = testData.groups.createdGroupId; + + // Policy references a dashboard prefixed with the second connection ID + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:read",\n resource == RocketAdmin::Dashboard::"${secondConnectionId}/dash-1"\n);`; + + const response = 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(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should allow cedar policy that references a valid group from the same connection as resource`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const adminGroupId = testData.groups.firstAdminGroupId; + + // Policy grants group:read access to the admin group of the SAME connection - this is valid + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"group:read",\n resource == RocketAdmin::Group::"${adminGroupId}"\n);`, + ].join('\n\n'); + + const response = 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(response.status, 201); + t.truthy(response.body.cedarPolicy); + t.truthy(response.body.classicalPermissions); + } catch (error) { + console.error(error); + throw error; + } + }, +); diff --git a/backend/test/ava-tests/saas-tests/saas-cedar-save-policy-e2e.test.ts b/backend/test/ava-tests/saas-tests/saas-cedar-save-policy-e2e.test.ts new file mode 100644 index 000000000..dd4416a42 --- /dev/null +++ b/backend/test/ava-tests/saas-tests/saas-cedar-save-policy-e2e.test.ts @@ -0,0 +1,587 @@ +/* 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 { AccessLevelEnum } from '../../../src/enums/index.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); + } +}); + +//****************************** SAVE CEDAR POLICY ENDPOINT ****************************** + +currentTest = 'POST /connection/cedar-policy/:connectionId'; + +test.serial( + `${currentTest} should save cedar policy and return classical permissions for connection read + table read`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + ].join('\n\n'); + + const response = 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(response.status, 201); + const body = response.body; + t.is(body.cedarPolicy, cedarPolicy); + t.truthy(body.classicalPermissions); + t.is(body.classicalPermissions.connection.accessLevel, AccessLevelEnum.readonly); + t.is(body.classicalPermissions.group.accessLevel, AccessLevelEnum.none); + t.is(body.classicalPermissions.tables.length, 1); + t.is(body.classicalPermissions.tables[0].tableName, tableName); + t.is(body.classicalPermissions.tables[0].accessLevel.visibility, true); + t.is(body.classicalPermissions.tables[0].accessLevel.readonly, true); + t.is(body.classicalPermissions.tables[0].accessLevel.add, false); + t.is(body.classicalPermissions.tables[0].accessLevel.edit, false); + t.is(body.classicalPermissions.tables[0].accessLevel.delete, false); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should save cedar policy with connection edit + table full access and generate correct classical permissions`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:edit",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"group:read",\n resource == RocketAdmin::Group::"${groupId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"group:edit",\n resource == RocketAdmin::Group::"${groupId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:add",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:edit",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:delete",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + ].join('\n\n'); + + const response = 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(response.status, 201); + const body = response.body; + t.is(body.classicalPermissions.connection.accessLevel, AccessLevelEnum.edit); + t.is(body.classicalPermissions.group.accessLevel, AccessLevelEnum.edit); + t.is(body.classicalPermissions.tables.length, 1); + t.is(body.classicalPermissions.tables[0].tableName, tableName); + t.is(body.classicalPermissions.tables[0].accessLevel.visibility, true); + t.is(body.classicalPermissions.tables[0].accessLevel.readonly, true); + t.is(body.classicalPermissions.tables[0].accessLevel.add, true); + t.is(body.classicalPermissions.tables[0].accessLevel.edit, true); + t.is(body.classicalPermissions.tables[0].accessLevel.delete, true); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should enforce saved cedar policy - user with table:read can read table rows`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + // Save cedar policy with connection:read + table:read + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\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); + + // Verify user with these permissions can read table rows + const getTableRows = await request(app.getHttpServer()) + .get(`/table/rows/${connectionId}?tableName=${tableName}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getTableRows.status, 200); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should enforce saved cedar policy - user without table:add cannot add rows`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + const testTableColumnName = testData.firstTableInfo.testTableColumnName; + + // Save cedar policy with only connection:read + table:read (no table:add) + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\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); + + // Verify user cannot add rows (no table:add permission) + const addRowResponse = await request(app.getHttpServer()) + .post(`/table/row/${connectionId}?tableName=${tableName}`) + .send({ [testTableColumnName]: 'test_value' }) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(addRowResponse.status, 403); + t.is(JSON.parse(addRowResponse.text).message, Messages.DONT_HAVE_PERMISSIONS); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should enforce saved cedar policy - user with table:add can add rows`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + const testTableColumnName = testData.firstTableInfo.testTableColumnName; + + // Save cedar policy with connection:read + table:read + table:add + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:add",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\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); + + // Verify user can add rows + const addRowResponse = await request(app.getHttpServer()) + .post(`/table/row/${connectionId}?tableName=${tableName}`) + .send({ [testTableColumnName]: 'cedar_test_value' }) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(addRowResponse.status, 201); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should reject request when user does not have connection edit access`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`; + + // Simple user has readonly access, not edit + const response = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(response.status, 403); + t.is(JSON.parse(response.text).message, Messages.DONT_HAVE_PERMISSIONS); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial(`${currentTest} should reject empty cedar policy string`, async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + const response = 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(response.status, 400); + } catch (error) { + console.error(error); + throw error; + } +}); + +test.serial(`${currentTest} should reject request without groupId`, async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + + const cedarPolicy = `permit(\n principal,\n action,\n resource\n);`; + + const response = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(response.status, 400); + } catch (error) { + console.error(error); + throw error; + } +}); + +test.serial( + `${currentTest} should reject saving cedar policy when group does not belong to connection`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const secondConnectionId = testData.connections.secondId; + const groupId = testData.groups.createdGroupId; + + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${secondConnectionId}"\n);`; + + // Group belongs to first connection, but we're sending to second connection + const response = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${secondConnectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.GROUP_NOT_FROM_THIS_CONNECTION); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should save cedar policy with dashboard permissions and return correct classical permissions`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:read",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:create",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"dashboard:edit",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, + ].join('\n\n'); + + const response = 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(response.status, 201); + const body = response.body; + t.is(body.classicalPermissions.connection.accessLevel, AccessLevelEnum.readonly); + t.truthy(body.classicalPermissions.dashboards); + t.is(body.classicalPermissions.dashboards.length, 1); + t.is(body.classicalPermissions.dashboards[0].dashboardId, 'dash-1'); + t.is(body.classicalPermissions.dashboards[0].accessLevel.read, true); + t.is(body.classicalPermissions.dashboards[0].accessLevel.create, true); + t.is(body.classicalPermissions.dashboards[0].accessLevel.edit, true); + t.is(body.classicalPermissions.dashboards[0].accessLevel.delete, false); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should overwrite previous permissions when saving new cedar policy`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + // First: save policy with full table access + const fullPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:edit",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:add",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:edit",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:delete",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + ].join('\n\n'); + + const firstResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy: fullPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(firstResponse.status, 201); + t.is(firstResponse.body.classicalPermissions.connection.accessLevel, AccessLevelEnum.edit); + t.is(firstResponse.body.classicalPermissions.tables[0].accessLevel.add, true); + + // Second: save policy with only read access (overwrite) + const readOnlyPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + ].join('\n\n'); + + const secondResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy: readOnlyPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(secondResponse.status, 201); + t.is(secondResponse.body.classicalPermissions.connection.accessLevel, AccessLevelEnum.readonly); + t.is(secondResponse.body.classicalPermissions.tables[0].accessLevel.add, false); + t.is(secondResponse.body.classicalPermissions.tables[0].accessLevel.edit, false); + t.is(secondResponse.body.classicalPermissions.tables[0].accessLevel.delete, false); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +//****************************** CEDAR POLICY REFERENCE VALIDATION TESTS ****************************** + +test.serial( + `${currentTest} should reject cedar policy that references a different group as principal`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const adminGroupId = testData.groups.firstAdminGroupId; + + // Policy references adminGroupId as principal instead of the target groupId + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${adminGroupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`; + + const response = 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(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.CEDAR_POLICY_REFERENCES_FOREIGN_PRINCIPAL); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should reject cedar policy that references a foreign connection`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const secondConnectionId = testData.connections.secondId; + const groupId = testData.groups.createdGroupId; + + // Policy references secondConnectionId as the resource + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${secondConnectionId}"\n);`; + + const response = 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(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should reject cedar policy that references a group from another connection as resource`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const secondAdminGroupId = testData.groups.secondAdminGroupId; + + // Policy grants group:edit access to a group from the second connection + const cedarPolicy = [ + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"group:edit",\n resource == RocketAdmin::Group::"${secondAdminGroupId}"\n);`, + ].join('\n\n'); + + const response = 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(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.CEDAR_POLICY_REFERENCES_FOREIGN_GROUP); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should reject cedar policy that references a table from another connection`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const secondConnectionId = testData.connections.secondId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + const cedarPolicy = `permit(\n principal in RocketAdmin::Group::"${groupId}",\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${secondConnectionId}/${tableName}"\n);`; + + const response = 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(response.status, 400); + t.is(JSON.parse(response.text).message, Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION); + } catch (error) { + console.error(error); + throw error; + } + }, +);