diff --git a/server/jest.config.ts b/server/jest.config.ts index babc368094..fd6760a66f 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -2,7 +2,7 @@ module.exports = { clearMocks: true, preset: 'ts-jest', testEnvironment: 'node', - setupFilesAfterEnv: ['/src/prisma-mock/jest-prisma-singleton.ts'], + setupFilesAfterEnv: ['/src/database/client-mock/jest-prisma-singleton.ts'], moduleFileExtensions: ['js', 'json', 'ts'], moduleNameMapper: { diff --git a/server/package.json b/server/package.json index 53998e8009..b5679d1b85 100644 --- a/server/package.json +++ b/server/package.json @@ -27,6 +27,8 @@ }, "dependencies": { "@apollo/server": "^4.7.3", + "@casl/ability": "^6.5.0", + "@casl/prisma": "^1.4.0", "@nestjs/apollo": "^11.0.5", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.3.2", diff --git a/server/src/ability/ability.action.ts b/server/src/ability/ability.action.ts new file mode 100644 index 0000000000..da035d7bf6 --- /dev/null +++ b/server/src/ability/ability.action.ts @@ -0,0 +1,7 @@ +export enum AbilityAction { + Manage = 'manage', + Create = 'create', + Read = 'read', + Update = 'update', + Delete = 'delete', +} diff --git a/server/src/ability/ability.factory.ts b/server/src/ability/ability.factory.ts new file mode 100644 index 0000000000..aaac870d15 --- /dev/null +++ b/server/src/ability/ability.factory.ts @@ -0,0 +1,98 @@ +import { PureAbility, AbilityBuilder, subject } from '@casl/ability'; +import { createPrismaAbility, PrismaQuery, Subjects } from '@casl/prisma'; +import { Injectable } from '@nestjs/common'; +import { + CommentThread, + Company, + Comment, + Person, + RefreshToken, + User, + Workspace, + WorkspaceMember, + CommentThreadTarget, + Pipeline, + PipelineStage, + PipelineProgress, +} from '@prisma/client'; +import { AbilityAction } from './ability.action'; + +type SubjectsAbility = Subjects<{ + User: User; + Workspace: Workspace; + WorkspaceMember: WorkspaceMember; + Company: Company; + Person: Person; + RefreshToken: RefreshToken; + CommentThread: CommentThread; + Comment: Comment; + CommentThreadTarget: CommentThreadTarget; + Pipeline: Pipeline; + PipelineStage: PipelineStage; + PipelineProgress: PipelineProgress; +}>; + +export type AppAbility = PureAbility< + [string, SubjectsAbility | 'all'], + PrismaQuery +>; + +@Injectable() +export class AbilityFactory { + defineAbility(user: User, workspace: Workspace) { + const { can, cannot, build } = new AbilityBuilder( + createPrismaAbility, + ); + + // User + can(AbilityAction.Update, 'User', { id: user.id }); + cannot(AbilityAction.Delete, 'User'); + + // Workspace + can(AbilityAction.Read, 'Workspace', { id: workspace.id }); + + // Workspace Member + can(AbilityAction.Read, 'WorkspaceMember', { userId: user.id }); + + // Company + can(AbilityAction.Read, 'Company', { workspaceId: workspace.id }); + + // Person + can(AbilityAction.Read, 'Person', { workspaceId: workspace.id }); + + // RefreshToken + cannot(AbilityAction.Manage, 'RefreshToken'); + + // CommentThread + can(AbilityAction.Read, 'CommentThread', { workspaceId: workspace.id }); + + // Comment + can(AbilityAction.Read, 'Comment', { workspaceId: workspace.id }); + can(AbilityAction.Update, 'Comment', { + workspaceId: workspace.id, + authorId: user.id, + }); + can(AbilityAction.Delete, 'Comment', { + workspaceId: workspace.id, + authorId: user.id, + }); + + // CommentThreadTarget + can(AbilityAction.Read, 'CommentThreadTarget'); + + // Pipeline + can(AbilityAction.Read, 'Pipeline', { workspaceId: workspace.id }); + + // PipelineStage + can(AbilityAction.Read, 'PipelineStage', { workspaceId: workspace.id }); + can(AbilityAction.Update, 'PipelineStage', { workspaceId: workspace.id }); + + // PipelineProgress + can(AbilityAction.Read, 'PipelineProgress', { workspaceId: workspace.id }); + can(AbilityAction.Update, 'PipelineProgress', { + workspaceId: workspace.id, + }); + + return build(); + } +} diff --git a/server/src/ability/ability.module.ts b/server/src/ability/ability.module.ts new file mode 100644 index 0000000000..53841fd42e --- /dev/null +++ b/server/src/ability/ability.module.ts @@ -0,0 +1,243 @@ +import { Global, Module } from '@nestjs/common'; +import { AbilityFactory } from 'src/ability/ability.factory'; +import { PrismaService } from 'src/database/prisma.service'; +import { + CreateUserAbilityHandler, + DeleteUserAbilityHandler, + ManageUserAbilityHandler, + ReadUserAbilityHandler, + UpdateUserAbilityHandler, +} from './handlers/user.ability-handler'; +import { + CreateWorkspaceAbilityHandler, + DeleteWorkspaceAbilityHandler, + ManageWorkspaceAbilityHandler, + ReadWorkspaceAbilityHandler, + UpdateWorkspaceAbilityHandler, +} from './handlers/workspace.ability-handler'; +import { + CreateWorkspaceMemberAbilityHandler, + DeleteWorkspaceMemberAbilityHandler, + ManageWorkspaceMemberAbilityHandler, + ReadWorkspaceMemberAbilityHandler, + UpdateWorkspaceMemberAbilityHandler, +} from './handlers/workspace-member.ability-handler'; +import { + ManageCompanyAbilityHandler, + ReadCompanyAbilityHandler, + CreateCompanyAbilityHandler, + UpdateCompanyAbilityHandler, + DeleteCompanyAbilityHandler, +} from './handlers/company.ability-handler'; +import { + CreatePersonAbilityHandler, + DeletePersonAbilityHandler, + ManagePersonAbilityHandler, + ReadPersonAbilityHandler, + UpdatePersonAbilityHandler, +} from './handlers/person.ability-handler'; +import { + ManageRefreshTokenAbilityHandler, + ReadRefreshTokenAbilityHandler, + CreateRefreshTokenAbilityHandler, + UpdateRefreshTokenAbilityHandler, + DeleteRefreshTokenAbilityHandler, +} from './handlers/refresh-token.ability-handler'; +import { + ManageCommentThreadAbilityHandler, + ReadCommentThreadAbilityHandler, + CreateCommentThreadAbilityHandler, + UpdateCommentThreadAbilityHandler, + DeleteCommentThreadAbilityHandler, +} from './handlers/comment-thread.ability-handler'; +import { + ManageCommentAbilityHandler, + ReadCommentAbilityHandler, + CreateCommentAbilityHandler, + UpdateCommentAbilityHandler, + DeleteCommentAbilityHandler, +} from './handlers/comment.ability-handler'; +import { + ManageCommentThreadTargetAbilityHandler, + ReadCommentThreadTargetAbilityHandler, + CreateCommentThreadTargetAbilityHandler, + UpdateCommentThreadTargetAbilityHandler, + DeleteCommentThreadTargetAbilityHandler, +} from './handlers/comment-thread-target.ability-handler'; +import { + ManagePipelineAbilityHandler, + ReadPipelineAbilityHandler, + CreatePipelineAbilityHandler, + UpdatePipelineAbilityHandler, + DeletePipelineAbilityHandler, +} from './handlers/pipeline.ability-handler'; +import { + ManagePipelineStageAbilityHandler, + ReadPipelineStageAbilityHandler, + CreatePipelineStageAbilityHandler, + UpdatePipelineStageAbilityHandler, + DeletePipelineStageAbilityHandler, +} from './handlers/pipeline-stage.ability-handler'; +import { + ManagePipelineProgressAbilityHandler, + ReadPipelineProgressAbilityHandler, + CreatePipelineProgressAbilityHandler, + UpdatePipelineProgressAbilityHandler, + DeletePipelineProgressAbilityHandler, +} from './handlers/pipeline-progress.ability-handler'; + +@Global() +@Module({ + providers: [ + AbilityFactory, + PrismaService, + // User + ManageUserAbilityHandler, + ReadUserAbilityHandler, + CreateUserAbilityHandler, + UpdateUserAbilityHandler, + DeleteUserAbilityHandler, + // Workspace + ManageWorkspaceAbilityHandler, + ReadWorkspaceAbilityHandler, + CreateWorkspaceAbilityHandler, + UpdateWorkspaceAbilityHandler, + DeleteWorkspaceAbilityHandler, + // Workspace Member + ManageWorkspaceMemberAbilityHandler, + ReadWorkspaceMemberAbilityHandler, + CreateWorkspaceMemberAbilityHandler, + UpdateWorkspaceMemberAbilityHandler, + DeleteWorkspaceMemberAbilityHandler, + // Company + ManageCompanyAbilityHandler, + ReadCompanyAbilityHandler, + CreateCompanyAbilityHandler, + UpdateCompanyAbilityHandler, + DeleteCompanyAbilityHandler, + // Person + ManagePersonAbilityHandler, + ReadPersonAbilityHandler, + CreatePersonAbilityHandler, + UpdatePersonAbilityHandler, + DeletePersonAbilityHandler, + // RefreshToken + ManageRefreshTokenAbilityHandler, + ReadRefreshTokenAbilityHandler, + CreateRefreshTokenAbilityHandler, + UpdateRefreshTokenAbilityHandler, + DeleteRefreshTokenAbilityHandler, + // CommentThread + ManageCommentThreadAbilityHandler, + ReadCommentThreadAbilityHandler, + CreateCommentThreadAbilityHandler, + UpdateCommentThreadAbilityHandler, + DeleteCommentThreadAbilityHandler, + // Comment + ManageCommentAbilityHandler, + ReadCommentAbilityHandler, + CreateCommentAbilityHandler, + UpdateCommentAbilityHandler, + DeleteCommentAbilityHandler, + // CommentThreadTarget + ManageCommentThreadTargetAbilityHandler, + ReadCommentThreadTargetAbilityHandler, + CreateCommentThreadTargetAbilityHandler, + UpdateCommentThreadTargetAbilityHandler, + DeleteCommentThreadTargetAbilityHandler, + // Pipeline + ManagePipelineAbilityHandler, + ReadPipelineAbilityHandler, + CreatePipelineAbilityHandler, + UpdatePipelineAbilityHandler, + DeletePipelineAbilityHandler, + // PipelineStage + ManagePipelineStageAbilityHandler, + ReadPipelineStageAbilityHandler, + CreatePipelineStageAbilityHandler, + UpdatePipelineStageAbilityHandler, + DeletePipelineStageAbilityHandler, + // PipelineProgress + ManagePipelineProgressAbilityHandler, + ReadPipelineProgressAbilityHandler, + CreatePipelineProgressAbilityHandler, + UpdatePipelineProgressAbilityHandler, + DeletePipelineProgressAbilityHandler, + ], + exports: [ + AbilityFactory, + // User + ManageUserAbilityHandler, + ReadUserAbilityHandler, + CreateUserAbilityHandler, + UpdateUserAbilityHandler, + DeleteUserAbilityHandler, + // Workspace + ManageWorkspaceAbilityHandler, + ReadWorkspaceAbilityHandler, + CreateWorkspaceAbilityHandler, + UpdateWorkspaceAbilityHandler, + DeleteWorkspaceAbilityHandler, + // Workspace Member + ManageWorkspaceMemberAbilityHandler, + ReadWorkspaceMemberAbilityHandler, + CreateWorkspaceMemberAbilityHandler, + UpdateWorkspaceMemberAbilityHandler, + DeleteWorkspaceMemberAbilityHandler, + // Company + ManageCompanyAbilityHandler, + ReadCompanyAbilityHandler, + CreateCompanyAbilityHandler, + UpdateCompanyAbilityHandler, + DeleteCompanyAbilityHandler, + // Person + ManagePersonAbilityHandler, + ReadPersonAbilityHandler, + CreatePersonAbilityHandler, + UpdatePersonAbilityHandler, + DeletePersonAbilityHandler, + // RefreshToken + ManageRefreshTokenAbilityHandler, + ReadRefreshTokenAbilityHandler, + CreateRefreshTokenAbilityHandler, + UpdateRefreshTokenAbilityHandler, + DeleteRefreshTokenAbilityHandler, + // CommentThread + ManageCommentThreadAbilityHandler, + ReadCommentThreadAbilityHandler, + CreateCommentThreadAbilityHandler, + UpdateCommentThreadAbilityHandler, + DeleteCommentThreadAbilityHandler, + // Comment + ManageCommentAbilityHandler, + ReadCommentAbilityHandler, + CreateCommentAbilityHandler, + UpdateCommentAbilityHandler, + DeleteCommentAbilityHandler, + // CommentThreadTarget + ManageCommentThreadTargetAbilityHandler, + ReadCommentThreadTargetAbilityHandler, + CreateCommentThreadTargetAbilityHandler, + UpdateCommentThreadTargetAbilityHandler, + DeleteCommentThreadTargetAbilityHandler, + // Pipeline + ManagePipelineAbilityHandler, + ReadPipelineAbilityHandler, + CreatePipelineAbilityHandler, + UpdatePipelineAbilityHandler, + DeletePipelineAbilityHandler, + // PipelineStage + ManagePipelineStageAbilityHandler, + ReadPipelineStageAbilityHandler, + CreatePipelineStageAbilityHandler, + UpdatePipelineStageAbilityHandler, + DeletePipelineStageAbilityHandler, + // PipelineProgress + ManagePipelineProgressAbilityHandler, + ReadPipelineProgressAbilityHandler, + CreatePipelineProgressAbilityHandler, + UpdatePipelineProgressAbilityHandler, + DeletePipelineProgressAbilityHandler, + ], +}) +export class AbilityModule {} diff --git a/server/src/ability/handlers/comment-thread-target.ability-handler.ts b/server/src/ability/handlers/comment-thread-target.ability-handler.ts new file mode 100644 index 0000000000..94a5540178 --- /dev/null +++ b/server/src/ability/handlers/comment-thread-target.ability-handler.ts @@ -0,0 +1,50 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ManageCommentThreadTargetAbilityHandler + implements IAbilityHandler +{ + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'CommentThreadTarget'); + } +} + +@Injectable() +export class ReadCommentThreadTargetAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'CommentThreadTarget'); + } +} + +@Injectable() +export class CreateCommentThreadTargetAbilityHandler + implements IAbilityHandler +{ + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'CommentThreadTarget'); + } +} + +@Injectable() +export class UpdateCommentThreadTargetAbilityHandler + implements IAbilityHandler +{ + handle(ability: AppAbility) { + return ability.can(AbilityAction.Update, 'CommentThreadTarget'); + } +} + +@Injectable() +export class DeleteCommentThreadTargetAbilityHandler + implements IAbilityHandler +{ + handle(ability: AppAbility) { + return ability.can(AbilityAction.Delete, 'CommentThreadTarget'); + } +} diff --git a/server/src/ability/handlers/comment-thread.ability-handler.ts b/server/src/ability/handlers/comment-thread.ability-handler.ts new file mode 100644 index 0000000000..3c2beb0f13 --- /dev/null +++ b/server/src/ability/handlers/comment-thread.ability-handler.ts @@ -0,0 +1,42 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ManageCommentThreadAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'CommentThread'); + } +} + +@Injectable() +export class ReadCommentThreadAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'CommentThread'); + } +} + +@Injectable() +export class CreateCommentThreadAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'CommentThread'); + } +} + +@Injectable() +export class UpdateCommentThreadAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Update, 'CommentThread'); + } +} + +@Injectable() +export class DeleteCommentThreadAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Delete, 'CommentThread'); + } +} diff --git a/server/src/ability/handlers/comment.ability-handler.ts b/server/src/ability/handlers/comment.ability-handler.ts new file mode 100644 index 0000000000..ffe74dfdbb --- /dev/null +++ b/server/src/ability/handlers/comment.ability-handler.ts @@ -0,0 +1,42 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ManageCommentAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'Comment'); + } +} + +@Injectable() +export class ReadCommentAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'Comment'); + } +} + +@Injectable() +export class CreateCommentAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'Comment'); + } +} + +@Injectable() +export class UpdateCommentAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Update, 'Comment'); + } +} + +@Injectable() +export class DeleteCommentAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Delete, 'Comment'); + } +} diff --git a/server/src/ability/handlers/company.ability-handler.ts b/server/src/ability/handlers/company.ability-handler.ts new file mode 100644 index 0000000000..6969f5b9c4 --- /dev/null +++ b/server/src/ability/handlers/company.ability-handler.ts @@ -0,0 +1,42 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ManageCompanyAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'Company'); + } +} + +@Injectable() +export class ReadCompanyAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'Company'); + } +} + +@Injectable() +export class CreateCompanyAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'Company'); + } +} + +@Injectable() +export class UpdateCompanyAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Update, 'Company'); + } +} + +@Injectable() +export class DeleteCompanyAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Delete, 'Company'); + } +} diff --git a/server/src/ability/handlers/person.ability-handler.ts b/server/src/ability/handlers/person.ability-handler.ts new file mode 100644 index 0000000000..12dbcb71c8 --- /dev/null +++ b/server/src/ability/handlers/person.ability-handler.ts @@ -0,0 +1,42 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ManagePersonAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'Person'); + } +} + +@Injectable() +export class ReadPersonAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'Person'); + } +} + +@Injectable() +export class CreatePersonAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'Person'); + } +} + +@Injectable() +export class UpdatePersonAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Update, 'Person'); + } +} + +@Injectable() +export class DeletePersonAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Delete, 'Person'); + } +} diff --git a/server/src/ability/handlers/pipeline-progress.ability-handler.ts b/server/src/ability/handlers/pipeline-progress.ability-handler.ts new file mode 100644 index 0000000000..f70d8c1847 --- /dev/null +++ b/server/src/ability/handlers/pipeline-progress.ability-handler.ts @@ -0,0 +1,78 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { + ExecutionContext, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { assert } from 'src/utils/assert'; +import { subject } from '@casl/ability'; +import { PipelineProgressWhereInput } from 'src/core/@generated/pipeline-progress/pipeline-progress-where.input'; + +class PipelineProgressArgs { + where?: PipelineProgressWhereInput; +} + +@Injectable() +export class ManagePipelineProgressAbilityHandler implements IAbilityHandler { + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'PipelineProgress'); + } +} + +@Injectable() +export class ReadPipelineProgressAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'PipelineProgress'); + } +} + +@Injectable() +export class CreatePipelineProgressAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'PipelineProgress'); + } +} + +@Injectable() +export class UpdatePipelineProgressAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const pipelineProgress = + await this.prismaService.pipelineProgress.findFirst({ + where: args.where, + }); + assert(pipelineProgress, '', NotFoundException); + + return ability.can( + AbilityAction.Update, + subject('PipelineProgress', pipelineProgress), + ); + } +} + +@Injectable() +export class DeletePipelineProgressAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const pipelineProgress = + await this.prismaService.pipelineProgress.findFirst({ + where: args.where, + }); + assert(pipelineProgress, '', NotFoundException); + + return ability.can( + AbilityAction.Delete, + subject('PipelineProgress', pipelineProgress), + ); + } +} diff --git a/server/src/ability/handlers/pipeline-stage.ability-handler.ts b/server/src/ability/handlers/pipeline-stage.ability-handler.ts new file mode 100644 index 0000000000..cb2c95c427 --- /dev/null +++ b/server/src/ability/handlers/pipeline-stage.ability-handler.ts @@ -0,0 +1,76 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { + ExecutionContext, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PipelineStageWhereInput } from 'src/core/@generated/pipeline-stage/pipeline-stage-where.input'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { assert } from 'src/utils/assert'; +import { subject } from '@casl/ability'; + +class PipelineStageArgs { + where?: PipelineStageWhereInput; +} + +@Injectable() +export class ManagePipelineStageAbilityHandler implements IAbilityHandler { + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'PipelineStage'); + } +} + +@Injectable() +export class ReadPipelineStageAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'PipelineStage'); + } +} + +@Injectable() +export class CreatePipelineStageAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'PipelineStage'); + } +} + +@Injectable() +export class UpdatePipelineStageAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const pipelineStage = await this.prismaService.pipelineStage.findFirst({ + where: args.where, + }); + assert(pipelineStage, '', NotFoundException); + + return ability.can( + AbilityAction.Update, + subject('PipelineStage', pipelineStage), + ); + } +} + +@Injectable() +export class DeletePipelineStageAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const pipelineStage = await this.prismaService.pipelineStage.findFirst({ + where: args.where, + }); + assert(pipelineStage, '', NotFoundException); + + return ability.can( + AbilityAction.Delete, + subject('PipelineStage', pipelineStage), + ); + } +} diff --git a/server/src/ability/handlers/pipeline.ability-handler.ts b/server/src/ability/handlers/pipeline.ability-handler.ts new file mode 100644 index 0000000000..23ca2da3e9 --- /dev/null +++ b/server/src/ability/handlers/pipeline.ability-handler.ts @@ -0,0 +1,70 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { + ExecutionContext, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PipelineWhereInput } from 'src/core/@generated/pipeline/pipeline-where.input'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { assert } from 'src/utils/assert'; +import { subject } from '@casl/ability'; + +class PipelineArgs { + where?: PipelineWhereInput; +} + +@Injectable() +export class ManagePipelineAbilityHandler implements IAbilityHandler { + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'Pipeline'); + } +} + +@Injectable() +export class ReadPipelineAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'Pipeline'); + } +} + +@Injectable() +export class CreatePipelineAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'Pipeline'); + } +} + +@Injectable() +export class UpdatePipelineAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const pipeline = await this.prismaService.pipeline.findFirst({ + where: args.where, + }); + assert(pipeline, '', NotFoundException); + + return ability.can(AbilityAction.Update, subject('Pipeline', pipeline)); + } +} + +@Injectable() +export class DeletePipelineAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const pipeline = await this.prismaService.pipeline.findFirst({ + where: args.where, + }); + assert(pipeline, '', NotFoundException); + + return ability.can(AbilityAction.Delete, subject('Pipeline', pipeline)); + } +} diff --git a/server/src/ability/handlers/refresh-token.ability-handler.ts b/server/src/ability/handlers/refresh-token.ability-handler.ts new file mode 100644 index 0000000000..e2e35c883c --- /dev/null +++ b/server/src/ability/handlers/refresh-token.ability-handler.ts @@ -0,0 +1,42 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ManageRefreshTokenAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'RefreshToken'); + } +} + +@Injectable() +export class ReadRefreshTokenAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'RefreshToken'); + } +} + +@Injectable() +export class CreateRefreshTokenAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'RefreshToken'); + } +} + +@Injectable() +export class UpdateRefreshTokenAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Update, 'RefreshToken'); + } +} + +@Injectable() +export class DeleteRefreshTokenAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Delete, 'RefreshToken'); + } +} diff --git a/server/src/ability/handlers/user.ability-handler.ts b/server/src/ability/handlers/user.ability-handler.ts new file mode 100644 index 0000000000..371b223a6e --- /dev/null +++ b/server/src/ability/handlers/user.ability-handler.ts @@ -0,0 +1,42 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ManageUserAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'User'); + } +} + +@Injectable() +export class ReadUserAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'User'); + } +} + +@Injectable() +export class CreateUserAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'User'); + } +} + +@Injectable() +export class UpdateUserAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Update, 'User'); + } +} + +@Injectable() +export class DeleteUserAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Delete, 'User'); + } +} diff --git a/server/src/ability/handlers/workspace-member.ability-handler.ts b/server/src/ability/handlers/workspace-member.ability-handler.ts new file mode 100644 index 0000000000..d75f10995e --- /dev/null +++ b/server/src/ability/handlers/workspace-member.ability-handler.ts @@ -0,0 +1,42 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ManageWorkspaceMemberAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'WorkspaceMember'); + } +} + +@Injectable() +export class ReadWorkspaceMemberAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'WorkspaceMember'); + } +} + +@Injectable() +export class CreateWorkspaceMemberAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'WorkspaceMember'); + } +} + +@Injectable() +export class UpdateWorkspaceMemberAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Update, 'WorkspaceMember'); + } +} + +@Injectable() +export class DeleteWorkspaceMemberAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Delete, 'WorkspaceMember'); + } +} diff --git a/server/src/ability/handlers/workspace.ability-handler.ts b/server/src/ability/handlers/workspace.ability-handler.ts new file mode 100644 index 0000000000..368485c0ce --- /dev/null +++ b/server/src/ability/handlers/workspace.ability-handler.ts @@ -0,0 +1,42 @@ +import { PrismaService } from 'src/database/prisma.service'; +import { AbilityAction } from '../ability.action'; +import { AppAbility } from '../ability.factory'; +import { IAbilityHandler } from '../interfaces/ability-handler.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ManageWorkspaceAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'Workspace'); + } +} + +@Injectable() +export class ReadWorkspaceAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'Workspace'); + } +} + +@Injectable() +export class CreateWorkspaceAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Create, 'Workspace'); + } +} + +@Injectable() +export class UpdateWorkspaceAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Update, 'Workspace'); + } +} + +@Injectable() +export class DeleteWorkspaceAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Delete, 'Workspace'); + } +} diff --git a/server/src/ability/interfaces/ability-handler.interface.ts b/server/src/ability/interfaces/ability-handler.interface.ts new file mode 100644 index 0000000000..40d46bc687 --- /dev/null +++ b/server/src/ability/interfaces/ability-handler.interface.ts @@ -0,0 +1,11 @@ +import { ExecutionContext, Type } from '@nestjs/common'; +import { AppAbility } from '../ability.factory'; + +export interface IAbilityHandler { + handle( + ability: AppAbility, + executionContext: ExecutionContext, + ): Promise | boolean; +} + +export type AbilityHandler = Type; diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 87f7871ed2..c4a0806ca8 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -5,9 +5,11 @@ import { AppService } from './app.service'; import { ConfigModule } from '@nestjs/config'; import { CoreModule } from './core/core.module'; import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; +import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default'; import { GraphQLError } from 'graphql'; import { PrismaModule } from './database/prisma.module'; import { HealthModule } from './health/health.module'; +import { AbilityModule } from './ability/ability.module'; @Module({ imports: [ @@ -15,9 +17,11 @@ import { HealthModule } from './health/health.module'; isGlobal: true, }), GraphQLModule.forRoot({ + playground: false, context: ({ req }) => ({ req }), driver: ApolloDriver, autoSchemaFile: true, + plugins: [ApolloServerPluginLandingPageLocalDefault()], formatError: (error: GraphQLError) => { error.extensions.stacktrace = undefined; return error; @@ -25,6 +29,7 @@ import { HealthModule } from './health/health.module'; }), PrismaModule, HealthModule, + AbilityModule, CoreModule, ], providers: [AppService], diff --git a/server/src/core/auth/services/token.service.spec.ts b/server/src/core/auth/services/token.service.spec.ts index 9d05aa98ec..a17a9ff6fe 100644 --- a/server/src/core/auth/services/token.service.spec.ts +++ b/server/src/core/auth/services/token.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TokenService } from './token.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; diff --git a/server/src/core/comment/services/comment-thread-target.service.spec.ts b/server/src/core/comment/services/comment-thread-target.service.spec.ts index 5c00ab220e..2ac9c14c92 100644 --- a/server/src/core/comment/services/comment-thread-target.service.spec.ts +++ b/server/src/core/comment/services/comment-thread-target.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CommentThreadTargetService } from './comment-thread-target.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; describe('CommentThreadTargetService', () => { let service: CommentThreadTargetService; diff --git a/server/src/core/comment/services/comment-thread.service.spec.ts b/server/src/core/comment/services/comment-thread.service.spec.ts index 04cb088405..792ce34c11 100644 --- a/server/src/core/comment/services/comment-thread.service.spec.ts +++ b/server/src/core/comment/services/comment-thread.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CommentThreadService } from './comment-thread.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; describe('CommentThreadService', () => { let service: CommentThreadService; diff --git a/server/src/core/comment/services/comment.service.spec.ts b/server/src/core/comment/services/comment.service.spec.ts index 30bdc59895..75cd8c1df0 100644 --- a/server/src/core/comment/services/comment.service.spec.ts +++ b/server/src/core/comment/services/comment.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CommentService } from './comment.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; describe('CommentService', () => { let service: CommentService; diff --git a/server/src/core/company/company.service.spec.ts b/server/src/core/company/company.service.spec.ts index 8ff7340582..5c929517b7 100644 --- a/server/src/core/company/company.service.spec.ts +++ b/server/src/core/company/company.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CompanyService } from './company.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; describe('CompanyService', () => { let service: CompanyService; diff --git a/server/src/core/person/person.service.spec.ts b/server/src/core/person/person.service.spec.ts index 6db8370748..552df3aa30 100644 --- a/server/src/core/person/person.service.spec.ts +++ b/server/src/core/person/person.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PersonService } from './person.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; describe('PersonService', () => { let service: PersonService; diff --git a/server/src/core/pipeline/resolvers/pipeline-progress.resolver.spec.ts b/server/src/core/pipeline/resolvers/pipeline-progress.resolver.spec.ts index d9d38e2014..ea2c5cba15 100644 --- a/server/src/core/pipeline/resolvers/pipeline-progress.resolver.spec.ts +++ b/server/src/core/pipeline/resolvers/pipeline-progress.resolver.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PipelineProgressResolver } from './pipeline-progress.resolver'; import { PipelineProgressService } from '../services/pipeline-progress.service'; +import { AbilityFactory } from 'src/ability/ability.factory'; describe('PipelineProgressResolver', () => { let resolver: PipelineProgressResolver; @@ -13,6 +14,10 @@ describe('PipelineProgressResolver', () => { provide: PipelineProgressService, useValue: {}, }, + { + provide: AbilityFactory, + useValue: {}, + }, ], }).compile(); diff --git a/server/src/core/pipeline/resolvers/pipeline-progress.resolver.ts b/server/src/core/pipeline/resolvers/pipeline-progress.resolver.ts index 0388f4dd36..266811a68d 100644 --- a/server/src/core/pipeline/resolvers/pipeline-progress.resolver.ts +++ b/server/src/core/pipeline/resolvers/pipeline-progress.resolver.ts @@ -1,5 +1,6 @@ import { Resolver, Args, Query, Mutation } from '@nestjs/graphql'; import { UseGuards } from '@nestjs/common'; +import { accessibleBy } from '@casl/prisma'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { Workspace } from '../../../core/@generated/workspace/workspace.model'; import { AuthWorkspace } from '../../../decorators/auth-workspace.decorator'; @@ -11,7 +12,16 @@ import { AffectedRows } from '../../@generated/prisma/affected-rows.output'; import { DeleteManyPipelineProgressArgs } from '../../@generated/pipeline-progress/delete-many-pipeline-progress.args'; import { CreateOnePipelineProgressArgs } from '../../@generated/pipeline-progress/create-one-pipeline-progress.args'; import { PipelineProgressService } from '../services/pipeline-progress.service'; -import { prepareFindManyArgs } from 'src/utils/prepare-find-many'; +import { AbilityGuard } from 'src/guards/ability.guard'; +import { CheckAbilities } from 'src/decorators/check-abilities.decorator'; +import { + CreatePipelineProgressAbilityHandler, + ReadPipelineProgressAbilityHandler, + UpdatePipelineProgressAbilityHandler, + DeletePipelineProgressAbilityHandler, +} from 'src/ability/handlers/pipeline-progress.ability-handler'; +import { UserAbility } from 'src/decorators/user-ability.decorator'; +import { AppAbility } from 'src/ability/ability.factory'; @UseGuards(JwtAuthGuard) @Resolver(() => PipelineProgress) @@ -21,20 +31,26 @@ export class PipelineProgressResolver { ) {} @Query(() => [PipelineProgress]) + @UseGuards(AbilityGuard) + @CheckAbilities(ReadPipelineProgressAbilityHandler) async findManyPipelineProgress( @Args() args: FindManyPipelineProgressArgs, - @AuthWorkspace() workspace: Workspace, + @UserAbility() ability: AppAbility, ) { - const preparedArgs = prepareFindManyArgs( - args, - workspace, - ); - return this.pipelineProgressService.findMany(preparedArgs); + return this.pipelineProgressService.findMany({ + ...args, + where: { + ...args.where, + AND: [accessibleBy(ability).PipelineProgress], + }, + }); } @Mutation(() => PipelineProgress, { nullable: true, }) + @UseGuards(AbilityGuard) + @CheckAbilities(UpdatePipelineProgressAbilityHandler) async updateOnePipelineProgress( @Args() args: UpdateOnePipelineProgressArgs, ): Promise { @@ -46,6 +62,8 @@ export class PipelineProgressResolver { @Mutation(() => AffectedRows, { nullable: false, }) + @UseGuards(AbilityGuard) + @CheckAbilities(DeletePipelineProgressAbilityHandler) async deleteManyPipelineProgress( @Args() args: DeleteManyPipelineProgressArgs, ): Promise { @@ -57,6 +75,8 @@ export class PipelineProgressResolver { @Mutation(() => PipelineProgress, { nullable: false, }) + @UseGuards(AbilityGuard) + @CheckAbilities(CreatePipelineProgressAbilityHandler) async createOnePipelineProgress( @Args() args: CreateOnePipelineProgressArgs, @AuthWorkspace() workspace: Workspace, diff --git a/server/src/core/pipeline/resolvers/pipeline-stage.resolver.spec.ts b/server/src/core/pipeline/resolvers/pipeline-stage.resolver.spec.ts index 038ffc2c38..b8dfc2768d 100644 --- a/server/src/core/pipeline/resolvers/pipeline-stage.resolver.spec.ts +++ b/server/src/core/pipeline/resolvers/pipeline-stage.resolver.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PipelineStageResolver } from './pipeline-stage.resolver'; import { PipelineStageService } from '../services/pipeline-stage.service'; +import { AbilityFactory } from 'src/ability/ability.factory'; describe('PipelineStageResolver', () => { let resolver: PipelineStageResolver; @@ -13,6 +14,10 @@ describe('PipelineStageResolver', () => { provide: PipelineStageService, useValue: {}, }, + { + provide: AbilityFactory, + useValue: {}, + }, ], }).compile(); diff --git a/server/src/core/pipeline/resolvers/pipeline-stage.resolver.ts b/server/src/core/pipeline/resolvers/pipeline-stage.resolver.ts index 0312e956e4..516f3bd60e 100644 --- a/server/src/core/pipeline/resolvers/pipeline-stage.resolver.ts +++ b/server/src/core/pipeline/resolvers/pipeline-stage.resolver.ts @@ -1,12 +1,15 @@ import { Resolver, Args, Query } from '@nestjs/graphql'; import { UseGuards } from '@nestjs/common'; +import { accessibleBy } from '@casl/prisma'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; -import { Workspace } from '../../../core/@generated/workspace/workspace.model'; -import { AuthWorkspace } from '../../../decorators/auth-workspace.decorator'; import { PipelineStage } from '../../../core/@generated/pipeline-stage/pipeline-stage.model'; import { FindManyPipelineStageArgs } from '../../../core/@generated/pipeline-stage/find-many-pipeline-stage.args'; import { PipelineStageService } from '../services/pipeline-stage.service'; -import { prepareFindManyArgs } from 'src/utils/prepare-find-many'; +import { AbilityGuard } from 'src/guards/ability.guard'; +import { CheckAbilities } from 'src/decorators/check-abilities.decorator'; +import { ReadPipelineStageAbilityHandler } from 'src/ability/handlers/pipeline-stage.ability-handler'; +import { UserAbility } from 'src/decorators/user-ability.decorator'; +import { AppAbility } from 'src/ability/ability.factory'; @UseGuards(JwtAuthGuard) @Resolver(() => PipelineStage) @@ -14,15 +17,18 @@ export class PipelineStageResolver { constructor(private readonly pipelineStageService: PipelineStageService) {} @Query(() => [PipelineStage]) + @UseGuards(AbilityGuard) + @CheckAbilities(ReadPipelineStageAbilityHandler) async findManyPipelineStage( @Args() args: FindManyPipelineStageArgs, - @AuthWorkspace() workspace: Workspace, + @UserAbility() ability: AppAbility, ) { - const preparedArgs = prepareFindManyArgs( - args, - workspace, - ); - - return this.pipelineStageService.findMany(preparedArgs); + return this.pipelineStageService.findMany({ + ...args, + where: { + ...args.where, + AND: [accessibleBy(ability).PipelineStage], + }, + }); } } diff --git a/server/src/core/pipeline/resolvers/pipeline.resolver.spec.ts b/server/src/core/pipeline/resolvers/pipeline.resolver.spec.ts index c23e9c62a6..0eebfb9c9b 100644 --- a/server/src/core/pipeline/resolvers/pipeline.resolver.spec.ts +++ b/server/src/core/pipeline/resolvers/pipeline.resolver.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PipelineResolver } from './pipeline.resolver'; import { PipelineService } from '../services/pipeline.service'; +import { AbilityFactory } from 'src/ability/ability.factory'; describe('PipelineResolver', () => { let resolver: PipelineResolver; @@ -13,6 +14,10 @@ describe('PipelineResolver', () => { provide: PipelineService, useValue: {}, }, + { + provide: AbilityFactory, + useValue: {}, + }, ], }).compile(); diff --git a/server/src/core/pipeline/resolvers/pipeline.resolver.ts b/server/src/core/pipeline/resolvers/pipeline.resolver.ts index 977bad29b9..c3bbc58763 100644 --- a/server/src/core/pipeline/resolvers/pipeline.resolver.ts +++ b/server/src/core/pipeline/resolvers/pipeline.resolver.ts @@ -1,12 +1,15 @@ import { Resolver, Args, Query } from '@nestjs/graphql'; import { UseGuards } from '@nestjs/common'; +import { accessibleBy } from '@casl/prisma'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; -import { Workspace } from '../../@generated/workspace/workspace.model'; -import { AuthWorkspace } from '../../../decorators/auth-workspace.decorator'; import { Pipeline } from '../../@generated/pipeline/pipeline.model'; import { FindManyPipelineArgs } from '../../@generated/pipeline/find-many-pipeline.args'; import { PipelineService } from '../services/pipeline.service'; -import { prepareFindManyArgs } from 'src/utils/prepare-find-many'; +import { AbilityGuard } from 'src/guards/ability.guard'; +import { CheckAbilities } from 'src/decorators/check-abilities.decorator'; +import { ReadPipelineAbilityHandler } from 'src/ability/handlers/pipeline.ability-handler'; +import { UserAbility } from 'src/decorators/user-ability.decorator'; +import { AppAbility } from 'src/ability/ability.factory'; @UseGuards(JwtAuthGuard) @Resolver(() => Pipeline) @@ -14,14 +17,18 @@ export class PipelineResolver { constructor(private readonly pipelineService: PipelineService) {} @Query(() => [Pipeline]) + @UseGuards(AbilityGuard) + @CheckAbilities(ReadPipelineAbilityHandler) async findManyPipeline( @Args() args: FindManyPipelineArgs, - @AuthWorkspace() workspace: Workspace, + @UserAbility() ability: AppAbility, ) { - const preparedArgs = prepareFindManyArgs( - args, - workspace, - ); - return this.pipelineService.findMany(preparedArgs); + return this.pipelineService.findMany({ + ...args, + where: { + ...args.where, + AND: [accessibleBy(ability).Pipeline], + }, + }); } } diff --git a/server/src/core/pipeline/services/pipeline-progress.service.spec.ts b/server/src/core/pipeline/services/pipeline-progress.service.spec.ts index ddc45c73a5..c17c0b5adb 100644 --- a/server/src/core/pipeline/services/pipeline-progress.service.spec.ts +++ b/server/src/core/pipeline/services/pipeline-progress.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PipelineProgressService } from './pipeline-progress.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; describe('PipelineProgressService', () => { let service: PipelineProgressService; diff --git a/server/src/core/pipeline/services/pipeline-stage.service.spec.ts b/server/src/core/pipeline/services/pipeline-stage.service.spec.ts index 275430abef..da462ef1e2 100644 --- a/server/src/core/pipeline/services/pipeline-stage.service.spec.ts +++ b/server/src/core/pipeline/services/pipeline-stage.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PipelineStageService } from './pipeline-stage.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; describe('PipelineStageService', () => { let service: PipelineStageService; diff --git a/server/src/core/pipeline/services/pipeline.service.spec.ts b/server/src/core/pipeline/services/pipeline.service.spec.ts index 3dcff4e031..c4c3973156 100644 --- a/server/src/core/pipeline/services/pipeline.service.spec.ts +++ b/server/src/core/pipeline/services/pipeline.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PipelineService } from './pipeline.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; describe('PipelineService', () => { let service: PipelineService; diff --git a/server/src/core/user/user.service.spec.ts b/server/src/core/user/user.service.spec.ts index b1585d40f5..9be9bf74f8 100644 --- a/server/src/core/user/user.service.spec.ts +++ b/server/src/core/user/user.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UserService } from './user.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; import { WorkspaceService } from '../workspace/services/workspace.service'; import { WorkspaceMemberService } from '../workspace/services/workspace-member.service'; diff --git a/server/src/core/workspace/services/workspace-member.service.spec.ts b/server/src/core/workspace/services/workspace-member.service.spec.ts index 8a65ad4544..e4eacfc80a 100644 --- a/server/src/core/workspace/services/workspace-member.service.spec.ts +++ b/server/src/core/workspace/services/workspace-member.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { WorkspaceMemberService } from './workspace-member.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; describe('WorkspaceMemberService', () => { let service: WorkspaceMemberService; diff --git a/server/src/core/workspace/services/workspace.service.spec.ts b/server/src/core/workspace/services/workspace.service.spec.ts index f566cb3142..965c609626 100644 --- a/server/src/core/workspace/services/workspace.service.spec.ts +++ b/server/src/core/workspace/services/workspace.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { WorkspaceService } from './workspace.service'; import { PrismaService } from 'src/database/prisma.service'; -import { prismaMock } from 'src/prisma-mock/jest-prisma-singleton'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; describe('WorkspaceService', () => { let service: WorkspaceService; diff --git a/server/src/prisma-mock/client.ts b/server/src/database/client-mock/client.ts similarity index 100% rename from server/src/prisma-mock/client.ts rename to server/src/database/client-mock/client.ts diff --git a/server/src/database/client-mock/context.ts b/server/src/database/client-mock/context.ts deleted file mode 100644 index 0b66a3ac3e..0000000000 --- a/server/src/database/client-mock/context.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -import { mockDeep, DeepMockProxy } from 'jest-mock-extended'; - -export type Context = { - prisma: PrismaClient; -}; - -export type MockContext = { - prisma: DeepMockProxy; -}; - -export const createMockContext = (): MockContext => { - return { - prisma: mockDeep(), - }; -}; diff --git a/server/src/prisma-mock/jest-prisma-singleton.ts b/server/src/database/client-mock/jest-prisma-singleton.ts similarity index 100% rename from server/src/prisma-mock/jest-prisma-singleton.ts rename to server/src/database/client-mock/jest-prisma-singleton.ts diff --git a/server/src/decorators/check-abilities.decorator.ts b/server/src/decorators/check-abilities.decorator.ts new file mode 100644 index 0000000000..fedd12f00f --- /dev/null +++ b/server/src/decorators/check-abilities.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { AbilityHandler } from 'src/ability/interfaces/ability-handler.interface'; + +export const CHECK_ABILITIES_KEY = 'check_abilities'; +export const CheckAbilities = (...handlers: AbilityHandler[]) => + SetMetadata(CHECK_ABILITIES_KEY, handlers); diff --git a/server/src/decorators/user-ability.decorator.ts b/server/src/decorators/user-ability.decorator.ts new file mode 100644 index 0000000000..062484fd37 --- /dev/null +++ b/server/src/decorators/user-ability.decorator.ts @@ -0,0 +1,10 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { getRequest } from 'src/utils/extract-request'; + +export const UserAbility = createParamDecorator( + (_: unknown, context: ExecutionContext) => { + const request = getRequest(context); + + return request.ability; + }, +); diff --git a/server/src/guards/ability.guard.ts b/server/src/guards/ability.guard.ts new file mode 100644 index 0000000000..e0536f1718 --- /dev/null +++ b/server/src/guards/ability.guard.ts @@ -0,0 +1,68 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ModuleRef, Reflector } from '@nestjs/core'; +import { PassportUser } from 'src/core/auth/strategies/jwt.auth.strategy'; +import { CHECK_ABILITIES_KEY } from 'src/decorators/check-abilities.decorator'; +import { AbilityFactory, AppAbility } from 'src/ability/ability.factory'; +import { AbilityHandler } from 'src/ability/interfaces/ability-handler.interface'; +import { assert } from 'src/utils/assert'; +import { getRequest } from 'src/utils/extract-request'; + +@Injectable() +export class AbilityGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly abilityFactory: AbilityFactory, + private readonly moduleRef: ModuleRef, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const handlers = + this.reflector.get( + CHECK_ABILITIES_KEY, + context.getHandler(), + ) || []; + + const request = getRequest(context); + const passportUser = request?.user as PassportUser | null | undefined; + + assert(passportUser, '', UnauthorizedException); + + const ability = this.abilityFactory.defineAbility( + passportUser.user, + passportUser.workspace, + ); + + request.ability = ability; + + for (const handler of handlers) { + const result = await this._execAbilityHandler(handler, ability, context); + + if (!result) { + return false; + } + } + + return true; + } + + private async _execAbilityHandler( + abilityHandler: AbilityHandler, + ability: AppAbility, + context: ExecutionContext, + ) { + const handler = this.moduleRef.get(abilityHandler, { strict: false }); + + if (!handler) { + throw new Error(`Handler of type ${abilityHandler.name} not provided`); + } + + const res = await handler.handle(ability, context); + + return res; + } +} diff --git a/server/yarn.lock b/server/yarn.lock index 08e9bee4f0..c7d01a7fd2 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -570,6 +570,21 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@casl/ability@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@casl/ability/-/ability-6.5.0.tgz#a151a7637886099b8ffe52a96601225004a5c157" + integrity sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw== + dependencies: + "@ucast/mongo2js" "^1.3.0" + +"@casl/prisma@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@casl/prisma/-/prisma-1.4.0.tgz#0b446e272c2b1ab300de8958c39aa534e3a29db2" + integrity sha512-edDoBfm2aSww5HLyAqKmSYlGqCX06Bo8j+4P8hBNuIxmSO97Q1jEO8hkCzMThnucuGFEbNvUct1+K64CH2zTWQ== + dependencies: + "@ucast/core" "^1.10.0" + "@ucast/js" "^3.0.1" + "@colors/colors@1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" @@ -1808,6 +1823,34 @@ "@typescript-eslint/types" "5.59.11" eslint-visitor-keys "^3.3.0" +"@ucast/core@^1.0.0", "@ucast/core@^1.10.0", "@ucast/core@^1.4.1", "@ucast/core@^1.6.1": + version "1.10.2" + resolved "https://registry.yarnpkg.com/@ucast/core/-/core-1.10.2.tgz#30b6b893479823265368e528b61b042f752f2c92" + integrity sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g== + +"@ucast/js@^3.0.0", "@ucast/js@^3.0.1": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@ucast/js/-/js-3.0.3.tgz#6ff618a85bd95f1a8f46658cc663a1f798de327f" + integrity sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ== + dependencies: + "@ucast/core" "^1.0.0" + +"@ucast/mongo2js@^1.3.0": + version "1.3.4" + resolved "https://registry.yarnpkg.com/@ucast/mongo2js/-/mongo2js-1.3.4.tgz#579f9e5eb074cba54640d5c70c71c500580f3af3" + integrity sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA== + dependencies: + "@ucast/core" "^1.6.1" + "@ucast/js" "^3.0.0" + "@ucast/mongo" "^2.4.0" + +"@ucast/mongo@^2.4.0": + version "2.4.3" + resolved "https://registry.yarnpkg.com/@ucast/mongo/-/mongo-2.4.3.tgz#92b1dd7c0ab06a907f2ab1422aa3027518ccc05e" + integrity sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA== + dependencies: + "@ucast/core" "^1.4.1" + "@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24"