diff --git a/apps/server/src/modules/authorization/authorization.module.ts b/apps/server/src/modules/authorization/authorization.module.ts index 37ca0a2b229..c555f13dc7b 100644 --- a/apps/server/src/modules/authorization/authorization.module.ts +++ b/apps/server/src/modules/authorization/authorization.module.ts @@ -15,6 +15,7 @@ import { UserRule, UserLoginMigrationRule, LegacySchoolRule, + GroupRule, } from './domain/rules'; import { AuthorizationHelper, AuthorizationService, RuleManager } from './domain'; import { FeathersAuthorizationService, FeathersAuthProvider } from './feathers'; @@ -33,6 +34,7 @@ import { FeathersAuthorizationService, FeathersAuthProvider } from './feathers'; ContextExternalToolRule, CourseGroupRule, CourseRule, + GroupRule, LessonRule, SchoolExternalToolRule, SubmissionRule, diff --git a/apps/server/src/modules/authorization/domain/rules/group.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/group.rule.spec.ts new file mode 100644 index 00000000000..bb2bc2e48b3 --- /dev/null +++ b/apps/server/src/modules/authorization/domain/rules/group.rule.spec.ts @@ -0,0 +1,210 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission, Role, SchoolEntity, User } from '@shared/domain'; +import { groupFactory, roleFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { Action, AuthorizationContext, AuthorizationHelper } from '@src/modules/authorization'; +import { Group } from '@src/modules/group'; +import { ObjectId } from 'bson'; +import { GroupRule } from './group.rule'; + +describe('GroupRule', () => { + let module: TestingModule; + let rule: GroupRule; + + let authorizationHelper: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + GroupRule, + { + provide: AuthorizationHelper, + useValue: createMock(), + }, + ], + }).compile(); + + rule = module.get(GroupRule); + authorizationHelper = module.get(AuthorizationHelper); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isApplicable', () => { + describe('when the entity is applicable', () => { + const setup = () => { + const role: Role = roleFactory.buildWithId(); + const user: User = userFactory.buildWithId({ roles: [role] }); + const group: Group = groupFactory.build({ + users: [ + { + userId: user.id, + roleId: user.roles[0].id, + }, + ], + }); + + return { + user, + group, + }; + }; + + it('should return true', () => { + const { user, group } = setup(); + + const result = rule.isApplicable(user, group); + + expect(result).toEqual(true); + }); + }); + + describe('when the entity is not applicable', () => { + const setup = () => { + const role: Role = roleFactory.buildWithId(); + const userNotInGroup: User = userFactory.buildWithId({ roles: [role] }); + + return { + userNotInGroup, + }; + }; + + it('should return false', () => { + const { userNotInGroup } = setup(); + + const result = rule.isApplicable(userNotInGroup, {} as unknown as Group); + + expect(result).toEqual(false); + }); + }); + }); + + describe('hasPermission', () => { + describe('when the user has all required permissions and is at the same school then the group', () => { + const setup = () => { + const role: Role = roleFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId(); + const user: User = userFactory.buildWithId({ school, roles: [role] }); + const group: Group = groupFactory.build({ + users: [ + { + userId: user.id, + roleId: user.roles[0].id, + }, + ], + organizationId: user.school.id, + }); + const context: AuthorizationContext = { + action: Action.write, + requiredPermissions: [Permission.GROUP_VIEW], + }; + + authorizationHelper.hasAllPermissions.mockReturnValue(true); + + return { + user, + group, + context, + }; + }; + + it('should check all permissions', () => { + const { user, group, context } = setup(); + + rule.hasPermission(user, group, context); + + expect(authorizationHelper.hasAllPermissions).toHaveBeenCalledWith(user, context.requiredPermissions); + }); + + it('should return true', () => { + const { user, group, context } = setup(); + + const result = rule.hasPermission(user, group, context); + + expect(result).toEqual(true); + }); + }); + + describe('when the user has not the required permission', () => { + const setup = () => { + const role: Role = roleFactory.buildWithId({ permissions: [] }); + const school: SchoolEntity = schoolFactory.buildWithId(); + const user: User = userFactory.buildWithId({ school, roles: [role] }); + const group: Group = groupFactory.build({ + users: [ + { + userId: user.id, + roleId: user.roles[0].id, + }, + ], + organizationId: user.school.id, + }); + const context: AuthorizationContext = { + action: Action.write, + requiredPermissions: [Permission.GROUP_VIEW], + }; + + authorizationHelper.hasAllPermissions.mockReturnValue(false); + + return { + user, + group, + context, + }; + }; + + it('should return false', () => { + const { user, group, context } = setup(); + + const result = rule.hasPermission(user, group, context); + + expect(result).toEqual(false); + }); + }); + + describe('when the user is at another school then the group', () => { + const setup = () => { + const role: Role = roleFactory.buildWithId({ permissions: [] }); + const school: SchoolEntity = schoolFactory.buildWithId(); + const user: User = userFactory.buildWithId({ school, roles: [role] }); + const group: Group = groupFactory.build({ + users: [ + { + userId: user.id, + roleId: user.roles[0].id, + }, + ], + organizationId: new ObjectId().toHexString(), + }); + const context: AuthorizationContext = { + action: Action.write, + requiredPermissions: [Permission.GROUP_VIEW], + }; + + authorizationHelper.hasAllPermissions.mockReturnValue(true); + + return { + user, + group, + context, + }; + }; + + it('should return false', () => { + const { user, group, context } = setup(); + + const result = rule.hasPermission(user, group, context); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/authorization/domain/rules/group.rule.ts b/apps/server/src/modules/authorization/domain/rules/group.rule.ts new file mode 100644 index 00000000000..e25e90230c8 --- /dev/null +++ b/apps/server/src/modules/authorization/domain/rules/group.rule.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { User } from '@shared/domain'; +import { Group } from '@src/modules/group'; +import { AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; + +@Injectable() +export class GroupRule implements Rule { + constructor(private readonly authorizationHelper: AuthorizationHelper) {} + + public isApplicable(user: User, domainObject: Group): boolean { + const isMatched: boolean = domainObject instanceof Group; + + return isMatched; + } + + public hasPermission(user: User, domainObject: Group, context: AuthorizationContext): boolean { + const hasPermission: boolean = + this.authorizationHelper.hasAllPermissions(user, context.requiredPermissions) && + (domainObject.organizationId ? user.school.id === domainObject.organizationId : true); + + return hasPermission; + } +} diff --git a/apps/server/src/modules/authorization/domain/rules/index.ts b/apps/server/src/modules/authorization/domain/rules/index.ts index bd4ffe27a59..b78f43051d0 100644 --- a/apps/server/src/modules/authorization/domain/rules/index.ts +++ b/apps/server/src/modules/authorization/domain/rules/index.ts @@ -14,3 +14,4 @@ export * from './task.rule'; export * from './team.rule'; export * from './user-login-migration.rule'; export * from './user.rule'; +export * from './group.rule'; diff --git a/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts index 78ef313ade1..5b62f850416 100644 --- a/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts @@ -16,6 +16,7 @@ import { TeamRule, UserRule, UserLoginMigrationRule, + GroupRule, } from '../rules'; import { RuleManager } from './rule-manager'; @@ -33,6 +34,7 @@ describe('RuleManager', () => { let boardDoRule: DeepMocked; let contextExternalToolRule: DeepMocked; let userLoginMigrationRule: DeepMocked; + let groupRule: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -42,6 +44,7 @@ describe('RuleManager', () => { RuleManager, { provide: CourseRule, useValue: createMock() }, { provide: CourseGroupRule, useValue: createMock() }, + { provide: GroupRule, useValue: createMock() }, { provide: LessonRule, useValue: createMock() }, { provide: LegacySchoolRule, useValue: createMock() }, { provide: UserRule, useValue: createMock() }, @@ -68,6 +71,7 @@ describe('RuleManager', () => { boardDoRule = await module.get(BoardDoRule); contextExternalToolRule = await module.get(ContextExternalToolRule); userLoginMigrationRule = await module.get(UserLoginMigrationRule); + groupRule = await module.get(GroupRule); }); afterEach(() => { @@ -98,6 +102,7 @@ describe('RuleManager', () => { boardDoRule.isApplicable.mockReturnValueOnce(false); contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); + groupRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -119,6 +124,7 @@ describe('RuleManager', () => { expect(boardDoRule.isApplicable).toBeCalled(); expect(contextExternalToolRule.isApplicable).toBeCalled(); expect(userLoginMigrationRule.isApplicable).toBeCalled(); + expect(groupRule.isApplicable).toBeCalled(); }); it('should return CourseRule', () => { @@ -148,6 +154,7 @@ describe('RuleManager', () => { boardDoRule.isApplicable.mockReturnValueOnce(false); contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); + groupRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -177,6 +184,7 @@ describe('RuleManager', () => { boardDoRule.isApplicable.mockReturnValueOnce(false); contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); + groupRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; diff --git a/apps/server/src/modules/authorization/domain/service/rule-manager.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.ts index 77d09f284c2..6e6237d125f 100644 --- a/apps/server/src/modules/authorization/domain/service/rule-manager.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.ts @@ -15,6 +15,7 @@ import { TeamRule, UserLoginMigrationRule, UserRule, + GroupRule, } from '../rules'; @Injectable() @@ -33,7 +34,8 @@ export class RuleManager { private readonly schoolExternalToolRule: SchoolExternalToolRule, private readonly boardDoRule: BoardDoRule, private readonly contextExternalToolRule: ContextExternalToolRule, - private readonly userLoginMigrationRule: UserLoginMigrationRule + private readonly userLoginMigrationRule: UserLoginMigrationRule, + private readonly groupRule: GroupRule ) { this.rules = [ this.courseRule, @@ -48,6 +50,7 @@ export class RuleManager { this.boardDoRule, this.contextExternalToolRule, this.userLoginMigrationRule, + this.groupRule, ]; } diff --git a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts index 3ace7386565..34a49c03a35 100644 --- a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts +++ b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts @@ -15,6 +15,7 @@ import { import { ClassEntity } from '@modules/class/entity'; import { classEntityFactory } from '@modules/class/entity/testing/factory/class.entity.factory'; import { ServerTestModule } from '@modules/server'; +import { ObjectId } from 'bson'; import { GroupEntity, GroupEntityTypes } from '../../entity'; import { ClassRootType } from '../../uc/dto/class-root-type'; import { ClassInfoSearchListResponse, ClassSortBy } from '../dto'; @@ -41,7 +42,7 @@ describe('Group (API)', () => { await app.close(); }); - describe('findClassesForSchool', () => { + describe('[GET] /groups/class', () => { describe('when an admin requests a list of classes', () => { const setup = async () => { const school: SchoolEntity = schoolFactory.buildWithId(); @@ -158,4 +159,119 @@ describe('Group (API)', () => { }); }); }); + + describe('[GET] /groups/:groupId', () => { + describe('when authorized user requests a group', () => { + describe('when group exists', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + + const group: GroupEntity = groupEntityFactory.buildWithId({ + users: [ + { + user: teacherUser, + role: teacherUser.roles[0], + }, + ], + organization: school, + }); + + await em.persistAndFlush([teacherAccount, teacherUser, group]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + group, + teacherUser, + }; + }; + + it('should return the group', async () => { + const { loggedInClient, group, teacherUser } = await setup(); + + const response = await loggedInClient.get(`${group.id}`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + id: group.id, + name: group.name, + type: group.type, + users: [ + { + id: teacherUser.id, + firstName: teacherUser.firstName, + lastName: teacherUser.lastName, + role: teacherUser.roles[0].name, + }, + ], + externalSource: { + externalId: group.externalSource?.externalId, + systemId: group.externalSource?.system.id, + }, + }); + }); + }); + + describe('when group does not exist', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + await em.persistAndFlush([teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + }; + }; + + it('should return not found', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get(`${new ObjectId().toHexString()}`); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + expect(response.body).toEqual({ + code: HttpStatus.NOT_FOUND, + message: 'Not Found', + title: 'Not Found', + type: 'NOT_FOUND', + }); + }); + }); + }); + + describe('when unauthorized user requests a group', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const group: GroupEntity = groupEntityFactory.buildWithId(); + + await em.persistAndFlush([studentAccount, studentUser, group]); + em.clear(); + + return { + groupId: group.id, + }; + }; + + it('should return unauthorized', async () => { + const { groupId } = await setup(); + + const response = await testApiClient.get(`${groupId}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); }); diff --git a/apps/server/src/modules/group/controller/dto/request/group-id-params.ts b/apps/server/src/modules/group/controller/dto/request/group-id-params.ts new file mode 100644 index 00000000000..9423966009f --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/request/group-id-params.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; + +export class GroupIdParams { + @IsMongoId() + @ApiProperty({ nullable: false, required: true }) + groupId!: string; +} diff --git a/apps/server/src/modules/group/controller/dto/request/index.ts b/apps/server/src/modules/group/controller/dto/request/index.ts index 2255e9aac09..17ecd658b7d 100644 --- a/apps/server/src/modules/group/controller/dto/request/index.ts +++ b/apps/server/src/modules/group/controller/dto/request/index.ts @@ -1 +1,2 @@ export * from './class-sort-params'; +export * from './group-id-params'; diff --git a/apps/server/src/modules/group/controller/dto/response/external-source.response.ts b/apps/server/src/modules/group/controller/dto/response/external-source.response.ts new file mode 100644 index 00000000000..f03327c8a8c --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/external-source.response.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ExternalSourceResponse { + @ApiProperty() + externalId: string; + + @ApiProperty() + systemId: string; + + constructor(props: ExternalSourceResponse) { + this.externalId = props.externalId; + this.systemId = props.systemId; + } +} diff --git a/apps/server/src/modules/group/controller/dto/response/group-type.response.ts b/apps/server/src/modules/group/controller/dto/response/group-type.response.ts new file mode 100644 index 00000000000..54c32148ca1 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/group-type.response.ts @@ -0,0 +1,3 @@ +export enum GroupTypeResponse { + CLASS = 'class', +} diff --git a/apps/server/src/modules/group/controller/dto/response/group-user.response.ts b/apps/server/src/modules/group/controller/dto/response/group-user.response.ts new file mode 100644 index 00000000000..000958c96cf --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/group-user.response.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { RoleName } from '@shared/domain'; + +export class GroupUserResponse { + @ApiProperty() + id: string; + + @ApiProperty() + firstName: string; + + @ApiProperty() + lastName: string; + + @ApiProperty({ enum: RoleName }) + role: RoleName; + + constructor(user: GroupUserResponse) { + this.id = user.id; + this.firstName = user.firstName; + this.lastName = user.lastName; + this.role = user.role; + } +} diff --git a/apps/server/src/modules/group/controller/dto/response/group.response.ts b/apps/server/src/modules/group/controller/dto/response/group.response.ts new file mode 100644 index 00000000000..1abb28a8a30 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/group.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ExternalSourceResponse } from './external-source.response'; +import { GroupTypeResponse } from './group-type.response'; +import { GroupUserResponse } from './group-user.response'; + +export class GroupResponse { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty({ enum: GroupTypeResponse }) + type: GroupTypeResponse; + + @ApiProperty({ type: [GroupUserResponse] }) + users: GroupUserResponse[]; + + @ApiPropertyOptional() + externalSource?: ExternalSourceResponse; + + @ApiPropertyOptional() + organizationId?: string; + + constructor(group: GroupResponse) { + this.id = group.id; + this.name = group.name; + this.type = group.type; + this.users = group.users; + this.externalSource = group.externalSource; + this.organizationId = group.organizationId; + } +} diff --git a/apps/server/src/modules/group/controller/dto/response/index.ts b/apps/server/src/modules/group/controller/dto/response/index.ts index 1ec8a62f0d4..9593930f21e 100644 --- a/apps/server/src/modules/group/controller/dto/response/index.ts +++ b/apps/server/src/modules/group/controller/dto/response/index.ts @@ -1,2 +1,6 @@ export * from './class-info.response'; export * from './class-info-search-list.response'; +export * from './external-source.response'; +export * from './group.response'; +export * from './group-type.response'; +export * from './group-user.response'; diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts index 553d07b0d5c..9e5f4b3b51a 100644 --- a/apps/server/src/modules/group/controller/group.controller.ts +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -1,12 +1,12 @@ -import { Controller, Get, HttpStatus, Query } from '@nestjs/common'; +import { Controller, Get, HttpStatus, Param, Query } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { PaginationParams } from '@shared/controller'; import { Page } from '@shared/domain'; import { ErrorResponse } from '@src/core/error/dto'; import { ICurrentUser, Authenticate, CurrentUser } from '@modules/authentication'; import { GroupUc } from '../uc'; -import { ClassInfoDto } from '../uc/dto'; -import { ClassInfoSearchListResponse, ClassSortParams } from './dto'; +import { ClassInfoDto, ResolvedGroupDto } from '../uc/dto'; +import { ClassInfoSearchListResponse, ClassSortParams, GroupIdParams, GroupResponse } from './dto'; import { GroupResponseMapper } from './mapper'; @ApiTags('Group') @@ -42,4 +42,20 @@ export class GroupController { return response; } + + @Get('/:groupId') + @ApiOperation({ summary: 'Get a group by id.' }) + @ApiResponse({ status: HttpStatus.OK, type: GroupResponse }) + @ApiResponse({ status: '4XX', type: ErrorResponse }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + public async getGroup( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: GroupIdParams + ): Promise { + const group: ResolvedGroupDto = await this.groupUc.getGroup(currentUser.userId, params.groupId); + + const response: GroupResponse = GroupResponseMapper.mapToGroupResponse(group); + + return response; + } } diff --git a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts index 5f337eb204c..6efd02d899d 100644 --- a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts +++ b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts @@ -1,6 +1,18 @@ import { Page } from '@shared/domain'; -import { ClassInfoDto } from '../../uc/dto'; -import { ClassInfoResponse, ClassInfoSearchListResponse } from '../dto'; +import { GroupTypes } from '../../domain'; +import { ClassInfoDto, ResolvedGroupDto } from '../../uc/dto'; +import { + ClassInfoResponse, + ClassInfoSearchListResponse, + ExternalSourceResponse, + GroupResponse, + GroupTypeResponse, + GroupUserResponse, +} from '../dto'; + +const typeMapping: Record = { + [GroupTypes.CLASS]: GroupTypeResponse.CLASS, +}; export class GroupResponseMapper { static mapToClassInfosToListResponse( @@ -35,4 +47,30 @@ export class GroupResponseMapper { return mapped; } + + static mapToGroupResponse(resolvedGroup: ResolvedGroupDto): GroupResponse { + const mapped: GroupResponse = new GroupResponse({ + id: resolvedGroup.id, + name: resolvedGroup.name, + type: typeMapping[resolvedGroup.type], + externalSource: resolvedGroup.externalSource + ? new ExternalSourceResponse({ + externalId: resolvedGroup.externalSource.externalId, + systemId: resolvedGroup.externalSource.systemId, + }) + : undefined, + users: resolvedGroup.users.map( + (user) => + new GroupUserResponse({ + id: user.user.id as string, + role: user.role.name, + firstName: user.user.firstName, + lastName: user.user.lastName, + }) + ), + organizationId: resolvedGroup.organizationId, + }); + + return mapped; + } } diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index 826bbd36b22..3d1f19bc312 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -38,6 +38,10 @@ export class Group extends DomainObject { return this.props.organizationId; } + get type(): GroupTypes { + return this.props.type; + } + removeUser(user: UserDO): void { this.props.users = this.props.users.filter((groupUser: GroupUser): boolean => groupUser.userId !== user.id); } diff --git a/apps/server/src/modules/group/uc/dto/index.ts b/apps/server/src/modules/group/uc/dto/index.ts index 389a31da162..d795f1c30d3 100644 --- a/apps/server/src/modules/group/uc/dto/index.ts +++ b/apps/server/src/modules/group/uc/dto/index.ts @@ -1,2 +1,3 @@ export * from './class-info.dto'; export * from './resolved-group-user'; +export * from './resolved-group.dto'; diff --git a/apps/server/src/modules/group/uc/dto/resolved-group.dto.ts b/apps/server/src/modules/group/uc/dto/resolved-group.dto.ts new file mode 100644 index 00000000000..4d288f936a0 --- /dev/null +++ b/apps/server/src/modules/group/uc/dto/resolved-group.dto.ts @@ -0,0 +1,26 @@ +import { ExternalSource } from '@shared/domain'; +import { GroupTypes } from '../../domain'; +import { ResolvedGroupUser } from './resolved-group-user'; + +export class ResolvedGroupDto { + id: string; + + name: string; + + type: GroupTypes; + + users: ResolvedGroupUser[]; + + externalSource?: ExternalSource; + + organizationId?: string; + + constructor(group: ResolvedGroupDto) { + this.id = group.id; + this.name = group.name; + this.type = group.type; + this.users = group.users; + this.externalSource = group.externalSource; + this.organizationId = group.organizationId; + } +} diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts index 8e882c052d3..34cb55a1354 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -2,6 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { LegacySchoolDo, Page, Permission, SchoolYearEntity, SortOrder, User, UserDO } from '@shared/domain'; import { groupFactory, @@ -22,9 +23,9 @@ import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { SystemDto, SystemService } from '@modules/system'; import { UserService } from '@modules/user'; -import { Group } from '../domain'; +import { Group, GroupTypes } from '../domain'; import { GroupService } from '../service'; -import { ClassInfoDto } from './dto'; +import { ClassInfoDto, ResolvedGroupDto } from './dto'; import { ClassRootType } from './dto/class-root-type'; import { GroupUc } from './group.uc'; @@ -218,7 +219,7 @@ describe('GroupUc', () => { }; }; - it('should check the CLASS_LIST permission', async () => { + it('should check the required permissions', async () => { const { teacherUser, school } = setup(); await uc.findAllClassesForSchool(teacherUser.id, teacherUser.school.id); @@ -228,7 +229,7 @@ describe('GroupUc', () => { school, { action: Action.read, - requiredPermissions: [Permission.CLASS_LIST], + requiredPermissions: [Permission.CLASS_LIST, Permission.GROUP_LIST], } ); }); @@ -340,4 +341,144 @@ describe('GroupUc', () => { }); }); }); + + describe('getGroup', () => { + describe('when the user has no permission', () => { + const setup = () => { + const user: User = userFactory.buildWithId(); + const error = new ForbiddenException(); + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.checkPermission.mockImplementation(() => { + throw error; + }); + + return { + user, + error, + }; + }; + + it('should throw forbidden', async () => { + const { user, error } = setup(); + + const func = () => uc.getGroup(user.id, 'groupId'); + + await expect(func).rejects.toThrow(error); + }); + }); + + describe('when the group is not found', () => { + const setup = () => { + groupService.findById.mockRejectedValue(new NotFoundLoggableException(Group.name, 'id', 'groupId')); + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); + + return { + teacherId: teacherUser.id, + }; + }; + + it('should throw not found', async () => { + const { teacherId } = setup(); + + const func = () => uc.getGroup(teacherId, 'groupId'); + + await expect(func).rejects.toThrow(NotFoundLoggableException); + }); + }); + + describe('when the group is found', () => { + const setup = () => { + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { studentUser } = UserAndAccountTestFactory.buildStudent(); + const group: Group = groupFactory.build({ + users: [ + { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, + { userId: studentUser.id, roleId: studentUser.roles[0].id }, + ], + }); + const teacherRole: RoleDto = roleDtoFactory.build({ + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }); + const studentRole: RoleDto = roleDtoFactory.build({ + id: studentUser.roles[0].id, + name: studentUser.roles[0].name, + }); + const teacherUserDo: UserDO = userDoFactory.build({ + id: teacherUser.id, + firstName: teacherUser.firstName, + lastName: teacherUser.lastName, + email: teacherUser.email, + roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], + }); + const studentUserDo: UserDO = userDoFactory.build({ + id: studentUser.id, + firstName: teacherUser.firstName, + lastName: studentUser.lastName, + email: studentUser.email, + roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], + }); + + groupService.findById.mockResolvedValueOnce(group); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); + userService.findById.mockResolvedValueOnce(teacherUserDo); + roleService.findById.mockResolvedValueOnce(teacherRole); + userService.findById.mockResolvedValueOnce(studentUserDo); + roleService.findById.mockResolvedValueOnce(studentRole); + + return { + teacherId: teacherUser.id, + teacherUser, + studentUser, + group, + expectedExternalId: group.externalSource?.externalId as string, + expectedSystemId: group.externalSource?.systemId as string, + }; + }; + + it('should return the resolved group', async () => { + const { teacherId, teacherUser, studentUser, group, expectedExternalId, expectedSystemId } = setup(); + + const result: ResolvedGroupDto = await uc.getGroup(teacherId, group.id); + + expect(result).toMatchObject({ + id: group.id, + name: group.name, + type: GroupTypes.CLASS, + externalSource: { + externalId: expectedExternalId, + systemId: expectedSystemId, + }, + users: [ + { + user: { + id: teacherUser.id, + firstName: teacherUser.firstName, + lastName: teacherUser.lastName, + email: teacherUser.email, + }, + role: { + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }, + }, + { + user: { + id: studentUser.id, + firstName: studentUser.firstName, + lastName: studentUser.lastName, + email: studentUser.email, + }, + role: { + id: studentUser.roles[0].id, + name: studentUser.roles[0].name, + }, + }, + ], + }); + }); + }); + }); }); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 23cac984a6d..2421e444e73 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -11,7 +11,7 @@ import { UserService } from '@modules/user'; import { Group, GroupUser } from '../domain'; import { GroupService } from '../service'; import { SortHelper } from '../util'; -import { ClassInfoDto, ResolvedGroupUser } from './dto'; +import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from './dto'; import { GroupUcMapper } from './mapper/group-uc.mapper'; @Injectable() @@ -38,7 +38,11 @@ export class GroupUc { const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); const user: User = await this.authorizationService.getUserWithPermissions(userId); - this.authorizationService.checkPermission(user, school, AuthorizationContextBuilder.read([Permission.CLASS_LIST])); + this.authorizationService.checkPermission( + user, + school, + AuthorizationContextBuilder.read([Permission.CLASS_LIST, Permission.GROUP_LIST]) + ); const combinedClassInfo: ClassInfoDto[] = await this.findCombinedClassListForSchool(schoolId); @@ -153,4 +157,24 @@ export class GroupUc { return page; } + + public async getGroup(userId: EntityId, groupId: EntityId): Promise { + const group: Group = await this.groupService.findById(groupId); + + await this.checkPermission(userId, group); + + const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); + const resolvedGroup: ResolvedGroupDto = GroupUcMapper.mapToResolvedGroupDto(group, resolvedUsers); + + return resolvedGroup; + } + + private async checkPermission(userId: EntityId, group: Group): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + return this.authorizationService.checkPermission( + user, + group, + AuthorizationContextBuilder.read([Permission.GROUP_VIEW]) + ); + } } diff --git a/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts index ebeba60117e..5ac11f0e0b6 100644 --- a/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts +++ b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts @@ -2,7 +2,7 @@ import { RoleName, SchoolYearEntity, UserDO } from '@shared/domain'; import { Class } from '@modules/class/domain'; import { SystemDto } from '@modules/system'; import { Group } from '../../domain'; -import { ClassInfoDto, ResolvedGroupUser } from '../dto'; +import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from '../dto'; import { ClassRootType } from '../dto/class-root-type'; export class GroupUcMapper { @@ -40,4 +40,16 @@ export class GroupUcMapper { return mapped; } + + public static mapToResolvedGroupDto(group: Group, resolvedGroupUsers: ResolvedGroupUser[]): ResolvedGroupDto { + const mapped: ResolvedGroupDto = new ResolvedGroupDto({ + id: group.id, + name: group.name, + type: group.type, + externalSource: group.externalSource, + users: resolvedGroupUsers, + }); + + return mapped; + } } diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index 3d00ef24be2..4512b95de7f 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -54,6 +54,8 @@ export enum Permission { FILE_MOVE = 'FILE_MOVE', FOLDER_CREATE = 'FOLDER_CREATE', FOLDER_DELETE = 'FOLDER_DELETE', + GROUP_LIST = 'GROUP_LIST', + GROUP_VIEW = 'GROUP_VIEW', HELPDESK_CREATE = 'HELPDESK_CREATE', HELPDESK_EDIT = 'HELPDESK_EDIT', HELPDESK_VIEW = 'HELPDESK_VIEW', diff --git a/apps/server/src/shared/domain/rules/index.ts b/apps/server/src/shared/domain/rules/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/server/src/shared/testing/user-role-permissions.ts b/apps/server/src/shared/testing/user-role-permissions.ts index c2a75fe478d..cfd38cea3a3 100644 --- a/apps/server/src/shared/testing/user-role-permissions.ts +++ b/apps/server/src/shared/testing/user-role-permissions.ts @@ -53,6 +53,7 @@ export const userPermissions = [ Permission.CLASS_VIEW, Permission.COURSE_VIEW, Permission.LERNSTORE_VIEW, + Permission.GROUP_VIEW, ] as Permission[]; export const studentPermissions = [ @@ -73,6 +74,7 @@ const sharedAdminPermissions = [ Permission.COURSE_CREATE, Permission.COURSE_EDIT, Permission.COURSE_REMOVE, + Permission.GROUP_LIST, Permission.NEWS_CREATE, Permission.NEWS_EDIT, Permission.STUDENT_SKIP_REGISTRATION, diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 4cec0040475..8292d8fc62f 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -317,5 +317,16 @@ "$date": "2023-10-11T10:40:18.782Z" }, "__v": 0 + }, + { + "_id": { + "$oid": "652ea0196ddf74176cb57561" + }, + "state": "up", + "name": "add-group-view-and-list-permission", + "createdAt": { + "$date": "2023-10-17T14:38:44.886Z" + }, + "__v": 0 } ] diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 3d7909a84da..0ad460fc526 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -6,7 +6,7 @@ "name": "user", "roles": [], "updatedAt": { - "$date": "2023-05-16T11:11:21.297Z" + "$date": "2023-10-18T05:58:52.716Z" }, "createdAt": { "$date": "2017-01-01T00:06:37.148Z" @@ -58,7 +58,8 @@ "TOOL_VIEW", "TOPIC_VIEW", "NEXTCLOUD_USER", - "CONTEXT_TOOL_USER" + "CONTEXT_TOOL_USER", + "GROUP_VIEW" ], "__v": 0 }, @@ -68,7 +69,7 @@ }, "name": "administrator", "updatedAt": { - "$date": "2023-09-04T12:13:44.069Z" + "$date": "2023-10-18T05:58:52.729Z" }, "createdAt": { "$date": "2017-01-01T00:06:37.148Z" @@ -132,7 +133,8 @@ "SCHOOL_TOOL_ADMIN", "USER_LOGIN_MIGRATION_ADMIN", "START_MEETING", - "JOIN_MEETING" + "JOIN_MEETING", + "GROUP_LIST" ], "__v": 2 }, @@ -142,7 +144,7 @@ }, "name": "superhero", "updatedAt": { - "$date": "2022-09-22T13:14:23.839Z" + "$date": "2023-10-18T05:58:52.729Z" }, "createdAt": { "$date": "2017-01-01T00:06:37.148Z" @@ -188,7 +190,8 @@ "TEAM_EDIT", "TOOL_CREATE", "TOOL_EDIT", - "YEARS_EDIT" + "YEARS_EDIT", + "GROUP_LIST" ], "__v": 2 }, @@ -198,7 +201,7 @@ }, "name": "teacher", "updatedAt": { - "$date": "2023-09-04T12:54:47.237Z" + "$date": "2023-10-18T05:59:16.621Z" }, "createdAt": { "$date": "2017-01-01T00:06:37.148Z" @@ -245,7 +248,8 @@ "HOMEWORK_CREATE", "HOMEWORK_EDIT", "CONTEXT_TOOL_ADMIN", - "JOIN_MEETING" + "JOIN_MEETING", + "GROUP_LIST" ], "__v": 2 }, diff --git a/migrations/1697553524886-add-group-view-and-list-permission.js b/migrations/1697553524886-add-group-view-and-list-permission.js new file mode 100644 index 00000000000..2bf46755aed --- /dev/null +++ b/migrations/1697553524886-add-group-view-and-list-permission.js @@ -0,0 +1,100 @@ +const mongoose = require('mongoose'); +// eslint-disable-next-line no-unused-vars +const { info } = require('winston'); +const { alert, error } = require('../src/logger'); + +const { connect, close } = require('../src/utils/database'); + +const Roles = mongoose.model( + 'roles2023101716394', + new mongoose.Schema( + { + name: { type: String, required: true }, + permissions: [{ type: String }], + }, + { + timestamps: true, + } + ), + 'roles' +); + +module.exports = { + up: async function up() { + // eslint-disable-next-line no-process-env + if (process.env.SC_THEME !== 'n21') { + info('Permissions GROUP_VIEW and GROUP_LIST will not be added for this instance.'); + return; + } + + await connect(); + + const groupViewPermission = await Roles.updateOne( + { name: 'user' }, + { + $addToSet: { + permissions: { + $each: ['GROUP_VIEW'], + }, + }, + } + ).exec(); + if (groupViewPermission) { + alert(`Permission GROUP_VIEW added to role user`); + } + + const groupListPermission = await Roles.updateMany( + { name: { $in: ['teacher', 'administrator', 'superhero'] } }, + { + $addToSet: { + permissions: { + $each: ['GROUP_LIST'], + }, + }, + } + ).exec(); + if (groupListPermission) { + alert(`Permission GROUP_LIST added to role user and administrator`); + } + + await close(); + }, + + down: async function down() { + // eslint-disable-next-line no-process-env + if (process.env.SC_THEME !== 'n21') { + info('Permissions GROUP_VIEW and GROUP_LIST will not be removed for this instance.'); + return; + } + + await connect(); + + const groupViewRollback = await Roles.updateOne( + { name: 'user' }, + { + $pull: { + permissions: 'GROUP_VIEW', + }, + } + ).exec(); + + if (groupViewRollback) { + alert(`Rollback: Removed permission GROUP_VIEW from role user`); + } + + const groupListRollback = await Roles.updateMany( + { name: { $in: ['teacher', 'administrator', 'superhero'] } }, + { + $pull: { + permissions: 'GROUP_LIST', + }, + } + ).exec(); + + if (groupListRollback) { + alert(`Rollback: Removed permission GROUP_LIST from roles teacher and administrator`); + } + + await close(); + }, +};