diff --git a/src/modules/workspaces/domains/workspace-team-user.domain.ts b/src/modules/workspaces/domains/workspace-team-user.domain.ts new file mode 100644 index 000000000..2a77c693b --- /dev/null +++ b/src/modules/workspaces/domains/workspace-team-user.domain.ts @@ -0,0 +1,48 @@ +import { User } from '../../user/user.domain'; +import { WorkspaceTeam } from './workspace-team.domain'; +import { WorkspaceTeamUserAttributes } from '../attributes/workspace-team-users.attributes'; + +export class WorkspaceTeamUser implements WorkspaceTeamUserAttributes { + id: string; + teamId: string; + memberId: string; + team?: WorkspaceTeam; + createdAt: Date; + updatedAt: Date; + + constructor({ + id, + teamId, + memberId, + team, + createdAt, + updatedAt, + }: WorkspaceTeamUserAttributes & { team?: WorkspaceTeam }) { + this.id = id; + this.teamId = teamId; + this.memberId = memberId; + this.team = team; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + static build( + workspaceTeamUser: WorkspaceTeamUserAttributes & { + team?: WorkspaceTeam; + member?: User; + }, + ): WorkspaceTeamUser { + return new WorkspaceTeamUser(workspaceTeamUser); + } + + toJSON() { + return { + id: this.id, + teamId: this.teamId, + memberId: this.memberId, + team: this.team ? this.team.toJSON() : undefined, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } +} diff --git a/src/modules/workspaces/domains/workspace-team.domain.ts b/src/modules/workspaces/domains/workspace-team.domain.ts index ff90620b4..7b53cea62 100644 --- a/src/modules/workspaces/domains/workspace-team.domain.ts +++ b/src/modules/workspaces/domains/workspace-team.domain.ts @@ -1,3 +1,4 @@ +import { User } from '../../user/user.domain'; import { WorkspaceTeamAttributes } from '../attributes/workspace-team.attributes'; export class WorkspaceTeam implements WorkspaceTeamAttributes { @@ -24,6 +25,10 @@ export class WorkspaceTeam implements WorkspaceTeamAttributes { this.updatedAt = updatedAt; } + isUserManager(userUuid: User['uuid']) { + return userUuid === this.managerId; + } + static build(teamAttributes: WorkspaceTeamAttributes): WorkspaceTeam { return new WorkspaceTeam(teamAttributes); } diff --git a/src/modules/workspaces/domains/workspace-user.domain.ts b/src/modules/workspaces/domains/workspace-user.domain.ts new file mode 100644 index 000000000..d36d4c102 --- /dev/null +++ b/src/modules/workspaces/domains/workspace-user.domain.ts @@ -0,0 +1,57 @@ +import { WorkspaceUserAttributes } from '../attributes/workspace-users.attributes'; + +export class WorkspaceUser implements WorkspaceUserAttributes { + id: string; + memberId: string; + key: string; + workspaceId: string; + spaceLimit: bigint; + driveUsage: bigint; + backupsUsage: bigint; + deactivated: boolean; + createdAt: Date; + updatedAt: Date; + + constructor({ + id, + memberId, + key, + workspaceId, + spaceLimit, + driveUsage, + backupsUsage, + deactivated, + createdAt, + updatedAt, + }: WorkspaceUserAttributes) { + this.id = id; + this.memberId = memberId; + this.key = key; + this.workspaceId = workspaceId; + this.spaceLimit = spaceLimit; + this.driveUsage = driveUsage; + this.backupsUsage = backupsUsage; + this.deactivated = deactivated; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + static build(workspaceUser: WorkspaceUserAttributes): WorkspaceUser { + return new WorkspaceUser(workspaceUser); + } + + toJSON() { + return { + id: this.id, + memberId: this.memberId, + key: this.key, + workspaceId: this.workspaceId, + spaceLimit: this.spaceLimit.toString(), + driveUsage: this.driveUsage.toString(), + backupsUsage: this.backupsUsage.toString(), + deactivated: this.deactivated, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } +} diff --git a/src/modules/workspaces/domains/workspaces.domain.ts b/src/modules/workspaces/domains/workspaces.domain.ts index 869c45585..82c4d3131 100644 --- a/src/modules/workspaces/domains/workspaces.domain.ts +++ b/src/modules/workspaces/domains/workspaces.domain.ts @@ -1,3 +1,4 @@ +import { UserAttributes } from '../../user/user.attributes'; import { WorkspaceAttributes } from '../attributes/workspace.attributes'; export class Workspace implements WorkspaceAttributes { @@ -40,6 +41,10 @@ export class Workspace implements WorkspaceAttributes { return new Workspace(user); } + isUserOwner(userUuid: UserAttributes['uuid']) { + return userUuid === this.ownerId; + } + toJSON() { return { id: this.id, diff --git a/src/modules/workspaces/guards/workspace-required-access.decorator.ts b/src/modules/workspaces/guards/workspace-required-access.decorator.ts new file mode 100644 index 000000000..61c66eccf --- /dev/null +++ b/src/modules/workspaces/guards/workspace-required-access.decorator.ts @@ -0,0 +1,29 @@ +import { SetMetadata } from '@nestjs/common'; + +export enum WorkspaceRole { + OWNER = 'owner', + MANAGER = 'manager', + MEMBER = 'member', +} + +export enum AccessContext { + WORKSPACE = 'workspace', + TEAM = 'team', +} + +export const WorkspaceContextIdFieldName = { + [AccessContext.WORKSPACE]: 'workspaceId', + [AccessContext.TEAM]: 'teamId', +}; + +export interface AccessOptions { + accessContext: AccessContext; + requiredRole: WorkspaceRole; + idSource: 'params' | 'body' | 'query'; +} + +export const WorkspaceRequiredAcess = ( + accessContext: AccessContext, + requiredRole: WorkspaceRole, + idSource: 'params' | 'body' | 'query' = 'params', +) => SetMetadata('accessControl', { accessContext, requiredRole, idSource }); diff --git a/src/modules/workspaces/guards/workspaces.guard.ts b/src/modules/workspaces/guards/workspaces.guard.ts new file mode 100644 index 000000000..92195ddb1 --- /dev/null +++ b/src/modules/workspaces/guards/workspaces.guard.ts @@ -0,0 +1,114 @@ +// flexible-access.guard.ts +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { WorkspacesUsecases } from '../workspaces.usecase'; +import { + AccessContext, + AccessOptions, + WorkspaceContextIdFieldName, + WorkspaceRole, +} from './workspace-required-access.decorator'; + +@Injectable() +export class WorkspaceGuard implements CanActivate { + constructor( + private reflector: Reflector, + private workspaceUseCases: WorkspacesUsecases, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const accessOptions: AccessOptions = this.reflector.get( + 'accessControl', + context.getHandler(), + ); + + if (!accessOptions) { + return true; + } + + const { requiredRole, accessContext, idSource } = accessOptions; + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + const id = this.getIdFromRequest( + request, + idSource, + WorkspaceContextIdFieldName[accessContext], + ); + + if (accessContext === AccessContext.WORKSPACE) { + return this.checkUserWorkspaceRole(user.uuid, id, requiredRole); + } else if (accessContext === AccessContext.TEAM) { + return this.checkUserTeamRole(user.uuid, id, requiredRole); + } + + return false; + } + + private async checkUserWorkspaceRole( + userUuid: string, + workspaceId: string, + role: WorkspaceRole, + ) { + const { workspace, workspaceUser } = + await this.workspaceUseCases.findUserInWorkspace(userUuid, workspaceId); + + if (!workspace) { + throw new NotFoundException('Workspace not found'); + } + + if ( + !(workspaceUser && workspaceUser) || + !(role === WorkspaceRole.OWNER && workspace.isUserOwner(userUuid)) + ) { + Logger.log( + `[WORKSPACES/GUARD]: user has no requiered access to workspace. id: ${workspaceId} userUuid: ${userUuid} `, + ); + throw new ForbiddenException('You have no access to this workspace'); + } + + return !!workspaceUser; + } + + private async checkUserTeamRole( + userUuid: string, + teamId: string, + role: WorkspaceRole, + ) { + const { team, teamUser } = await this.workspaceUseCases.findUserInTeam( + userUuid, + teamId, + ); + + const workspace = await this.workspaceUseCases.findById(team.workspaceId); + if (workspace.isUserOwner(userUuid)) { + return true; + } + + if (teamUser && role === WorkspaceRole.MANAGER) { + return team.isUserManager(userUuid); + } + + if (teamUser && role === WorkspaceRole.MEMBER) { + return true; + } + + throw new ForbiddenException('You have no access to this team'); + } + + private getIdFromRequest( + request, + source: 'params' | 'body' | 'query', + field: string, + ): string | undefined { + return request[source]?.[field]; + } +} diff --git a/src/modules/workspaces/models/workspace-team-users.model.ts b/src/modules/workspaces/models/workspace-team-users.model.ts index 768f462ba..ea3ca5002 100644 --- a/src/modules/workspaces/models/workspace-team-users.model.ts +++ b/src/modules/workspaces/models/workspace-team-users.model.ts @@ -14,7 +14,7 @@ import { WorkspaceTeamUserAttributes } from '../attributes/workspace-team-users. @Table({ underscored: true, timestamps: true, - tableName: 'teams_users', + tableName: 'workspace_teams_users', }) export class WorkspaceTeamUserModel extends Model diff --git a/src/modules/workspaces/models/workspace.model.ts b/src/modules/workspaces/models/workspace.model.ts index fae2e96fc..6bb342ddb 100644 --- a/src/modules/workspaces/models/workspace.model.ts +++ b/src/modules/workspaces/models/workspace.model.ts @@ -6,6 +6,7 @@ import { PrimaryKey, ForeignKey, BelongsTo, + HasMany, } from 'sequelize-typescript'; import { UserModel } from '../../user/user.model'; import { WorkspaceUserModel } from './workspace-users.model'; @@ -60,6 +61,9 @@ export class WorkspaceModel extends Model implements WorkspaceAttributes { @Column(DataType.UUID) workspaceUserId: string; + @HasMany(() => WorkspaceUserModel) + workspaceUsers: WorkspaceUserModel[]; + @Column createdAt: Date; diff --git a/src/modules/workspaces/repositories/team.repository.ts b/src/modules/workspaces/repositories/team.repository.ts index f585067fb..e8615f088 100644 --- a/src/modules/workspaces/repositories/team.repository.ts +++ b/src/modules/workspaces/repositories/team.repository.ts @@ -8,6 +8,8 @@ import { WorkspaceTeamModel } from '../models/workspace-team.model'; import { WorkspaceTeamUserModel } from '../models/workspace-team-users.model'; import { WorkspaceTeam } from '../domains/workspace-team.domain'; import { WorkspaceTeamAttributes } from '../attributes/workspace-team.attributes'; +import { UserAttributes } from '../../user/user.attributes'; +import { WorkspaceTeamUser } from '../domains/workspace-team-user.domain'; @Injectable() export class SequelizeWorkspaceTeamRepository { @@ -44,6 +46,26 @@ export class SequelizeWorkspaceTeamRepository { return result.map((teamUser) => User.build({ ...teamUser.member })); } + async getTeamUserAndTeamByTeamId( + userUuid: UserAttributes['uuid'], + teamId: WorkspaceTeamAttributes['id'], + ) { + const team = await this.teamModel.findOne({ + where: { id: teamId }, + include: { + required: false, + model: WorkspaceTeamUserModel, + where: { memberId: userUuid }, + }, + }); + return { + team: team ? this.toDomain(team) : null, + teamUser: team.teamUsers[0] + ? this.teamUserToDomain(team.teamUsers[0]) + : null, + }; + } + async getTeamById( teamId: WorkspaceTeamAttributes['id'], ): Promise { @@ -85,4 +107,10 @@ export class SequelizeWorkspaceTeamRepository { ...model.toJSON(), }); } + + teamUserToDomain(model: WorkspaceTeamUserModel): WorkspaceTeamUser { + return WorkspaceTeamUser.build({ + ...model.toJSON(), + }); + } } diff --git a/src/modules/workspaces/repositories/workspaces.repository.ts b/src/modules/workspaces/repositories/workspaces.repository.ts index a177438cf..07e5e34a6 100644 --- a/src/modules/workspaces/repositories/workspaces.repository.ts +++ b/src/modules/workspaces/repositories/workspaces.repository.ts @@ -4,9 +4,11 @@ import { FindOrCreateOptions, Transaction } from 'sequelize/types'; import { WorkspaceAttributes } from '../attributes/workspace.attributes'; import { Workspace } from '../domains/workspaces.domain'; import { WorkspaceModel } from '../models/workspace.model'; +import { WorkspaceUserModel } from '../models/workspace-users.model'; +import { WorkspaceUser } from '../domains/workspace-user.domain'; export interface WorkspaceRepository { - findById(id: number): Promise; + findById(id: WorkspaceAttributes['id']): Promise; findByOwner(ownerId: Workspace['ownerId']): Promise; createTransaction(): Promise; findOrCreate(opts: FindOrCreateOptions): Promise<[Workspace | null, boolean]>; @@ -35,8 +37,10 @@ export class SequelizeWorkspaceRepository implements WorkspaceRepository { constructor( @InjectModel(WorkspaceModel) private modelWorkspace: typeof WorkspaceModel, + @InjectModel(WorkspaceUserModel) + private modelWorkspaceUser: typeof WorkspaceUserModel, ) {} - async findById(id: number): Promise { + async findById(id: WorkspaceAttributes['id']): Promise { const workspace = await this.modelWorkspace.findByPk(id); return workspace ? this.toDomain(workspace) : null; } @@ -75,6 +79,29 @@ export class SequelizeWorkspaceRepository implements WorkspaceRepository { return workspaces.map((workspace) => this.toDomain(workspace)); } + async findWorkspaceAndUser( + userUuid: string, + workspaceId: string, + ): Promise<{ workspace: Workspace; workspaceUser: WorkspaceUser } | null> { + const workspace = await this.modelWorkspace.findOne({ + where: { id: workspaceId }, + include: { + required: false, + model: WorkspaceUserModel, + where: { + memberId: userUuid, + }, + }, + }); + + return { + workspace: workspace ? this.toDomain(workspace) : null, + workspaceUser: workspace?.workspaceUsers + ? this.workspaceUserToDomain(workspace.workspaceUsers[0]) + : null, + }; + } + async findAllByWithPagination( where: any, limit = 20, @@ -110,8 +137,14 @@ export class SequelizeWorkspaceRepository implements WorkspaceRepository { }); } + workspaceUserToDomain(model: WorkspaceUserModel): WorkspaceUser { + return WorkspaceUser.build({ + ...model?.toJSON(), + }); + } + toModel(domain: Workspace): Partial { - return domain.toJSON(); + return domain?.toJSON(); } } export { WorkspaceModel }; diff --git a/src/modules/workspaces/workspaces.controller.ts b/src/modules/workspaces/workspaces.controller.ts index f17d94f5d..008cad5d9 100644 --- a/src/modules/workspaces/workspaces.controller.ts +++ b/src/modules/workspaces/workspaces.controller.ts @@ -7,6 +7,7 @@ import { Param, Patch, Post, + UseGuards, } from '@nestjs/common'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { WorkspacesUsecases } from './workspaces.usecase'; @@ -15,6 +16,12 @@ import { WorkspaceAttributes } from './attributes/workspace.attributes'; import { User as UserDecorator } from '../auth/decorators/user.decorator'; import { User } from '../user/user.domain'; import { isUUID } from 'class-validator'; +import { WorkspaceGuard } from './guards/workspaces.guard'; +import { + AccessContext, + WorkspaceRequiredAcess, + WorkspaceRole, +} from './guards/workspace-required-access.decorator'; @ApiTags('Workspaces') @Controller('workspaces') @@ -22,6 +29,8 @@ export class WorkspacesController { constructor(private workspaceUseCases: WorkspacesUsecases) {} @Patch('/:workspaceId') + @UseGuards(WorkspaceGuard) + @WorkspaceRequiredAcess(AccessContext.WORKSPACE, WorkspaceRole.OWNER) async setupWorkspace( @Param('workspaceId') workspaceId: WorkspaceAttributes['id'], ) { @@ -35,6 +44,8 @@ export class WorkspacesController { @ApiOkResponse({ description: 'Created team', }) + @UseGuards(WorkspaceGuard) + @WorkspaceRequiredAcess(AccessContext.WORKSPACE, WorkspaceRole.OWNER) async createTeam( @Param('workspaceId') workspaceId: WorkspaceAttributes['id'], @Body() createTeamBody: CreateTeamDto, @@ -65,7 +76,9 @@ export class WorkspacesController { return this.workspaceUseCases.getWorkspaceTeams(user, workspaceId); } - @Get('/:workspaceId/teams/:teamId/members') + @Get('/teams/:teamId/members') + @UseGuards(WorkspaceGuard) + @WorkspaceRequiredAcess(AccessContext.TEAM, WorkspaceRole.MEMBER) async getTeamMembers() { throw new NotImplementedException(); } diff --git a/src/modules/workspaces/workspaces.module.ts b/src/modules/workspaces/workspaces.module.ts index 81840128f..0c4de72ab 100644 --- a/src/modules/workspaces/workspaces.module.ts +++ b/src/modules/workspaces/workspaces.module.ts @@ -11,6 +11,7 @@ import { SequelizeWorkspaceTeamRepository } from './repositories/team.repository import { BridgeModule } from '../../externals/bridge/bridge.module'; import { WorkspaceTeamModel } from './models/workspace-team.model'; import { WorkspaceTeamUserModel } from './models/workspace-team-users.model'; +import { WorkspaceGuard } from './guards/workspaces.guard'; @Module({ imports: [ @@ -30,6 +31,7 @@ import { WorkspaceTeamUserModel } from './models/workspace-team-users.model'; WorkspacesUsecases, SequelizeWorkspaceTeamRepository, SequelizeWorkspaceRepository, + WorkspaceGuard, ], exports: [WorkspacesUsecases, SequelizeModule], }) diff --git a/src/modules/workspaces/workspaces.usecase.ts b/src/modules/workspaces/workspaces.usecase.ts index 79f49fe34..897868f76 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -129,4 +129,16 @@ export class WorkspacesUsecases { return teamsWithMemberCount; } + + async findUserInWorkspace(userUuid: string, workspaceId: string) { + return this.workspaceRepository.findWorkspaceAndUser(userUuid, workspaceId); + } + + async findById(workspaceId: string) { + return this.workspaceRepository.findById(workspaceId); + } + + async findUserInTeam(userUuid: string, teamId: string) { + return this.teamRepository.getTeamUserAndTeamByTeamId(userUuid, teamId); + } }