diff --git a/package-lock.json b/package-lock.json index 2d5a32c..18c2bcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@aws-sdk/client-s3": "3.504.0", "@aws-sdk/s3-request-presigner": "^3.498.0", + "@casl/ability": "^6.7.1", "@nestjs/common": "10.3.2", "@nestjs/config": "3.2.0", "@nestjs/core": "10.3.2", @@ -2174,6 +2175,17 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@casl/ability": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.1.tgz", + "integrity": "sha512-e+Vgrehd1/lzOSwSqKHtmJ6kmIuZbGBlM2LBS5IuYGGKmVHuhUuyh3XgTn1VIw9+TO4gqU+uptvxfIRBUEdJuw==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -5788,6 +5800,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "node_modules/@ucast/js": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.4.tgz", + "integrity": "sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", diff --git a/package.json b/package.json index 028d3ca..f7b021e 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "@aws-sdk/client-s3": "3.504.0", "@aws-sdk/s3-request-presigner": "^3.498.0", + "@casl/ability": "^6.7.1", "@nestjs/common": "10.3.2", "@nestjs/config": "3.2.0", "@nestjs/core": "10.3.2", diff --git a/src/app.module.ts b/src/app.module.ts index 636c0f7..2b6f689 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -37,6 +37,7 @@ import { ThreadsModule } from './threads/threads.module'; import { EventsModule } from './events/events.module'; import { InvitesModule } from './invites/invites.module'; import { WorkspaceChannelModule } from './workspace-channel/workspace-channel.module'; +import { CaslModule } from './casl/casl.module'; @Module({ imports: [ @@ -106,6 +107,7 @@ import { WorkspaceChannelModule } from './workspace-channel/workspace-channel.mo EventsModule, InvitesModule, WorkspaceChannelModule, + CaslModule, ], providers: [], }) diff --git a/src/authz/check-policies/check-policies.decorator.ts b/src/authz/check-policies/check-policies.decorator.ts new file mode 100644 index 0000000..747ead7 --- /dev/null +++ b/src/authz/check-policies/check-policies.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { PolicyHandler } from './policy-handler.type'; + +export const CHECK_POLICIES_KEY = 'check_policy'; +export const CheckPolicies = (...handlers: PolicyHandler[]) => + SetMetadata(CHECK_POLICIES_KEY, handlers); diff --git a/src/authz/check-policies/policy-handler.type.ts b/src/authz/check-policies/policy-handler.type.ts new file mode 100644 index 0000000..b8dfa3d --- /dev/null +++ b/src/authz/check-policies/policy-handler.type.ts @@ -0,0 +1,9 @@ +import { AppAbility } from 'src/casl/casl-ability.factory/casl-ability.factory'; + +interface IPolicyHandler { + handle(ability: AppAbility): boolean; +} + +type PolicyHandlerCallback = (ability: AppAbility) => boolean; + +export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback; diff --git a/src/authz/policies.guard/policies.guard.ts b/src/authz/policies.guard/policies.guard.ts new file mode 100644 index 0000000..d030d01 --- /dev/null +++ b/src/authz/policies.guard/policies.guard.ts @@ -0,0 +1,39 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { + AppAbility, + CaslAbilityFactory, +} from 'src/casl/casl-ability.factory/casl-ability.factory'; +import { PolicyHandler } from '../check-policies/policy-handler.type'; +import { CHECK_POLICIES_KEY } from '../check-policies/check-policies.decorator'; + +@Injectable() +export class PoliciesGuard implements CanActivate { + constructor( + private reflector: Reflector, + private caslAbilityFactory: CaslAbilityFactory, + ) {} + + // eslint-disable-next-line @typescript-eslint/require-await + async canActivate(context: ExecutionContext): Promise { + const policyHandlers = + this.reflector.get( + CHECK_POLICIES_KEY, + context.getHandler(), + ) || []; + + const { user } = context.switchToHttp().getRequest(); + const ability = this.caslAbilityFactory.createForUser(user); + + 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/src/casl/action.enum.ts b/src/casl/action.enum.ts new file mode 100644 index 0000000..c448a74 --- /dev/null +++ b/src/casl/action.enum.ts @@ -0,0 +1,7 @@ +export enum Action { + MANAGE = 'manage', + CREATE = 'create', + READ = 'read', + UPDATE = 'update', + DELETE = 'delete', +} diff --git a/src/casl/casl-ability.factory/casl-ability.factory.ts b/src/casl/casl-ability.factory/casl-ability.factory.ts new file mode 100644 index 0000000..730037b --- /dev/null +++ b/src/casl/casl-ability.factory/casl-ability.factory.ts @@ -0,0 +1,50 @@ +import { + AbilityBuilder, + ExtractSubjectType, + InferSubjects, + MongoAbility, + MongoQuery, + createMongoAbility, +} from '@casl/ability'; +import { Channel } from 'src/channels/domain/channel'; +import { FlatChannel } from './typs'; +import { Message } from 'src/messages/domain/message'; +import { Workspace } from 'src/workspaces/domain/workspace'; +import { Action } from '../action.enum'; +import { Injectable } from '@nestjs/common'; +import { User } from 'src/users/domain/user'; +import { RoleEnum } from 'src/roles/roles.enum'; + +type Subjects = + | InferSubjects + | 'all'; + +type PossibleAbilities = [Action, Subjects]; +type Conditions = MongoQuery; +export type AppAbility = MongoAbility; + +@Injectable() +export class CaslAbilityFactory { + createForUser(user: User) { + const { can, build } = new AbilityBuilder( + createMongoAbility, + ); + + if (user.role?.id === RoleEnum.admin) { + can(Action.MANAGE, 'all'); + } else { + console.log(user); + console.log(Channel); + can(Action.READ, Channel); + can(Action.CREATE, Channel); + can(Action.UPDATE, Channel, { 'owner.id': user.id }); + can(Action.DELETE, Channel, { 'owner.id': user.id }); + } + + return build({ + // Read https://casl.js.org/v5/en/guide/subject-type-detection#use-classes-as-subject-types for details + detectSubjectType: (item) => + item.constructor as ExtractSubjectType, + }); + } +} diff --git a/src/casl/casl-ability.factory/typs.ts b/src/casl/casl-ability.factory/typs.ts new file mode 100644 index 0000000..6a192ea --- /dev/null +++ b/src/casl/casl-ability.factory/typs.ts @@ -0,0 +1,5 @@ +import { Channel } from 'src/channels/domain/channel'; + +export type FlatChannel = Channel & { + 'owner.id': Channel['owner']['id']; +}; diff --git a/src/casl/casl.module.ts b/src/casl/casl.module.ts new file mode 100644 index 0000000..14296fa --- /dev/null +++ b/src/casl/casl.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CaslAbilityFactory } from './casl-ability.factory/casl-ability.factory'; + +@Module({ + providers: [CaslAbilityFactory], + exports: [CaslAbilityFactory], +}) +export class CaslModule {} diff --git a/src/channels/channels.controller.ts b/src/channels/channels.controller.ts index 3c3dcc9..9ee1dff 100644 --- a/src/channels/channels.controller.ts +++ b/src/channels/channels.controller.ts @@ -21,17 +21,22 @@ import { UpdateChannelDto } from './dto/update-channel.dto'; import { QueryUserDto } from '../users/dto/query-user.dto'; import { infinityPagination } from '../utils/infinity-pagination'; import { QueryMessageDto } from 'src/messages/dto/query-message.dto'; +import { CheckPolicies } from 'src/authz/check-policies/check-policies.decorator'; +import { AppAbility } from 'src/casl/casl-ability.factory/casl-ability.factory'; +import { Action } from 'src/casl/action.enum'; +import { PoliciesGuard } from 'src/authz/policies.guard/policies.guard'; @ApiTags('Channels') @Controller({ path: 'channels', version: '1', }) +@UseGuards(AuthGuard('jwt'), PoliciesGuard) export class ChannelsController { constructor(private readonly channelsService: ChannelsService) {} @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) + @CheckPolicies((ability: AppAbility) => ability.can(Action.CREATE, Channel)) @Post() @HttpCode(HttpStatus.CREATED) createChannel( @@ -42,7 +47,6 @@ export class ChannelsController { } @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) @Get(':id') @ApiParam({ name: 'id', @@ -53,7 +57,7 @@ export class ChannelsController { } @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) + @CheckPolicies((ability: AppAbility) => ability.can(Action.UPDATE, Channel)) @Patch(':id') @ApiParam({ name: 'id', @@ -68,7 +72,6 @@ export class ChannelsController { } @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) @Get(':id/messages') @ApiParam({ name: 'id', @@ -94,7 +97,6 @@ export class ChannelsController { } @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) @Get(':id/users') @ApiParam({ name: 'id', @@ -127,7 +129,7 @@ export class ChannelsController { } @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) + @CheckPolicies((ability: AppAbility) => ability.can(Action.DELETE, Channel)) @Delete(':id') @ApiParam({ name: 'id', diff --git a/src/channels/channels.module.ts b/src/channels/channels.module.ts index ef51570..c5242d7 100644 --- a/src/channels/channels.module.ts +++ b/src/channels/channels.module.ts @@ -4,11 +4,12 @@ import { ChannelsController } from './channels.controller'; import { ChannelPersistenceModule } from './infrastructure/persistence/persistence.module'; import { MessagesModule } from 'src/messages/messages.module'; import { UsersModule } from '../users/users.module'; +import { CaslAbilityFactory } from 'src/casl/casl-ability.factory/casl-ability.factory'; @Module({ imports: [ChannelPersistenceModule, MessagesModule, UsersModule], controllers: [ChannelsController], - providers: [ChannelsService], + providers: [ChannelsService, CaslAbilityFactory], exports: [ChannelsService], }) export class ChannelsModule {} diff --git a/src/channels/channels.service.ts b/src/channels/channels.service.ts index bf0ffdf..6f43356 100644 --- a/src/channels/channels.service.ts +++ b/src/channels/channels.service.ts @@ -140,10 +140,6 @@ export class ChannelsService { throw new NotFoundException(); } - if (channel.owner.id !== user.id) { - throw new ForbiddenException(); - } - await this.channelRepostory.softDelete(id); }