diff --git a/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts b/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts index d233b98459..4bfb49bbbe 100644 --- a/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts +++ b/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts @@ -46,7 +46,7 @@ export class Migration20241113100535 extends Migration { ); if (teacherRoleUpdate.modifiedCount > 0) { - console.info('Rollback: Permission ROOM_CREATE added to role teacher.'); + console.info('Rollback: Permission ROOM_CREATE removed from role teacher.'); } const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( @@ -61,7 +61,7 @@ export class Migration20241113100535 extends Migration { ); if (roomEditorRoleUpdate.modifiedCount > 0) { - console.info('Rollback: Permission ROOM_DELETE added to role roomeditor.'); + console.info('Rollback: Permission ROOM_DELETE removed from role roomeditor.'); } } } diff --git a/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts b/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts new file mode 100644 index 0000000000..ffa54bbc77 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241209165812 extends Migration { + async up(): Promise { + // Add ROOM_OWNER role + await this.getCollection('roles').insertOne({ + name: 'roomowner', + permissions: [ + 'ROOM_VIEW', + 'ROOM_EDIT', + 'ROOM_DELETE', + 'ROOM_MEMBERS_ADD', + 'ROOM_MEMBERS_REMOVE', + 'ROOM_CHANGE_OWNER', + ], + }); + console.info( + 'Added ROOM_OWNER role with ROOM_VIEW, -_EDIT, _DELETE, -_MEMBERS_ADD, -_MEMBERS_REMOVE AND -_CHANGE_OWNER permission' + ); + + // Add ROOM_ADMIN role + await this.getCollection('roles').insertOne({ + name: 'roomadmin', + permissions: ['ROOM_VIEW', 'ROOM_EDIT', 'ROOM_MEMBERS_ADD', 'ROOM_MEMBERS_REMOVE'], + }); + console.info( + 'Added ROOM_ADMIN role with ROOM_VIEW, ROOM_EDIT, ROOM_MEMBERS_ADD AND ROOM_MEMBERS_REMOVE permissions' + ); + } + + async down(): Promise { + // Remove ROOM_OWNER role + await this.getCollection('roles').deleteOne({ name: 'roomowner' }); + console.info('Rollback: Removed ROOM_OWNER role'); + + // Remove ROOM_ADMIN role + await this.getCollection('roles').deleteOne({ name: 'roomadmin' }); + console.info('Rollback: Removed ROOM_ADMIN role'); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts b/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts new file mode 100644 index 0000000000..4bd331b505 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts @@ -0,0 +1,35 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241210152600 extends Migration { + async up(): Promise { + const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'roomeditor' }, + { + $set: { + permissions: ['ROOM_VIEW', 'ROOM_EDIT'], + }, + } + ); + + if (roomEditorRoleUpdate.modifiedCount > 0) { + console.info('Permission ROOM_DELETE removed from role roomeditor.'); + } + } + + async down(): Promise { + const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'roomeditor' }, + { + $set: { + permissions: ['ROOM_VIEW', 'ROOM_EDIT', 'ROOM_DELETE'], + }, + } + ); + + if (roomEditorRoleUpdate.modifiedCount > 0) { + console.info( + 'Rollback: Permissions ROOM_DELETE added to and ROOM_MEMBERS_ADD and ROOM_MEMBERS_REMOVE removed from role roomeditor.' + ); + } + } +} diff --git a/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts b/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts index 0326bb2d02..24384cd6b2 100644 --- a/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts +++ b/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts @@ -116,6 +116,17 @@ describe(RoomMembershipRule.name, () => { expect(res).toBe(false); }); + + it('should return false for change owner action', () => { + const { user, roomMembershipAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMembershipAuthorizable, { + action: Action.read, + requiredPermissions: [Permission.ROOM_CHANGE_OWNER], + }); + + expect(res).toBe(false); + }); }); describe('when user is not member of room', () => { diff --git a/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts b/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts index 3336e93892..544a8bdfac 100644 --- a/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts +++ b/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts @@ -10,18 +10,18 @@ export class RoomMembershipRule implements Rule { this.authorisationInjectionService.injectAuthorizationRule(this); } - public isApplicable(user: User, object: unknown): boolean { + public isApplicable(_: User, object: unknown): boolean { const isMatched = object instanceof RoomMembershipAuthorizable; return isMatched; } public hasPermission(user: User, object: RoomMembershipAuthorizable, context: AuthorizationContext): boolean { - const primarySchoolId = user.school.id; - const secondarySchools = user.secondarySchools ?? []; - const secondarySchoolIds = secondarySchools.map(({ school }) => school.id); + if (!this.hasAccessToSchool(user, object.schoolId)) { + return false; + } - if (![primarySchoolId, ...secondarySchoolIds].includes(object.schoolId)) { + if (!this.hasRequiredRoomPermissions(user, object, context.requiredPermissions)) { return false; } @@ -36,4 +36,30 @@ export class RoomMembershipRule implements Rule { } return permissionsThisUserHas.includes(Permission.ROOM_EDIT); } + + private hasAccessToSchool(user: User, schoolId: string): boolean { + const primarySchoolId = user.school.id; + const secondarySchools = user.secondarySchools ?? []; + const secondarySchoolIds = secondarySchools.map(({ school }) => school.id); + + return [primarySchoolId, ...secondarySchoolIds].includes(schoolId); + } + + private hasRequiredRoomPermissions( + user: User, + object: RoomMembershipAuthorizable, + requiredPermissions: string[] + ): boolean { + const roomPermissionsOfUser = this.resolveRoomPermissions(user, object); + const missingPermissions = requiredPermissions.filter((permission) => !roomPermissionsOfUser.includes(permission)); + return missingPermissions.length === 0; + } + + private resolveRoomPermissions(user: User, object: RoomMembershipAuthorizable): string[] { + const member = object.members.find((m) => m.userId === user.id); + if (!member) { + return []; + } + return member.roles.flatMap((role) => role.permissions ?? []); + } } diff --git a/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts index 98763e3335..c6249661dc 100644 --- a/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts +++ b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts @@ -87,26 +87,6 @@ describe('RoomMembershipService', () => { }; }; - it('should create new roomMembership when not exists', async () => { - const { user, room } = setup(); - - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); - - expect(roomMembershipRepo.save).toHaveBeenCalled(); - }); - - it('should save the schoolId of the room in the roomMembership', async () => { - const { user, room } = setup(); - - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); - - expect(roomMembershipRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ - schoolId: room.schoolId, - }) - ); - }); - describe('when no user is provided', () => { it('should throw an exception', async () => { const { room } = setup(); @@ -189,118 +169,148 @@ describe('RoomMembershipService', () => { }); describe('when roomMembership exists', () => { - const setup = () => { - const user = userFactory.buildWithId(); + const setupGroupAndRoom = (schoolId: string) => { const group = groupFactory.build({ type: GroupTypes.ROOM }); - const room = roomFactory.build(); - const roomMembership = roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const room = roomFactory.build({ schoolId }); + const roomMembership = roomMembershipFactory.build({ + roomId: room.id, + userGroupId: group.id, + schoolId, + }); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); groupService.findById.mockResolvedValue(group); - groupService.findGroups.mockResolvedValue({ total: 1, data: [group] }); + roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - return { - user, - room, - roomMembership, - group, - }; + return { group, room, roomMembership }; }; - it('should remove roomMembership', async () => { - const { user, room, group } = setup(); + const mockGroupsAtSchoolAfterRemoval = (groups: Group[]) => { + groupService.findGroups.mockResolvedValue({ total: groups.length, data: groups }); + }; - await service.removeMembersFromRoom(room.id, [user.id]); + const setupRoomRoles = () => { + const roomOwnerRole = roleFactory.buildWithId({ name: RoleName.ROOMOWNER }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + roleService.findByName.mockResolvedValue(roomOwnerRole); - expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [user.id]); - }); - }); + return { roomOwnerRole, roomEditorRole }; + }; - const setupUserWithSecondarySchool = () => { - const secondarySchool = schoolFactory.build(); - const otherSchool = schoolFactory.build(); - const role = roleFactory.buildWithId({ name: RoleName.TEACHER }); - const guestTeacher = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); - const externalUser = userDoFactory.buildWithId({ - roles: [role], - secondarySchools: [{ schoolId: secondarySchool.id, role: new RoleDto(guestTeacher) }], - }); + const setupUserWithSecondarySchool = () => { + const secondarySchool = schoolFactory.build(); + const otherSchool = schoolFactory.build(); + const role = roleFactory.buildWithId({ name: RoleName.TEACHER }); + const guestTeacher = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); + const externalUser = userDoFactory.buildWithId({ + roles: [role], + secondarySchools: [{ schoolId: secondarySchool.id, role: new RoleDto(guestTeacher) }], + }); + const externalUserId = externalUser.id as string; - return { secondarySchool, externalUser, otherSchool }; - }; + return { secondarySchool, externalUser, externalUserId, otherSchool }; + }; - const setupGroupAndRoom = (schoolId: string) => { - const group = groupFactory.build({ type: GroupTypes.ROOM }); - const room = roomFactory.build({ schoolId }); - const roomMembership = roomMembershipFactory.build({ - roomId: room.id, - userGroupId: group.id, - schoolId, - }); + describe('when removing user from a different school, with no further groups on host school', () => { + const setup = () => { + const { secondarySchool, externalUserId } = setupUserWithSecondarySchool(); + const { roomEditorRole } = setupRoomRoles(); - return { group, room, roomMembership }; - }; + const { room, group } = setupGroupAndRoom(secondarySchool.id); + group.addUser({ userId: externalUserId, roleId: roomEditorRole.id }); - const mockGroupsAtSchoolAfterRemoval = (groups: Group[]) => { - groupService.findGroups.mockResolvedValue({ total: groups.length, data: groups }); - }; + mockGroupsAtSchoolAfterRemoval([]); - it('should pass the schoolId of the room', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + return { secondarySchool, externalUserId, room, group }; + }; - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + it('should pass the schoolId of the room', async () => { + const { secondarySchool, externalUserId, room } = setup(); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + await service.removeMembersFromRoom(room.id, [externalUserId]); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([]); + expect(groupService.findGroups).toHaveBeenCalledWith( + expect.objectContaining({ schoolId: secondarySchool.id }) + ); + }); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + it('should remove user from room', async () => { + const { group, externalUserId, room } = setup(); - expect(groupService.findGroups).toHaveBeenCalledWith(expect.objectContaining({ schoolId: secondarySchool.id })); - }); + await service.removeMembersFromRoom(room.id, [externalUserId]); + + expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [externalUserId]); + }); - describe('when after removal: user is not in any room of that secondary school', () => { - it('should remove user from secondary school', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + it('should remove user from secondary school', async () => { + const { secondarySchool, externalUserId, room } = setup(); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + await service.removeMembersFromRoom(room.id, [externalUserId]); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([]); + expect(userService.removeSecondarySchoolFromUsers).toHaveBeenCalledWith([externalUserId], secondarySchool.id); + }); + }); + + describe('when removing user from a different school, with further groups on host school', () => { + const setup = () => { + const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + const { roomEditorRole } = setupRoomRoles(); + + const { room, group } = setupGroupAndRoom(secondarySchool.id); + group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + const { group: group2 } = setupGroupAndRoom(secondarySchool.id); + group2.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + + mockGroupsAtSchoolAfterRemoval([group2]); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + return { externalUser, room }; + }; + + it('should not remove user from secondary school', async () => { + const { externalUser, room } = setup(); + + await service.removeMembersFromRoom(room.id, [externalUser.id as string]); - expect(userService.removeSecondarySchoolFromUsers).toHaveBeenCalledWith([externalUser.id], secondarySchool.id); + expect(userService.removeSecondarySchoolFromUsers).not.toHaveBeenCalled(); + }); }); - }); - describe('when after removal: user is still in a room of that secondary school', () => { - it('should not remove user from secondary school', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + describe('when removing user from the same school', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const { roomEditorRole } = setupRoomRoles(); + const { room, group } = setupGroupAndRoom(user.school.id); + group.addUser({ userId: user.id, roleId: roomEditorRole.id }); - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + mockGroupsAtSchoolAfterRemoval([group]); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); - const { group: group2 } = setupGroupAndRoom(secondarySchool.id); - group2.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + return { user, room, group }; + }; - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([group2]); + it('should remove user from room', async () => { + const { user, group, room } = setup(); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + await service.removeMembersFromRoom(room.id, [user.id]); - expect(userService.removeSecondarySchoolFromUsers).not.toHaveBeenCalled(); + expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [user.id]); + }); + }); + + describe('when removing the owner of the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const { room, group } = setupGroupAndRoom(user.school.id); + const { roomOwnerRole } = setupRoomRoles(); + + group.addUser({ userId: user.id, roleId: roomOwnerRole.id }); + + return { user, room }; + }; + + it('should throw a badrequest exception', async () => { + const { user, room } = setup(); + + await expect(service.removeMembersFromRoom(room.id, [user.id])).rejects.toThrowError(BadRequestException); + }); }); }); }); diff --git a/apps/server/src/modules/room-membership/service/room-membership.service.ts b/apps/server/src/modules/room-membership/service/room-membership.service.ts index 8baa23dd64..fd978721d0 100644 --- a/apps/server/src/modules/room-membership/service/room-membership.service.ts +++ b/apps/server/src/modules/room-membership/service/room-membership.service.ts @@ -20,11 +20,7 @@ export class RoomMembershipService { private readonly userService: UserService ) {} - private async createNewRoomMembership( - roomId: EntityId, - userId: EntityId, - roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER - ): Promise { + public async createNewRoomMembership(roomId: EntityId, ownerUserId: EntityId): Promise { const room = await this.roomService.getSingleRoom(roomId); const group = await this.groupService.createGroup( @@ -32,7 +28,7 @@ export class RoomMembershipService { GroupTypes.ROOM, room.schoolId ); - await this.groupService.addUsersToGroup(group.id, [{ userId, roleName }]); + await this.groupService.addUsersToGroup(group.id, [{ userId: ownerUserId, roleName: RoleName.ROOMOWNER }]); const roomMembership = new RoomMembership({ id: new ObjectId().toHexString(), @@ -79,16 +75,14 @@ export class RoomMembershipService { public async addMembersToRoom( roomId: EntityId, - userIdsAndRoles: Array<{ userId: EntityId; roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER }> + userIdsAndRoles: Array<{ + userId: EntityId; + roleName: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; + }> ): Promise { const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId); if (roomMembership === null) { - const firstUser = userIdsAndRoles.shift(); - if (firstUser === undefined) { - throw new BadRequestException('No user provided'); - } - const newRoomMembership = await this.createNewRoomMembership(roomId, firstUser.userId, firstUser.roleName); - return newRoomMembership.id; + throw new Error('Room membership not found'); } await this.groupService.addUsersToGroup(roomMembership.userGroupId, userIdsAndRoles); @@ -106,6 +100,8 @@ export class RoomMembershipService { } const group = await this.groupService.findById(roomMembership.userGroupId); + + await this.ensureOwnerIsNotRemoved(group, userIds); await this.groupService.removeUsersFromGroup(group.id, userIds); await this.handleGuestRoleRemoval(userIds, roomMembership.schoolId); @@ -151,6 +147,17 @@ export class RoomMembershipService { return roomMembershipAuthorizable; } + private async ensureOwnerIsNotRemoved(group: Group, userIds: EntityId[]): Promise { + const role = await this.roleService.findByName(RoleName.ROOMOWNER); + const includedOwner = group.users + .filter((groupUser) => userIds.includes(groupUser.userId)) + .find((groupUser) => groupUser.roleId === role.id); + + if (includedOwner) { + throw new BadRequestException('Cannot remove owner from room'); + } + } + private async handleGuestRoleRemoval(userIds: EntityId[], schoolId: EntityId): Promise { const { data: groups } = await this.groupService.findGroups({ userIds, groupTypes: [GroupTypes.ROOM], schoolId }); diff --git a/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts b/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts index 93cb555646..9980d106fb 100644 --- a/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts +++ b/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsMongoId, IsString, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; -import { RoomRole, RoomRoleArray } from '@shared/domain/interface'; +import { RoleName, RoomRoleArray } from '@shared/domain/interface'; class UserIdAndRole { @ApiProperty({ @@ -17,7 +17,7 @@ class UserIdAndRole { enum: RoomRoleArray, }) @IsString() - roleName!: RoomRole; + roleName!: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; } export class AddRoomMembersBodyParams { diff --git a/apps/server/src/modules/room/api/room.uc.spec.ts b/apps/server/src/modules/room/api/room.uc.spec.ts index 8910130093..95cd6f7f6c 100644 --- a/apps/server/src/modules/room/api/room.uc.spec.ts +++ b/apps/server/src/modules/room/api/room.uc.spec.ts @@ -117,7 +117,7 @@ describe('RoomUc', () => { authorizationService.checkOneOfPermissions.mockReturnValue(undefined); const room = roomFactory.build(); roomService.createRoom.mockResolvedValue(room); - roomMembershipService.addMembersToRoom.mockRejectedValue(new Error('test')); + roomMembershipService.createNewRoomMembership.mockRejectedValue(new Error('test')); return { user, room }; }; diff --git a/apps/server/src/modules/room/api/room.uc.ts b/apps/server/src/modules/room/api/room.uc.ts index a80e2838c6..1de7fd11b5 100644 --- a/apps/server/src/modules/room/api/room.uc.ts +++ b/apps/server/src/modules/room/api/room.uc.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; import { Page, UserDO } from '@shared/domain/domainobject'; -import { IFindOptions, Permission, RoleName, RoomRole } from '@shared/domain/interface'; +import { IFindOptions, Permission, RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { BoardExternalReferenceType, ColumnBoard, ColumnBoardService } from '@modules/board'; import { Room, RoomService } from '../domain'; @@ -40,14 +40,13 @@ export class RoomUc { this.authorizationService.checkOneOfPermissions(user, [Permission.ROOM_CREATE]); - await this.roomMembershipService - .addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]) - .catch(async (err) => { - await this.roomService.deleteRoom(room); - throw err; - }); - - return room; + try { + await this.roomMembershipService.createNewRoomMembership(room.id, userId); + return room; + } catch (err) { + await this.roomService.deleteRoom(room); + throw err; + } } public async getSingleRoom(userId: EntityId, roomId: EntityId): Promise<{ room: Room; permissions: Permission[] }> { @@ -129,14 +128,17 @@ export class RoomUc { public async addMembersToRoom( currentUserId: EntityId, roomId: EntityId, - userIdsAndRoles: Array<{ userId: EntityId; roleName: RoomRole }> + userIdsAndRoles: Array<{ + userId: EntityId; + roleName: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; + }> ): Promise { this.checkFeatureEnabled(); - await this.checkRoomAuthorization(currentUserId, roomId, Action.write); + await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_ADD]); await this.roomMembershipService.addMembersToRoom(roomId, userIdsAndRoles); } - private mapToMember(member: UserWithRoomRoles, user: UserDO) { + private mapToMember(member: UserWithRoomRoles, user: UserDO): RoomMemberResponse { return new RoomMemberResponse({ userId: member.userId, firstName: user.firstName, @@ -148,7 +150,7 @@ export class RoomUc { public async removeMembersFromRoom(currentUserId: EntityId, roomId: EntityId, userIds: EntityId[]): Promise { this.checkFeatureEnabled(); - await this.checkRoomAuthorization(currentUserId, roomId, Action.write); + await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_REMOVE]); await this.roomMembershipService.removeMembersFromRoom(roomId, userIds); } diff --git a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts index ad8f5e6a3b..d4d5761ad5 100644 --- a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts @@ -54,6 +54,15 @@ describe('Room Controller (API)', () => { const teacherGuestRole = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); const studentGuestRole = roleFactory.buildWithId({ name: RoleName.GUESTSTUDENT }); const role = roleFactory.buildWithId({ + name: RoleName.ROOMADMIN, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], }); @@ -77,6 +86,7 @@ describe('Room Controller (API)', () => { teacherUser, teacherGuestRole, studentGuestRole, + roomEditorRole, otherTeacherUser, otherTeacherAccount, userGroupEntity, diff --git a/apps/server/src/modules/room/api/test/room-create.api.spec.ts b/apps/server/src/modules/room/api/test/room-create.api.spec.ts index eeca260725..47cecf68d3 100644 --- a/apps/server/src/modules/room/api/test/room-create.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-create.api.spec.ts @@ -69,10 +69,20 @@ describe('Room Controller (API)', () => { const setup = async () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const role = roleFactory.buildWithId({ - name: RoleName.ROOMEDITOR, - permissions: [Permission.ROOM_EDIT, Permission.ROOM_VIEW], + name: RoleName.TEACHER, + permissions: [Permission.ROOM_CREATE, Permission.ROOM_EDIT, Permission.ROOM_VIEW], }); - await em.persistAndFlush([teacherAccount, teacherUser, role]); + const roomOwnerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [ + Permission.ROOM_CREATE, + Permission.ROOM_EDIT, + Permission.ROOM_VIEW, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + await em.persistAndFlush([teacherAccount, teacherUser, role, roomOwnerRole]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); diff --git a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts index 4e8be194df..a088b76b87 100644 --- a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts @@ -96,32 +96,50 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { const setup = async () => { const room = roomEntityFactory.build(); - const role = roleFactory.buildWithId({ + const roomOwnerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [Permission.ROOM_EDIT, Permission.ROOM_DELETE], + }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); const school = schoolEntityFactory.buildWithId(); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const { teacherAccount: teacherOwnerAccount, teacherUser: teacherOwnerUser } = + UserAndAccountTestFactory.buildTeacher({ school }); + const { teacherAccount: teacherEditorAccount, teacherUser: teacherEditorUser } = + UserAndAccountTestFactory.buildTeacher({ school }); const userGroup = groupEntityFactory.buildWithId({ type: GroupEntityTypes.ROOM, - users: [{ role, user: teacherUser }], + users: [ + { role: roomOwnerRole, user: teacherOwnerUser }, + { role: roomEditorRole, user: teacherEditorUser }, + ], }); const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id, - schoolId: teacherUser.school.id, + schoolId: teacherOwnerUser.school.id, }); - await em.persistAndFlush([room, roomMembership, teacherAccount, teacherUser, userGroup, role]); + await em.persistAndFlush([ + room, + roomMembership, + teacherOwnerAccount, + teacherOwnerUser, + teacherEditorAccount, + teacherEditorUser, + userGroup, + roomOwnerRole, + ]); em.clear(); - const loggedInClient = await testApiClient.login(teacherAccount); - - return { loggedInClient, room }; + return { teacherOwnerAccount, teacherEditorAccount, room }; }; describe('when the room exists', () => { it('should delete the room', async () => { - const { loggedInClient, room } = await setup(); + const { teacherOwnerAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); const response = await loggedInClient.delete(room.id); expect(response.status).toBe(HttpStatus.NO_CONTENT); @@ -129,7 +147,8 @@ describe('Room Controller (API)', () => { }); it('should delete the roomMembership', async () => { - const { loggedInClient, room } = await setup(); + const { teacherOwnerAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); await expect(em.findOneOrFail(RoomMembershipEntity, { roomId: room.id })).resolves.not.toThrow(); @@ -137,11 +156,23 @@ describe('Room Controller (API)', () => { expect(response.status).toBe(HttpStatus.NO_CONTENT); await expect(em.findOneOrFail(RoomMembershipEntity, { roomId: room.id })).rejects.toThrow(NotFoundException); }); + + describe('when user is not the roomowner', () => { + it('should fail', async () => { + const { teacherEditorAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherEditorAccount); + + const response = await loggedInClient.delete(room.id); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); }); describe('when the room does not exist', () => { it('should return a 404 error', async () => { - const { loggedInClient } = await setup(); + const { teacherOwnerAccount } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); const someId = new ObjectId().toHexString(); const response = await loggedInClient.delete(someId); diff --git a/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts index 3810a9f4f3..f52dfc0bf2 100644 --- a/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts @@ -46,15 +46,30 @@ describe('Room Controller (API)', () => { describe('PATCH /rooms/:roomId/members/remove', () => { const setupRoomRoles = () => { - const editorRole = roleFactory.buildWithId({ - name: RoleName.ROOMEDITOR, - permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], + const ownerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_DELETE, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + const adminRole = roleFactory.buildWithId({ + name: RoleName.ROOMADMIN, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], }); const viewerRole = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); - return { editorRole, viewerRole }; + return { ownerRole, adminRole, viewerRole }; }; const setupRoomWithMembers = async () => { @@ -62,17 +77,17 @@ describe('Room Controller (API)', () => { const room = roomEntityFactory.buildWithId({ schoolId: school.id }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); - const { teacherUser: inRoomEditor2 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); - const { teacherUser: inRoomEditor3 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); + const { teacherUser: inRoomAdmin2 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); + const { teacherUser: inRoomAdmin3 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); const { teacherUser: inRoomViewer } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); const { teacherUser: outTeacher } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); - const users = { teacherUser, inRoomEditor2, inRoomEditor3, inRoomViewer, outTeacher }; + const users = { teacherUser, inRoomAdmin2, inRoomAdmin3, inRoomViewer, outTeacher }; - const { editorRole, viewerRole } = setupRoomRoles(); + const { ownerRole, adminRole, viewerRole } = setupRoomRoles(); - const roomUsers = [teacherUser, inRoomEditor2, inRoomEditor3].map((user) => { - return { role: editorRole, user }; + const roomUsers = [teacherUser, inRoomAdmin2, inRoomAdmin3].map((user) => { + return { role: adminRole, user }; }); roomUsers.push({ role: viewerRole, user: inRoomViewer }); @@ -89,7 +104,14 @@ describe('Room Controller (API)', () => { schoolId: school.id, }); - await em.persistAndFlush([...Object.values(users), room, roomMemberships, teacherAccount, userGroupEntity]); + await em.persistAndFlush([ + ...Object.values(users), + room, + roomMemberships, + teacherAccount, + userGroupEntity, + ownerRole, + ]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); @@ -137,9 +159,9 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { describe('when removing a user from the room', () => { it('should return OK', async () => { - const { loggedInClient, room, inRoomEditor2 } = await setupRoomWithMembers(); + const { loggedInClient, room, inRoomAdmin2 } = await setupRoomWithMembers(); - const userIds = [inRoomEditor2.id]; + const userIds = [inRoomAdmin2.id]; const response = await loggedInClient.patch(`/${room.id}/members/remove`, { userIds }); expect(response.status).toBe(HttpStatus.OK); @@ -148,9 +170,9 @@ describe('Room Controller (API)', () => { describe('when removing several users from the room', () => { it('should return OK', async () => { - const { loggedInClient, room, inRoomEditor2, inRoomEditor3 } = await setupRoomWithMembers(); + const { loggedInClient, room, inRoomAdmin2, inRoomAdmin3 } = await setupRoomWithMembers(); - const userIds = [inRoomEditor2.id, inRoomEditor3.id]; + const userIds = [inRoomAdmin2.id, inRoomAdmin3.id]; const response = await loggedInClient.patch(`/${room.id}/members/remove`, { userIds }); expect(response.status).toBe(HttpStatus.OK); diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index c5bed37ad1..bd1c2b0d25 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -104,6 +104,9 @@ export enum Permission { ROOM_EDIT = 'ROOM_EDIT', ROOM_VIEW = 'ROOM_VIEW', ROOM_DELETE = 'ROOM_DELETE', + ROOM_MEMBERS_ADD = 'ROOM_MEMBERS_ADD', + ROOM_MEMBERS_REMOVE = 'ROOM_MEMBERS_REMOVE', + ROOM_CHANGE_OWNER = 'ROOM_CHANGE_OWNER', SCHOOL_CHAT_MANAGE = 'SCHOOL_CHAT_MANAGE', SCHOOL_CREATE = 'SCHOOL_CREATE', SCHOOL_EDIT = 'SCHOOL_EDIT', diff --git a/apps/server/src/shared/domain/interface/rolename.enum.ts b/apps/server/src/shared/domain/interface/rolename.enum.ts index e354109efd..310f80cf84 100644 --- a/apps/server/src/shared/domain/interface/rolename.enum.ts +++ b/apps/server/src/shared/domain/interface/rolename.enum.ts @@ -13,6 +13,8 @@ export enum RoleName { HELPDESK = 'helpdesk', ROOMVIEWER = 'roomviewer', ROOMEDITOR = 'roomeditor', + ROOMADMIN = 'roomadmin', + ROOMOWNER = 'roomowner', STUDENT = 'student', SUPERHERO = 'superhero', TEACHER = 'teacher', @@ -32,7 +34,12 @@ export type IUserRoleName = | RoleName.DEMOSTUDENT | RoleName.DEMOTEACHER; -export const RoomRoleArray = [RoleName.ROOMEDITOR, RoleName.ROOMVIEWER] as const; +export const RoomRoleArray = [ + RoleName.ROOMOWNER, + RoleName.ROOMADMIN, + RoleName.ROOMEDITOR, + RoleName.ROOMVIEWER, +] as const; export type RoomRole = typeof RoomRoleArray[number]; export const GuestRoleArray = [RoleName.GUESTSTUDENT, RoleName.GUESTTEACHER] as const; diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index d99686e576..9babec5e26 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -278,6 +278,15 @@ "$date": "2024-11-13T10:13:12.411Z" } }, + { + "_id": { + "$oid": "673fca34cc4a3264457c8ad1" + }, + "name": "Migration20241120100616", + "created_at": { + "$date": "2024-11-20T17:03:31.473Z" + } + }, { "_id": { "$oid": "674444262ba8186272dc8abd" @@ -298,11 +307,20 @@ }, { "_id": { - "$oid": "673fca34cc4a3264457c8ad1" + "$oid": "675abdb4e76b1142cd4c89e5" }, - "name": "Migration20241120100616", + "name": "Migration20241209165812", "created_at": { - "$date": "2024-11-20T17:03:31.473Z" + "$date": "2024-12-12T10:40:52.027Z" + } + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e6" + }, + "name": "Migration20241210152600", + "created_at": { + "$date": "2024-12-12T10:40:52.029Z" } } ] diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 81c1b5bc4a..0c494cb441 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -599,8 +599,7 @@ "name": "roomeditor", "permissions": [ "ROOM_VIEW", - "ROOM_EDIT", - "ROOM_DELETE" + "ROOM_EDIT" ] }, { @@ -616,5 +615,31 @@ }, "name": "guestTeacher", "permissions": [] + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e3" + }, + "name": "roomowner", + "permissions": [ + "ROOM_VIEW", + "ROOM_EDIT", + "ROOM_DELETE", + "ROOM_MEMBERS_ADD", + "ROOM_MEMBERS_REMOVE", + "ROOM_CHANGE_OWNER" + ] + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e4" + }, + "name": "roomadmin", + "permissions": [ + "ROOM_VIEW", + "ROOM_EDIT", + "ROOM_MEMBERS_ADD", + "ROOM_MEMBERS_REMOVE" + ] } ]