feat: role authorizations - WIP

This commit is contained in:
Philipinho
2024-03-08 23:55:42 +00:00
parent 3e174b3838
commit b42fe48e9b
15 changed files with 263 additions and 14 deletions

View File

@ -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",

View File

@ -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<AppAbility>(createMongoAbility);
const userRole = workspace?.workspaceUser.role;
console.log(userRole);
if (userRole === Role.OWNER) {
// Workspace Users
can<any>([Action.Manage], Workspace);
can<any>([Action.Manage], WorkspaceUser);
can<any>([Action.Manage], WorkspaceInvitation);
// Groups
can<any>([Action.Manage], Group);
can<any>([Action.Manage], GroupUser);
// Attachments
can<any>([Action.Manage], Attachment);
}
if (userRole === Role.MEMBER) {
can<any>([Action.Read], WorkspaceUser);
// Groups
can<any>([Action.Read], Group);
can<any>([Action.Read], GroupUser);
// Attachments
can<any>([Action.Read, Action.Create], Attachment);
}
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
createForUser(user: User) {
const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
can<any>([Action.Manage], User, { id: user.id });
can<any>([Action.Read], User);
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
}

View File

@ -0,0 +1,7 @@
export enum Action {
Manage = 'manage',
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}

View File

@ -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 {}

View File

@ -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);

View File

@ -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<boolean> {
const policyHandlers =
this.reflector.get<PolicyHandler[]>(
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);
}
}

View File

@ -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;

View File

@ -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 {}

View File

@ -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(

View File

@ -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);

View File

@ -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<UserWithWorkspace> {
const user: User = await this.findById(userId);
if (!user) {

View File

@ -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(

View File

@ -86,4 +86,6 @@ export class Workspace {
@OneToMany(() => Group, (group) => group.workspace)
groups: [];
workspaceUser?: WorkspaceUser;
}

View File

@ -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<Workspace> {
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(