diff --git a/apps/server/package.json b/apps/server/package.json index d77bd1b7..c6f3fbee 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.456.0", "@aws-sdk/s3-request-presigner": "^3.456.0", + "@casl/ability": "^6.7.0", "@fastify/multipart": "^8.1.0", "@fastify/static": "^6.12.0", "@nestjs/common": "^10.3.0", diff --git a/apps/server/src/core/casl/abilities/casl-ability.factory.ts b/apps/server/src/core/casl/abilities/casl-ability.factory.ts new file mode 100644 index 00000000..b36b8351 --- /dev/null +++ b/apps/server/src/core/casl/abilities/casl-ability.factory.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import { + AbilityBuilder, + createMongoAbility, + ExtractSubjectType, + InferSubjects, + MongoAbility, +} from '@casl/ability'; +import { User } from '../../user/entities/user.entity'; +import { Action } from '../ability.action'; +import { Workspace } from '../../workspace/entities/workspace.entity'; +import { WorkspaceUser } from '../../workspace/entities/workspace-user.entity'; +import { WorkspaceInvitation } from '../../workspace/entities/workspace-invitation.entity'; +import { Role } from '../../../helpers/types/permission'; +import { Group } from '../../group/entities/group.entity'; +import { GroupUser } from '../../group/entities/group-user.entity'; +import { Attachment } from '../../attachment/entities/attachment.entity'; +import { Space } from '../../space/entities/space.entity'; +import { SpaceUser } from '../../space/entities/space-user.entity'; +import { Page } from '../../page/entities/page.entity'; +import { Comment } from '../../comment/entities/comment.entity'; + +export type Subjects = + | InferSubjects< + | typeof Workspace + | typeof WorkspaceUser + | typeof WorkspaceInvitation + | typeof Space + | typeof SpaceUser + | typeof Group + | typeof GroupUser + | typeof Attachment + | typeof Comment + | typeof Page + | typeof User + > + | 'all'; +export type AppAbility = MongoAbility<[Action, Subjects]>; + +@Injectable() +export default class CaslAbilityFactory { + createForWorkspace(user: User, workspace: Workspace) { + const { can, build } = new AbilityBuilder(createMongoAbility); + + const userRole = workspace?.workspaceUser.role; + console.log(userRole); + + if (userRole === Role.OWNER) { + // Workspace Users + can([Action.Manage], Workspace); + can([Action.Manage], WorkspaceUser); + can([Action.Manage], WorkspaceInvitation); + + // Groups + can([Action.Manage], Group); + can([Action.Manage], GroupUser); + + // Attachments + can([Action.Manage], Attachment); + } + + if (userRole === Role.MEMBER) { + can([Action.Read], WorkspaceUser); + + // Groups + can([Action.Read], Group); + can([Action.Read], GroupUser); + + // Attachments + can([Action.Read, Action.Create], Attachment); + } + + return build({ + detectSubjectType: (item) => + item.constructor as ExtractSubjectType, + }); + } + + createForUser(user: User) { + const { can, build } = new AbilityBuilder(createMongoAbility); + + can([Action.Manage], User, { id: user.id }); + can([Action.Read], User); + + return build({ + detectSubjectType: (item) => + item.constructor as ExtractSubjectType, + }); + } +} diff --git a/apps/server/src/core/casl/ability.action.ts b/apps/server/src/core/casl/ability.action.ts new file mode 100644 index 00000000..b398b218 --- /dev/null +++ b/apps/server/src/core/casl/ability.action.ts @@ -0,0 +1,7 @@ +export enum Action { + Manage = 'manage', + Create = 'create', + Read = 'read', + Update = 'update', + Delete = 'delete', +} diff --git a/apps/server/src/core/casl/casl.module.ts b/apps/server/src/core/casl/casl.module.ts new file mode 100644 index 00000000..2aeaff65 --- /dev/null +++ b/apps/server/src/core/casl/casl.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import CaslAbilityFactory from './abilities/casl-ability.factory'; + +@Global() +@Module({ + providers: [CaslAbilityFactory], + exports: [CaslAbilityFactory], +}) +export class CaslModule {} diff --git a/apps/server/src/core/casl/decorators/policies.decorator.ts b/apps/server/src/core/casl/decorators/policies.decorator.ts new file mode 100644 index 00000000..73286636 --- /dev/null +++ b/apps/server/src/core/casl/decorators/policies.decorator.ts @@ -0,0 +1,6 @@ +import { PolicyHandler } from '../interfaces/policy-handler.interface'; +import { SetMetadata } from '@nestjs/common'; + +export const CHECK_POLICIES_KEY = 'check_policy'; +export const CheckPolicies = (...handlers: PolicyHandler[]) => + SetMetadata(CHECK_POLICIES_KEY, handlers); diff --git a/apps/server/src/core/casl/guards/policies.guard.ts b/apps/server/src/core/casl/guards/policies.guard.ts new file mode 100644 index 00000000..62151600 --- /dev/null +++ b/apps/server/src/core/casl/guards/policies.guard.ts @@ -0,0 +1,40 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import CaslAbilityFactory, { + AppAbility, +} from '../abilities/casl-ability.factory'; +import { PolicyHandler } from '../interfaces/policy-handler.interface'; +import { CHECK_POLICIES_KEY } from '../decorators/policies.decorator'; + +@Injectable() +export class PoliciesGuard implements CanActivate { + constructor( + private reflector: Reflector, + private caslAbilityFactory: CaslAbilityFactory, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policyHandlers = + this.reflector.get( + CHECK_POLICIES_KEY, + context.getHandler(), + ) || []; + + const request = context.switchToHttp().getRequest(); + const user = request['user'].user; + const workspace = request['user'].workspace; + + const ability = this.caslAbilityFactory.createForWorkspace(user, workspace); + + return policyHandlers.every((handler) => + this.execPolicyHandler(handler, ability), + ); + } + + private execPolicyHandler(handler: PolicyHandler, ability: AppAbility) { + if (typeof handler === 'function') { + return handler(ability); + } + return handler.handle(ability); + } +} diff --git a/apps/server/src/core/casl/interfaces/policy-handler.interface.ts b/apps/server/src/core/casl/interfaces/policy-handler.interface.ts new file mode 100644 index 00000000..4b33d571 --- /dev/null +++ b/apps/server/src/core/casl/interfaces/policy-handler.interface.ts @@ -0,0 +1,9 @@ +import { AppAbility } from '../abilities/casl-ability.factory'; + +interface IPolicyHandler { + handle(ability: AppAbility): boolean; +} + +type PolicyHandlerCallback = (ability: AppAbility) => boolean; + +export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback; diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index c8319c89..75eb0312 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -10,6 +10,7 @@ import { CommentModule } from './comment/comment.module'; import { SearchModule } from './search/search.module'; import { SpaceModule } from './space/space.module'; import { GroupModule } from './group/group.module'; +import { CaslModule } from './casl/casl.module'; @Module({ imports: [ @@ -25,6 +26,7 @@ import { GroupModule } from './group/group.module'; SearchModule, SpaceModule, GroupModule, + CaslModule, ], }) export class CoreModule {} diff --git a/apps/server/src/core/group/group.controller.ts b/apps/server/src/core/group/group.controller.ts index b8535020..c48fbd49 100644 --- a/apps/server/src/core/group/group.controller.ts +++ b/apps/server/src/core/group/group.controller.ts @@ -19,6 +19,12 @@ import { PaginationOptions } from '../../helpers/pagination/pagination-options'; import { AddGroupUserDto } from './dto/add-group-user.dto'; import { RemoveGroupUserDto } from './dto/remove-group-user.dto'; import { UpdateGroupDto } from './dto/update-group.dto'; +import { Action } from '../casl/ability.action'; +import { Group } from './entities/group.entity'; +import { GroupUser } from './entities/group-user.entity'; +import { PoliciesGuard } from '../casl/guards/policies.guard'; +import { CheckPolicies } from '../casl/decorators/policies.decorator'; +import { AppAbility } from '../casl/abilities/casl-ability.factory'; @UseGuards(JwtGuard) @Controller('groups') @@ -38,6 +44,8 @@ export class GroupController { return this.groupService.getGroupsInWorkspace(workspace.id, pagination); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Read, Group)) @HttpCode(HttpStatus.OK) @Post('/details') getGroup( @@ -48,6 +56,8 @@ export class GroupController { return this.groupService.getGroup(groupIdDto.groupId, workspace.id); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, Group)) @HttpCode(HttpStatus.OK) @Post('create') createGroup( @@ -58,6 +68,8 @@ export class GroupController { return this.groupService.createGroup(user, workspace.id, createGroupDto); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, Group)) @HttpCode(HttpStatus.OK) @Post('update') updateGroup( @@ -68,6 +80,8 @@ export class GroupController { return this.groupService.updateGroup(workspace.id, updateGroupDto); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Read, GroupUser)) @HttpCode(HttpStatus.OK) @Post('members') getGroupMembers( @@ -82,6 +96,8 @@ export class GroupController { ); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, GroupUser)) @HttpCode(HttpStatus.OK) @Post('members/add') addGroupMember( @@ -96,6 +112,8 @@ export class GroupController { ); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, GroupUser)) @HttpCode(HttpStatus.OK) @Post('members/remove') removeGroupMember( @@ -109,6 +127,8 @@ export class GroupController { ); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, Group)) @HttpCode(HttpStatus.OK) @Post('delete') deleteGroup( diff --git a/apps/server/src/core/user/user.controller.ts b/apps/server/src/core/user/user.controller.ts index 4037435d..67c177d6 100644 --- a/apps/server/src/core/user/user.controller.ts +++ b/apps/server/src/core/user/user.controller.ts @@ -1,6 +1,5 @@ import { Controller, - Get, UseGuards, HttpCode, HttpStatus, @@ -16,12 +15,12 @@ import { UpdateUserDto } from './dto/update-user.dto'; import { AuthUser } from '../../decorators/auth-user.decorator'; @UseGuards(JwtGuard) -@Controller('user') +@Controller('users') export class UserController { constructor(private readonly userService: UserService) {} @HttpCode(HttpStatus.OK) - @Get('me') + @Post('me') async getUser(@AuthUser() authUser: User) { const user: User = await this.userService.findById(authUser.id); @@ -33,7 +32,7 @@ export class UserController { } @HttpCode(HttpStatus.OK) - @Get('info') + @Post('info') async getUserInfo(@AuthUser() user: User) { const data: { workspace: Workspace; user: User } = await this.userService.getUserInstance(user.id); diff --git a/apps/server/src/core/user/user.service.ts b/apps/server/src/core/user/user.service.ts index a29ecc62..51af6ee9 100644 --- a/apps/server/src/core/user/user.service.ts +++ b/apps/server/src/core/user/user.service.ts @@ -13,6 +13,12 @@ import { WorkspaceService } from '../workspace/services/workspace.service'; import { DataSource, EntityManager } from 'typeorm'; import { transactionWrapper } from '../../helpers/db.helper'; import { CreateWorkspaceDto } from '../workspace/dto/create-workspace.dto'; +import { Workspace } from '../workspace/entities/workspace.entity'; + +export type UserWithWorkspace = { + user: User; + workspace: Workspace; +}; @Injectable() export class UserService { @@ -59,7 +65,7 @@ export class UserService { return user; } - async getUserInstance(userId: string) { + async getUserInstance(userId: string): Promise { const user: User = await this.findById(userId); if (!user) { diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts index 2e999b70..3d0e1689 100644 --- a/apps/server/src/core/workspace/controllers/workspace.controller.ts +++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts @@ -26,6 +26,12 @@ import { InviteUserDto, RevokeInviteDto, } from '../dto/invitation.dto'; +import { Action } from '../../casl/ability.action'; +import { WorkspaceUser } from '../entities/workspace-user.entity'; +import { WorkspaceInvitation } from '../entities/workspace-invitation.entity'; +import { CheckPolicies } from '../../casl/decorators/policies.decorator'; +import { AppAbility } from '../../casl/abilities/casl-ability.factory'; +import { PoliciesGuard } from '../../casl/guards/policies.guard'; @UseGuards(JwtGuard) @Controller('workspaces') @@ -57,6 +63,8 @@ export class WorkspaceController { } */ + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, Workspace)) @HttpCode(HttpStatus.OK) @Post('update') async updateWorkspace( @@ -66,12 +74,18 @@ export class WorkspaceController { return this.workspaceService.update(workspace.id, updateWorkspaceDto); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, Workspace)) @HttpCode(HttpStatus.OK) @Post('delete') async deleteWorkspace(@Body() deleteWorkspaceDto: DeleteWorkspaceDto) { return this.workspaceService.delete(deleteWorkspaceDto); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => + ability.can(Action.Read, WorkspaceUser), + ) @HttpCode(HttpStatus.OK) @Post('members') async getWorkspaceMembers( @@ -85,6 +99,10 @@ export class WorkspaceController { ); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => + ability.can(Action.Manage, WorkspaceUser), + ) @HttpCode(HttpStatus.OK) @Post('members/add') async addWorkspaceMember( @@ -98,6 +116,10 @@ export class WorkspaceController { ); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => + ability.can(Action.Manage, WorkspaceUser), + ) @HttpCode(HttpStatus.OK) @Post('members/remove') async removeWorkspaceMember( @@ -110,6 +132,10 @@ export class WorkspaceController { ); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => + ability.can(Action.Manage, WorkspaceUser), + ) @HttpCode(HttpStatus.OK) @Post('members/role') async updateWorkspaceMemberRole( @@ -124,6 +150,10 @@ export class WorkspaceController { ); } + @UseGuards(PoliciesGuard) + @CheckPolicies((ability: AppAbility) => + ability.can(Action.Manage, WorkspaceInvitation), + ) @HttpCode(HttpStatus.OK) @Post('invite') async inviteUser( diff --git a/apps/server/src/core/workspace/entities/workspace.entity.ts b/apps/server/src/core/workspace/entities/workspace.entity.ts index a8e30e0f..d8911086 100644 --- a/apps/server/src/core/workspace/entities/workspace.entity.ts +++ b/apps/server/src/core/workspace/entities/workspace.entity.ts @@ -86,4 +86,6 @@ export class Workspace { @OneToMany(() => Group, (group) => group.workspace) groups: []; + + workspaceUser?: WorkspaceUser; } diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 53baab9a..6a8e5819 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -1,8 +1,4 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { CreateWorkspaceDto } from '../dto/create-workspace.dto'; import { WorkspaceRepository } from '../repositories/workspace.repository'; import { WorkspaceUserRepository } from '../repositories/workspace-user.repository'; @@ -15,12 +11,10 @@ import { plainToInstance } from 'class-transformer'; import { v4 as uuid } from 'uuid'; import { UpdateWorkspaceDto } from '../dto/update-workspace.dto'; import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto'; -import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto'; import { SpaceService } from '../../space/space.service'; import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; import { PaginationMetaDto } from '../../../helpers/pagination/pagination-meta-dto'; import { PaginatedResult } from '../../../helpers/pagination/paginated-result'; -import { User } from '../../user/entities/user.entity'; import { DataSource, EntityManager } from 'typeorm'; import { transactionWrapper } from '../../../helpers/db.helper'; import { CreateSpaceDto } from '../../space/dto/create-space.dto'; @@ -187,8 +181,8 @@ export class WorkspaceService { async getUserCurrentWorkspace(userId: string): Promise { const userWorkspace = await this.workspaceUserRepository.findOne({ - where: { userId: userId }, relations: ['workspace'], + where: { userId: userId }, order: { createdAt: 'ASC', }, @@ -198,7 +192,8 @@ export class WorkspaceService { throw new NotFoundException('No workspace found for this user'); } - return userWorkspace.workspace; + const { workspace, ...workspaceUser } = userWorkspace; + return { ...workspace, workspaceUser } as Workspace; } async getUserWorkspaces( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3322980..c268143d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.456.0 version: 3.485.0 + '@casl/ability': + specifier: ^6.7.0 + version: 6.7.0 '@fastify/multipart': specifier: ^8.1.0 version: 8.1.0 @@ -2379,6 +2382,12 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true + /@casl/ability@6.7.0: + resolution: {integrity: sha512-NC51ha1nnfCMy88Gdk7cTBipv6n3QNo1yZA68EklsUIzWVDhTs9jJ5y70c3LpT6sN1GcUnGBP/cF7M2I4TkQ3w==} + dependencies: + '@ucast/mongo2js': 1.3.4 + dev: false + /@colors/colors@1.5.0: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -5394,6 +5403,30 @@ packages: eslint-visitor-keys: 3.4.3 dev: true + /@ucast/core@1.10.2: + resolution: {integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==} + dev: false + + /@ucast/js@3.0.4: + resolution: {integrity: sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q==} + dependencies: + '@ucast/core': 1.10.2 + dev: false + + /@ucast/mongo2js@1.3.4: + resolution: {integrity: sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==} + dependencies: + '@ucast/core': 1.10.2 + '@ucast/js': 3.0.4 + '@ucast/mongo': 2.4.3 + dev: false + + /@ucast/mongo@2.4.3: + resolution: {integrity: sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==} + dependencies: + '@ucast/core': 1.10.2 + dev: false + /@ungap/structured-clone@1.2.0: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true