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..89f11498c 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(user: User) { + return user.uuid === 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..0dae51992 100644 --- a/src/modules/workspaces/domains/workspaces.domain.ts +++ b/src/modules/workspaces/domains/workspaces.domain.ts @@ -1,3 +1,4 @@ +import { User } from '../../user/user.domain'; 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(user: User) { + return user.uuid === 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..28e090e6b --- /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 WorkspaceRequiredAccess = ( + accessContext: AccessContext, + requiredRole: WorkspaceRole, + idSource: 'params' | 'body' | 'query' = 'params', +) => SetMetadata('accessControl', { accessContext, requiredRole, idSource }); diff --git a/src/modules/workspaces/guards/workspaces.guard.spec.ts b/src/modules/workspaces/guards/workspaces.guard.spec.ts new file mode 100644 index 000000000..bd7cb007e --- /dev/null +++ b/src/modules/workspaces/guards/workspaces.guard.spec.ts @@ -0,0 +1,317 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { + ExecutionContext, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { WorkspaceGuard } from './workspaces.guard'; +import { WorkspacesUsecases } from '../workspaces.usecase'; +import { + AccessContext, + WorkspaceRole, +} from './workspace-required-access.decorator'; +import { + newUser, + newWorkspace, + newWorkspaceTeam, + newWorkspaceUser, +} from '../../../../test/fixtures'; +import { WorkspaceUser } from '../domains/workspace-user.domain'; +import { WorkspaceTeamUser } from '../domains/workspace-team-user.domain'; + +describe('WorkspaceGuard', () => { + let guard: WorkspaceGuard; + let reflector: DeepMocked; + let workspaceUseCases: DeepMocked; + + beforeEach(async () => { + reflector = createMock(); + workspaceUseCases = createMock(); + guard = new WorkspaceGuard(reflector, workspaceUseCases); + }); + + it('Guard should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('When there is no metadata set, then bypass guard', async () => { + jest.spyOn(reflector, 'get').mockReturnValue(undefined); + + const context = createMockExecutionContext(null, null); + + const canUserAccess = await guard.canActivate(context); + + expect(canUserAccess).toBeTruthy(); + }); + + it('When there is no user set, then block access', async () => { + jest.spyOn(reflector, 'get').mockReturnValue({ + requiredRole: WorkspaceRole.OWNER, + accessContext: AccessContext.WORKSPACE, + idSource: 'params', + }); + const context = createMockExecutionContext(null, null); + + const canUserAccess = await guard.canActivate(context); + + expect(canUserAccess).toBeFalsy(); + }); + + describe('Workspace Permissions', () => { + it('When workspace is missing, then throw ', async () => { + const user = newUser(); + + jest.spyOn(reflector, 'get').mockReturnValue({ + requiredRole: WorkspaceRole.MEMBER, + accessContext: AccessContext.WORKSPACE, + idSource: 'params', + }); + + workspaceUseCases.findUserInWorkspace.mockResolvedValue({ + workspace: null, + workspaceUser: null, + }); + + const context = createMockExecutionContext(user, { + params: { workspaceId: 'nonexistent-workspace-id' }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + NotFoundException, + ); + }); + + it('When user is owner of the workspace and required role is owner, then grant access', async () => { + const workspaceOwner = newUser(); + const workspace = newWorkspace({ owner: workspaceOwner }); + + jest.spyOn(reflector, 'get').mockReturnValue({ + requiredRole: WorkspaceRole.OWNER, + accessContext: AccessContext.WORKSPACE, + idSource: 'params', + }); + + workspaceUseCases.findUserInWorkspace.mockResolvedValue({ + workspace, + workspaceUser: {} as WorkspaceUser, + }); + + const context = createMockExecutionContext(workspaceOwner, { + params: { workspaceId: workspace.id }, + }); + + await expect(guard.canActivate(context)).resolves.toBeTruthy(); + }); + + it('When user is not owner and required role is owner, then deny access', async () => { + const nonOwnerUser = newUser(); + const workspace = newWorkspace(); + + jest.spyOn(reflector, 'get').mockReturnValue({ + requiredRole: WorkspaceRole.OWNER, + accessContext: AccessContext.WORKSPACE, + idSource: 'params', + }); + workspaceUseCases.findUserInWorkspace.mockResolvedValue({ + workspace, + workspaceUser: {} as WorkspaceUser, + }); + const context = createMockExecutionContext(nonOwnerUser, { + params: { workspaceId: workspace.id }, + }); + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('When user is member and required role is member, then grant access', async () => { + const workspaceMember = newWorkspaceUser(); + const workspace = newWorkspace(); + + jest.spyOn(reflector, 'get').mockReturnValue({ + requiredRole: WorkspaceRole.MEMBER, + accessContext: AccessContext.WORKSPACE, + idSource: 'params', + }); + workspaceUseCases.findUserInWorkspace.mockResolvedValue({ + workspace, + workspaceUser: workspaceMember, + }); + const context = createMockExecutionContext(workspaceMember, { + params: { workspaceId: workspace.id }, + }); + const grantAccess = await guard.canActivate(context); + + expect(grantAccess).toBeTruthy(); + }); + + it('When user is not member and required role is member, then deny access', async () => { + const notMember = newUser(); + const workspace = newWorkspace(); + + jest.spyOn(reflector, 'get').mockReturnValue({ + requiredRole: WorkspaceRole.MEMBER, + accessContext: AccessContext.WORKSPACE, + idSource: 'params', + }); + workspaceUseCases.findUserInWorkspace.mockResolvedValue({ + workspace, + workspaceUser: null, + }); + const context = createMockExecutionContext(notMember, { + params: { workspaceId: workspace.id }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException, + ); + }); + }); + + describe('Team Permissions', () => { + it('When Team is not valid, then throw', async () => { + const user = newUser(); + jest.spyOn(reflector, 'get').mockReturnValue({ + requiredRole: WorkspaceRole.MANAGER, + accessContext: AccessContext.TEAM, + idSource: 'params', + }); + + workspaceUseCases.findUserInTeam.mockResolvedValue({ + team: null, + teamUser: null, + }); + + const context = createMockExecutionContext(user, { + params: { teamId: 'anyid' }, + query: {}, + body: {}, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + NotFoundException, + ); + }); + + it('When user is team member, then grant access', async () => { + const user = newUser(); + const workspace = newWorkspace({ owner: user }); + const team = newWorkspaceTeam({ + workspaceId: workspace.id, + manager: user, + }); + + jest.spyOn(reflector, 'get').mockReturnValue({ + requiredRole: WorkspaceRole.MEMBER, + accessContext: AccessContext.TEAM, + idSource: 'params', + }); + + workspaceUseCases.findUserInTeam.mockResolvedValue({ + team, + teamUser: {} as WorkspaceTeamUser, + }); + + const context = createMockExecutionContext(user, { + params: { teamId: team.id }, + }); + + await expect(guard.canActivate(context)).resolves.toBeTruthy(); + }); + + it('When user is not manager and required role is manager, then deny access', async () => { + const manager = newUser(); + const member = newUser(); + const team = newWorkspaceTeam({ manager }); + const workspace = newWorkspace(); + + jest.spyOn(reflector, 'get').mockReturnValue({ + requiredRole: WorkspaceRole.MANAGER, + accessContext: AccessContext.TEAM, + idSource: 'params', + }); + + workspaceUseCases.findUserInTeam.mockResolvedValue({ + team, + teamUser: {} as WorkspaceTeamUser, + }); + + workspaceUseCases.findById.mockResolvedValue(workspace); + + const context = createMockExecutionContext(member, { + params: { teamId: team.id }, + }); + const grantAccess = await guard.canActivate(context); + + expect(grantAccess).toBeFalsy(); + }); + + it('When user is not part of team, then deny access', async () => { + const nonMemberUser = newUser(); + const team = newWorkspaceTeam(); + const workspace = newWorkspace(); + + jest.spyOn(reflector, 'get').mockReturnValue({ + requiredRole: WorkspaceRole.MEMBER, + accessContext: AccessContext.TEAM, + idSource: 'params', + }); + + workspaceUseCases.findById.mockResolvedValue(workspace); + workspaceUseCases.findUserInTeam.mockResolvedValue({ + team, + teamUser: null, + }); + + const context = createMockExecutionContext(nonMemberUser, { + params: { teamId: team.id }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('When team workspace does not exist, then throw', async () => { + const nonMemberUser = newUser(); + const team = newWorkspaceTeam(); + + jest.spyOn(reflector, 'get').mockReturnValue({ + requiredRole: WorkspaceRole.MEMBER, + accessContext: AccessContext.TEAM, + idSource: 'params', + }); + + workspaceUseCases.findById.mockResolvedValue(null); + workspaceUseCases.findUserInTeam.mockResolvedValue({ + team, + teamUser: null, + }); + + const context = createMockExecutionContext(nonMemberUser, { + params: { teamId: team.id }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); + +const createMockExecutionContext = ( + user: any, + requestPayload: any, +): ExecutionContext => + ({ + getHandler: () => ({ + name: 'endPointHandler', + }), + switchToHttp: () => ({ + getRequest: () => ({ + user: user, + ...requestPayload, + }), + }), + }) as unknown as ExecutionContext; diff --git a/src/modules/workspaces/guards/workspaces.guard.ts b/src/modules/workspaces/guards/workspaces.guard.ts new file mode 100644 index 000000000..3ae922139 --- /dev/null +++ b/src/modules/workspaces/guards/workspaces.guard.ts @@ -0,0 +1,129 @@ +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'; +import { User } from '../../user/user.domain'; + +@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(); + + if (!request.user) { + return false; + } + + const user = User.build({ ...request.user }); + + const id = this.getIdFromRequest( + request, + idSource, + WorkspaceContextIdFieldName[accessContext], + ); + + if (accessContext === AccessContext.WORKSPACE) { + return this.verifyWorkspaceAccessByRole(user, id, requiredRole); + } else if (accessContext === AccessContext.TEAM) { + return this.verifyTeamAccessByRole(user, id, requiredRole); + } + + return false; + } + + private async verifyWorkspaceAccessByRole( + user: User, + workspaceId: string, + role: WorkspaceRole, + ) { + const { workspace, workspaceUser } = + await this.workspaceUseCases.findUserInWorkspace(user.uuid, workspaceId); + + if (!workspace) { + throw new NotFoundException('Workspace not found'); + } + + const isUserNotInWorkspace = !workspaceUser; + const isRequiredOwnerRoleAndUserIsNotOwner = + role === WorkspaceRole.OWNER && !workspace.isUserOwner(user); + + if (isUserNotInWorkspace || isRequiredOwnerRoleAndUserIsNotOwner) { + Logger.log( + `[WORKSPACES/GUARD]: user has no requiered access to workspace. id: ${workspaceId} userUuid: ${user.uuid} `, + ); + throw new ForbiddenException('You have no access to this workspace'); + } + + return true; + } + + private async verifyTeamAccessByRole( + user: User, + teamId: string, + role: WorkspaceRole, + ) { + const { team, teamUser } = await this.workspaceUseCases.findUserInTeam( + user.uuid, + teamId, + ); + + if (!team) { + throw new NotFoundException('Team not found'); + } + + const workspace = await this.workspaceUseCases.findById(team.workspaceId); + + if (!workspace) { + throw new NotFoundException('Workspace not found'); + } + + if (workspace.isUserOwner(user)) { + return true; + } + + if (teamUser && role === WorkspaceRole.MANAGER) { + return team.isUserManager(user); + } + + 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..747fd77ad 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,30 @@ export class SequelizeWorkspaceTeamRepository { return result.map((teamUser) => User.build({ ...teamUser.member })); } + async getTeamUserAndTeamByTeamId( + userUuid: UserAttributes['uuid'], + teamId: WorkspaceTeamAttributes['id'], + ): Promise<{ + team: WorkspaceTeam | null; + teamUser: WorkspaceTeamUser | null; + }> { + 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 +111,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..5c4def2e5 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,30 @@ export class SequelizeWorkspaceRepository implements WorkspaceRepository { return workspaces.map((workspace) => this.toDomain(workspace)); } + async findWorkspaceAndUser( + userUuid: string, + workspaceId: string, + ): Promise<{ + workspace: Workspace | null; + workspaceUser: WorkspaceUser | null; + }> { + const workspace = await this.modelWorkspace.findOne({ + where: { id: workspaceId }, + include: { + model: WorkspaceUserModel, + where: { memberId: userUuid }, + required: false, + }, + }); + + return { + workspace: workspace ? this.toDomain(workspace) : null, + workspaceUser: workspace?.workspaceUsers?.[0] + ? this.workspaceUserToDomain(workspace.workspaceUsers[0]) + : null, + }; + } + async findAllByWithPagination( where: any, limit = 20, @@ -110,8 +138,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..1bec64a29 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, + WorkspaceRequiredAccess, + 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) + @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.OWNER) async setupWorkspace( @Param('workspaceId') workspaceId: WorkspaceAttributes['id'], ) { @@ -35,6 +44,8 @@ export class WorkspacesController { @ApiOkResponse({ description: 'Created team', }) + @UseGuards(WorkspaceGuard) + @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.OWNER) async createTeam( @Param('workspaceId') workspaceId: WorkspaceAttributes['id'], @Body() createTeamBody: CreateTeamDto, @@ -66,6 +77,8 @@ export class WorkspacesController { } @Get('/:workspaceId/teams/:teamId/members') + @UseGuards(WorkspaceGuard) + @WorkspaceRequiredAccess(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..5c6571abc 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -11,6 +11,8 @@ import { BridgeService } from '../../externals/bridge/bridge.service'; import { SequelizeUserRepository } from '../user/user.repository'; import { UserAttributes } from '../user/user.attributes'; import { WorkspaceTeam } from './domains/workspace-team.domain'; +import { WorkspaceUser } from './domains/workspace-user.domain'; +import { WorkspaceTeamUser } from './domains/workspace-team-user.domain'; @Injectable() export class WorkspacesUsecases { @@ -129,4 +131,28 @@ export class WorkspacesUsecases { return teamsWithMemberCount; } + + findUserInWorkspace( + userUuid: string, + workspaceId: string, + ): Promise<{ + workspace: Workspace | null; + workspaceUser: WorkspaceUser | null; + }> { + return this.workspaceRepository.findWorkspaceAndUser(userUuid, workspaceId); + } + + findById(workspaceId: string): Promise { + return this.workspaceRepository.findById(workspaceId); + } + + findUserInTeam( + userUuid: string, + teamId: string, + ): Promise<{ + team: WorkspaceTeam | null; + teamUser: WorkspaceTeamUser | null; + }> { + return this.teamRepository.getTeamUserAndTeamByTeamId(userUuid, teamId); + } } diff --git a/test/fixtures.spec.ts b/test/fixtures.spec.ts index 7468946ba..49a203ea7 100644 --- a/test/fixtures.spec.ts +++ b/test/fixtures.spec.ts @@ -267,4 +267,126 @@ describe('Testing fixtures tests', () => { expect(limit.value).toEqual('0'); }); }); + + describe("Workspace's fixture", () => { + it('When it generates a workspace, then the identifier should be random', () => { + const workspace = fixtures.newWorkspace(); + const otherWorkspace = fixtures.newWorkspace(); + + expect(workspace.id).toBeTruthy(); + expect(workspace.id).not.toBe(otherWorkspace.id); + }); + + it('When it generates a workspace, then the ownerId should be random', () => { + const workspace = fixtures.newWorkspace(); + const otherWorkspace = fixtures.newWorkspace(); + + expect(workspace.ownerId).toBeTruthy(); + expect(workspace.ownerId).not.toBe(otherWorkspace.ownerId); + }); + + it('When it generates a workspace with an owner, then the ownerId should match the owner', () => { + const owner = fixtures.newUser(); + const workspace = fixtures.newWorkspace({ owner }); + + expect(workspace.ownerId).toBe(owner.uuid); + }); + + it('When it generates a workspace, then the createdAt should be equal or less than updatedAt', () => { + const workspace = fixtures.newWorkspace(); + + expect(workspace.createdAt.getTime()).toBeLessThanOrEqual( + workspace.updatedAt.getTime(), + ); + }); + + it('When it generates a workspace, then the setupCompleted should be a boolean value', () => { + const workspace = fixtures.newWorkspace(); + + expect(typeof workspace.setupCompleted).toBe('boolean'); + }); + }); + + describe("WorkspaceTeam's fixture", () => { + it('When it generates a workspace team, then the identifier should be random', () => { + const team = fixtures.newWorkspaceTeam(); + const otherTeam = fixtures.newWorkspaceTeam(); + + expect(team.id).toBeTruthy(); + expect(team.id).not.toBe(otherTeam.id); + }); + + it('When it generates a workspace team, then the workspaceId should be random', () => { + const team = fixtures.newWorkspaceTeam(); + const otherTeam = fixtures.newWorkspaceTeam(); + + expect(team.workspaceId).toBeTruthy(); + expect(team.workspaceId).not.toBe(otherTeam.workspaceId); + }); + + it('When it generates a workspace team with a manager, then the managerId should match the manager', () => { + const manager = fixtures.newUser(); + const team = fixtures.newWorkspaceTeam({ manager }); + + expect(team.managerId).toBe(manager.uuid); + }); + + it('When it generates a workspace team, then the createdAt should be equal or less than updatedAt', () => { + const team = fixtures.newWorkspaceTeam(); + + expect(team.createdAt.getTime()).toBeLessThanOrEqual( + team.updatedAt.getTime(), + ); + }); + + it('When it generates a workspace team, then the name should be populated', () => { + const team = fixtures.newWorkspaceTeam(); + + expect(team.name).toBeTruthy(); + expect(typeof team.name).toBe('string'); + }); + }); + + describe("WorkspaceUser's fixture", () => { + it('When it generates a workspace user, then the identifier should be random', () => { + const user = fixtures.newWorkspaceUser(); + const otherUser = fixtures.newWorkspaceUser(); + expect(user.id).toBeTruthy(); + expect(user.id).not.toBe(otherUser.id); + }); + + it('When it generates a workspace user, then the workspaceId should be random', () => { + const user = fixtures.newWorkspaceUser(); + const otherUser = fixtures.newWorkspaceUser(); + expect(user.workspaceId).toBeTruthy(); + expect(user.workspaceId).not.toBe(otherUser.workspaceId); + }); + + it('When it generates a workspace user with a specified memberId, then the memberId should match', () => { + const memberId = 'anyId'; + const user = fixtures.newWorkspaceUser({ memberId }); + expect(user.memberId).toBe(memberId); + }); + + it('When it generates a workspace user, then driveUsage and backupsUsage should not exceed spaceLimit', () => { + const user = fixtures.newWorkspaceUser(); + expect(Number(user.driveUsage)).toBeLessThanOrEqual( + BigInt(user.spaceLimit), + ); + expect(BigInt(user.backupsUsage)).toBeLessThanOrEqual( + BigInt(user.spaceLimit), + ); + }); + + it('When it generates a workspace user with custom attributes, then those attributes are set correctly', () => { + const customAttributes = { + deactivated: true, + spaceLimit: BigInt(500), + }; + const user = fixtures.newWorkspaceUser({ attributes: customAttributes }); + + expect(user.deactivated).toBe(customAttributes.deactivated); + expect(user.spaceLimit).toBe(customAttributes.spaceLimit); + }); + }); }); diff --git a/test/fixtures.ts b/test/fixtures.ts index 3046e7db7..af33d106c 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -16,6 +16,9 @@ import { LimitTypes, } from '../src/modules/feature-limit/limits.enum'; import { Limit } from '../src/modules/feature-limit/limit.domain'; +import { Workspace } from '../src/modules/workspaces/domains/workspaces.domain'; +import { WorkspaceTeam } from '../src/modules/workspaces/domains/workspace-team.domain'; +import { WorkspaceUser } from '../src/modules/workspaces/domains/workspace-user.domain'; export const constants = { BUCKET_ID_LENGTH: 24, @@ -245,3 +248,96 @@ export const newFeatureLimit = (bindTo?: { label: bindTo?.label ?? ('' as LimitLabels), }); }; + +export const newWorkspace = (params?: { + attributes?: Partial; + owner?: User; +}): Workspace => { + const randomCreatedAt = randomDataGenerator.date(); + + const workspace = Workspace.build({ + id: v4(), + ownerId: params?.owner?.uuid || v4(), + address: randomDataGenerator.address(), + name: randomDataGenerator.company(), + description: randomDataGenerator.sentence(), + defaultTeamId: v4(), + workspaceUserId: v4(), + setupCompleted: randomDataGenerator.bool(), + createdAt: randomCreatedAt, + updatedAt: new Date( + randomDataGenerator.date({ + min: randomCreatedAt, + }), + ), + }); + + params?.attributes && + Object.keys(params.attributes).forEach((key) => { + workspace[key] = params.attributes[key]; + }); + + return workspace; +}; + +export const newWorkspaceTeam = (params?: { + attributes?: Partial; + workspaceId?: string; + manager?: User; +}): WorkspaceTeam => { + const randomCreatedAt = randomDataGenerator.date(); + const manager = params?.manager || newUser(); + + const team = WorkspaceTeam.build({ + id: v4(), + workspaceId: params?.workspaceId || v4(), + managerId: manager.uuid, + name: randomDataGenerator.word(), + createdAt: randomCreatedAt, + updatedAt: new Date( + randomDataGenerator.date({ + min: randomCreatedAt, + }), + ), + }); + + params?.attributes && + Object.keys(params.attributes).forEach((key) => { + team[key] = params.attributes[key]; + }); + + return team; +}; + +export const newWorkspaceUser = (params?: { + workspaceId?: string; + memberId?: string; + attributes?: Partial; +}): WorkspaceUser => { + const randomCreatedAt = randomDataGenerator.date(); + const spaceLimit = randomDataGenerator.natural({ min: 1, max: 1073741824 }); + + const workspaceUser = WorkspaceUser.build({ + id: v4(), + memberId: params?.memberId || v4(), + key: randomDataGenerator.string({ length: 32 }), + workspaceId: params?.workspaceId || v4(), + spaceLimit: BigInt(spaceLimit), + driveUsage: BigInt( + randomDataGenerator.natural({ min: 1, max: spaceLimit }), + ), + backupsUsage: BigInt( + randomDataGenerator.natural({ min: 1, max: spaceLimit }), + ), + deactivated: randomDataGenerator.bool(), + createdAt: randomCreatedAt, + updatedAt: new Date(randomDataGenerator.date({ min: randomCreatedAt })), + }); + + params?.attributes && + Object.keys(params.attributes).forEach((key) => { + workspaceUser[key] = params.attributes[key]; + }); + + return workspaceUser; +};