diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index 94af5defccc..0718e15a6e4 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -4,7 +4,6 @@ import { Mail, MailService } from '@infra/mail'; /* eslint-disable no-console */ import { MikroORM } from '@mikro-orm/core'; import { AccountService } from '@modules/account'; -import { AccountValidationService } from '@src/modules/account/domain/services/account.validation.service'; import { AccountUc } from '@src/modules/account/api/account.uc'; import { SystemRule } from '@modules/authorization/domain/rules'; import { CollaborativeStorageUc } from '@modules/collaborative-storage/uc/collaborative-storage.uc'; @@ -83,8 +82,6 @@ async function bootstrap() { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-account-service'] = nestApp.get(AccountService); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - feathersExpress.services['nest-account-validation-service'] = nestApp.get(AccountValidationService); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-account-uc'] = nestApp.get(AccountUc); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-collaborative-storage-uc'] = nestApp.get(CollaborativeStorageUc); diff --git a/apps/server/src/modules/account/account.module.spec.ts b/apps/server/src/modules/account/account.module.spec.ts index ba7e80ec5c0..31a431eaf36 100644 --- a/apps/server/src/modules/account/account.module.spec.ts +++ b/apps/server/src/modules/account/account.module.spec.ts @@ -4,7 +4,6 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { AccountModule } from './account.module'; import { AccountIdmToDoMapper, AccountIdmToDoMapperDb, AccountIdmToDoMapperIdm } from './repo/micro-orm/mapper'; import { AccountService } from './domain/services/account.service'; -import { AccountValidationService } from './domain/services/account.validation.service'; describe('AccountModule', () => { let module: TestingModule; @@ -32,11 +31,6 @@ describe('AccountModule', () => { expect(accountService).toBeDefined(); }); - it('should have the account validation service defined', () => { - const accountValidationService = module.get(AccountValidationService); - expect(accountValidationService).toBeDefined(); - }); - describe('when FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED is enabled', () => { let moduleFeatureEnabled: TestingModule; diff --git a/apps/server/src/modules/account/account.module.ts b/apps/server/src/modules/account/account.module.ts index 4f7bd675c42..0f77a40fafd 100644 --- a/apps/server/src/modules/account/account.module.ts +++ b/apps/server/src/modules/account/account.module.ts @@ -11,7 +11,6 @@ import { AccountIdmToDoMapper, AccountIdmToDoMapperDb, AccountIdmToDoMapperIdm } import { AccountServiceDb } from './domain/services/account-db.service'; import { AccountServiceIdm } from './domain/services/account-idm.service'; import { AccountService } from './domain/services/account.service'; -import { AccountValidationService } from './domain/services/account.validation.service'; function accountIdmToDtoMapperFactory(configService: ConfigService): AccountIdmToDoMapper { if (configService.get('FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED') === true) { @@ -29,13 +28,12 @@ function accountIdmToDtoMapperFactory(configService: ConfigService { provide: ConfigService, useValue: createMock(), }, - { - provide: AccountValidationService, - useValue: createMock(), - }, { provide: AuthorizationService, useValue: createMock(), diff --git a/apps/server/src/modules/account/domain/services/account-db.service.spec.ts b/apps/server/src/modules/account/domain/services/account-db.service.spec.ts index e39962db20b..24a9b6f82e1 100644 --- a/apps/server/src/modules/account/domain/services/account-db.service.spec.ts +++ b/apps/server/src/modules/account/domain/services/account-db.service.spec.ts @@ -6,16 +6,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { IdmAccount } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { UserRepo } from '@shared/repo'; import { setupEntities, userFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; import bcrypt from 'bcryptjs'; import { v1 } from 'uuid'; -import { Account } from '../account'; import { AccountConfig } from '../../account-config'; import { AccountRepo } from '../../repo/micro-orm/account.repo'; +import { accountDoFactory } from '../../testing'; +import { Account } from '../account'; import { AccountEntity } from '../entity/account.entity'; import { AccountServiceDb } from './account-db.service'; -import { accountDoFactory } from '../../testing'; describe('AccountDbService', () => { let module: TestingModule; @@ -57,6 +58,10 @@ describe('AccountDbService', () => { provide: IdentityManagementService, useValue: createMock(), }, + { + provide: UserRepo, + useValue: createMock(), + }, ], }).compile(); accountRepo = module.get(AccountRepo); diff --git a/apps/server/src/modules/account/domain/services/account-db.service.ts b/apps/server/src/modules/account/domain/services/account-db.service.ts index 0a2d9b69de8..9b68993187c 100644 --- a/apps/server/src/modules/account/domain/services/account-db.service.ts +++ b/apps/server/src/modules/account/domain/services/account-db.service.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config/dist/config.service'; import { EntityNotFoundError } from '@shared/common'; import { Counted, EntityId } from '@shared/domain/types'; +import { UserRepo } from '@shared/repo'; import bcrypt from 'bcryptjs'; import { AccountConfig } from '../../account-config'; import { AccountRepo } from '../../repo/micro-orm/account.repo'; @@ -15,7 +16,8 @@ export class AccountServiceDb { constructor( private readonly accountRepo: AccountRepo, private readonly idmService: IdentityManagementService, - private readonly configService: ConfigService + private readonly configService: ConfigService, + private readonly userRepo: UserRepo ) {} async findById(id: EntityId): Promise { @@ -142,4 +144,20 @@ export class AccountServiceDb { return account; } + + async isUniqueEmail(email: string, userId?: EntityId, accountId?: EntityId, systemId?: EntityId): Promise { + const foundUsers = await this.userRepo.findByEmail(email); + const [accounts] = await this.accountRepo.searchByUsernameExactMatch(email); + const filteredAccounts = accounts.filter((foundAccount) => foundAccount.systemId === systemId); + + const multipleUsers = foundUsers.length > 1; + const multipleAccounts = filteredAccounts.length > 1; + // paranoid 'toString': legacy code may call userId or accountId as ObjectID + const oneUserWithoutGivenId = foundUsers.length === 1 && foundUsers[0].id.toString() !== userId?.toString(); + const oneAccountWithoutGivenId = + filteredAccounts.length === 1 && filteredAccounts[0].id.toString() !== accountId?.toString(); + const isUnique = !(multipleUsers || multipleAccounts || oneUserWithoutGivenId || oneAccountWithoutGivenId); + + return isUnique; + } } diff --git a/apps/server/src/modules/account/domain/services/account-idm.service.ts b/apps/server/src/modules/account/domain/services/account-idm.service.ts index 766a159b0cb..076b088ed44 100644 --- a/apps/server/src/modules/account/domain/services/account-idm.service.ts +++ b/apps/server/src/modules/account/domain/services/account-idm.service.ts @@ -191,4 +191,10 @@ export class AccountServiceIdm extends AbstractAccountService { } throw new EntityNotFoundError(`Account with id ${id.toString()} not found`); } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async isUniqueEmail(email: string, _userId?: EntityId, accountId?: EntityId, systemId?: EntityId): Promise { + const kc = await this.identityManager.findAccountsByUsername(email); + return kc.length > 0; + } } diff --git a/apps/server/src/modules/account/domain/services/account.service.abstract.ts b/apps/server/src/modules/account/domain/services/account.service.abstract.ts index 5876136170d..ab9cab799fa 100644 --- a/apps/server/src/modules/account/domain/services/account.service.abstract.ts +++ b/apps/server/src/modules/account/domain/services/account.service.abstract.ts @@ -46,4 +46,6 @@ export abstract class AbstractAccountService { abstract deleteByUserId(userId: EntityId): Promise; abstract searchByUsernameExactMatch(userName: string): Promise>; + + abstract isUniqueEmail(email: string, userId?: EntityId, accountId?: EntityId, systemId?: EntityId): Promise; } diff --git a/apps/server/src/modules/account/domain/services/account.service.integration.spec.ts b/apps/server/src/modules/account/domain/services/account.service.integration.spec.ts index 899f648cad3..9d75d0a0308 100644 --- a/apps/server/src/modules/account/domain/services/account.service.integration.spec.ts +++ b/apps/server/src/modules/account/domain/services/account.service.integration.spec.ts @@ -10,18 +10,18 @@ import { Test, TestingModule } from '@nestjs/testing'; import { IdmAccount } from '@shared/domain/interface'; import { UserRepo } from '@shared/repo'; import { cleanupCollections } from '@shared/testing'; -import { v1 } from 'uuid'; import { Logger } from '@src/core/logger'; +import { KeycloakIdentityManagementService } from '@src/infra/identity-management/keycloak/service/keycloak-identity-management.service'; +import { v1 } from 'uuid'; import { Account, AccountSave } from '..'; -import { AccountEntity } from '../entity/account.entity'; import { AccountRepo } from '../../repo/micro-orm/account.repo'; import { AccountIdmToDoMapper, AccountIdmToDoMapperDb } from '../../repo/micro-orm/mapper'; +import { accountFactory } from '../../testing'; +import { AccountEntity } from '../entity/account.entity'; import { AccountServiceDb } from './account-db.service'; import { AccountServiceIdm } from './account-idm.service'; import { AccountService } from './account.service'; import { AbstractAccountService } from './account.service.abstract'; -import { AccountValidationService } from './account.validation.service'; -import { accountFactory } from '../../testing'; describe('AccountService Integration', () => { let module: TestingModule; @@ -93,7 +93,10 @@ describe('AccountService Integration', () => { AccountServiceDb, AccountRepo, UserRepo, - AccountValidationService, + { + provide: KeycloakIdentityManagementService, + useValue: createMock(), + }, { provide: AccountIdmToDoMapper, useValue: new AccountIdmToDoMapperDb(), diff --git a/apps/server/src/modules/account/domain/services/account.service.spec.ts b/apps/server/src/modules/account/domain/services/account.service.spec.ts index 4c4b4866776..7a997c6dc9e 100644 --- a/apps/server/src/modules/account/domain/services/account.service.spec.ts +++ b/apps/server/src/modules/account/domain/services/account.service.spec.ts @@ -28,14 +28,12 @@ import { IdmCallbackLoggableException } from '../error'; import { AccountServiceDb } from './account-db.service'; import { AccountServiceIdm } from './account-idm.service'; import { AccountService } from './account.service'; -import { AccountValidationService } from './account.validation.service'; describe('AccountService', () => { let module: TestingModule; let accountService: AccountService; let accountServiceIdm: DeepMocked; let accountServiceDb: DeepMocked; - let accountValidationService: DeepMocked; let configService: DeepMocked; let logger: DeepMocked; let userRepo: DeepMocked; @@ -48,7 +46,6 @@ describe('AccountService', () => { accountServiceDb, accountServiceIdm, configService, - accountValidationService, logger, userRepo, accountRepo, @@ -90,12 +87,6 @@ describe('AccountService', () => { provide: AccountRepo, useValue: createMock(), }, - { - provide: AccountValidationService, - useValue: { - isUniqueEmail: jest.fn().mockResolvedValue(true), - }, - }, { provide: UserRepo, useValue: createMock(), @@ -115,7 +106,6 @@ describe('AccountService', () => { accountServiceDb = module.get(AccountServiceDb); accountServiceIdm = module.get(AccountServiceIdm); accountService = module.get(AccountService); - accountValidationService = module.get(AccountValidationService); configService = module.get(ConfigService); logger = module.get(Logger); userRepo = module.get(UserRepo); @@ -366,7 +356,7 @@ describe('AccountService', () => { return { id: '' }; }, } as Account); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); return spy; }; @@ -405,7 +395,7 @@ describe('AccountService', () => { return { id: '' }; }, } as Account); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); }; it('should not throw an error', async () => { @@ -425,7 +415,7 @@ describe('AccountService', () => { return { id: '' }; }, } as Account); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); }; it('should not throw an error', async () => { @@ -459,9 +449,26 @@ describe('AccountService', () => { }); }); - describe('When username already exists', () => { + describe('When username already exists in mongoDB', () => { + const setup = () => { + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(false); + }; + + it('should throw username already exists', async () => { + setup(); + const params: AccountSave = { + username: 'john.doe@mail.tld', + password: 'JohnsPassword_123', + } as AccountSave; + await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username already exists'); + }); + }); + + describe('When username already exists in identity management', () => { const setup = () => { - accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + configService.get.mockReturnValue(true); + + accountServiceIdm.isUniqueEmail.mockResolvedValueOnce(false); }; it('should throw username already exists', async () => { @@ -473,6 +480,7 @@ describe('AccountService', () => { await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username already exists'); }); }); + describe('When identity management is primary', () => { const setup = () => { configService.get.mockReturnValue(true); @@ -485,7 +493,7 @@ describe('AccountService', () => { accountServiceDb.save.mockResolvedValueOnce(account); accountServiceIdm.save.mockResolvedValueOnce(account); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceIdm.isUniqueEmail.mockResolvedValueOnce(true); return { service: newAccountService(), account }; }; @@ -1052,7 +1060,7 @@ describe('AccountService', () => { accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); const spyAccountServiceSave = jest.spyOn(accountServiceDb, 'save'); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo, spyAccountServiceSave }; }; @@ -1088,7 +1096,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1120,7 +1128,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); const accountSaveSpy = jest.spyOn(accountServiceDb, 'save'); @@ -1157,7 +1165,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); const userUpdateSpy = jest.spyOn(userRepo, 'save'); @@ -1192,7 +1200,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(true); const accountSaveSpy = jest.spyOn(accountServiceDb, 'save'); const userUpdateSpy = jest.spyOn(userRepo, 'save'); @@ -1228,7 +1236,7 @@ describe('AccountService', () => { const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); accountServiceDb.validatePassword.mockResolvedValue(true); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(false); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1328,7 +1336,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); accountServiceDb.save.mockRejectedValueOnce(undefined); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1361,7 +1369,7 @@ describe('AccountService', () => { accountServiceDb.validatePassword.mockResolvedValue(true); accountServiceDb.save.mockRejectedValueOnce(new ValidationError('fail to update')); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1431,7 +1439,7 @@ describe('AccountService', () => { Object.assign(mockStudentAccount, account); return Promise.resolve(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); }); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1493,7 +1501,7 @@ describe('AccountService', () => { userRepo.save.mockResolvedValue(); accountServiceDb.save.mockRejectedValueOnce(undefined); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1522,7 +1530,7 @@ describe('AccountService', () => { userRepo.save.mockRejectedValueOnce(undefined); - accountValidationService.isUniqueEmail.mockResolvedValue(true); + accountServiceDb.isUniqueEmail.mockResolvedValue(true); return { mockStudentUser, mockStudentAccountDo }; }; @@ -1583,7 +1591,7 @@ describe('AccountService', () => { const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); userRepo.save.mockRejectedValueOnce(undefined); - accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + accountServiceDb.isUniqueEmail.mockResolvedValueOnce(false); return { mockStudentUser, mockStudentAccountDo, mockOtherTeacherAccount }; }; diff --git a/apps/server/src/modules/account/domain/services/account.service.ts b/apps/server/src/modules/account/domain/services/account.service.ts index 37e6a6c8a81..946133edc34 100644 --- a/apps/server/src/modules/account/domain/services/account.service.ts +++ b/apps/server/src/modules/account/domain/services/account.service.ts @@ -43,7 +43,6 @@ import { import { AccountServiceDb } from './account-db.service'; import { AccountServiceIdm } from './account-idm.service'; import { AbstractAccountService } from './account.service.abstract'; -import { AccountValidationService } from './account.validation.service'; type UserPreferences = { firstLogin: boolean; @@ -58,7 +57,6 @@ export class AccountService extends AbstractAccountService implements DeletionSe private readonly accountDb: AccountServiceDb, private readonly accountIdm: AccountServiceIdm, private readonly configService: ConfigService, - private readonly accountValidationService: AccountValidationService, private readonly logger: Logger, private readonly userRepo: UserRepo, private readonly accountRepo: AccountRepo, @@ -324,14 +322,7 @@ export class AccountService extends AbstractAccountService implements DeletionSe // trimPassword hook will be done by class-validator ✔ // local.hooks.hashPassword('password'), will be done by account service ✔ // checkUnique ✔ - if ( - !(await this.accountValidationService.isUniqueEmail( - accountSave.username, - accountSave.userId, - accountSave.id, - accountSave.systemId - )) - ) { + if (!(await this.isUniqueEmail(accountSave.username, accountSave.userId, accountSave.id, accountSave.systemId))) { throw new ValidationError('Username already exists'); } // removePassword hook is not implemented @@ -436,7 +427,7 @@ export class AccountService extends AbstractAccountService implements DeletionSe } private async checkUniqueEmail(account: Account, user: User, email: string): Promise { - if (!(await this.accountValidationService.isUniqueEmail(email, user.id, account.id, account.systemId))) { + if (!(await this.isUniqueEmail(email, user.id, account.id, account.systemId))) { throw new ValidationError(`The email address is already in use!`); } } @@ -446,4 +437,27 @@ export class AccountService extends AbstractAccountService implements DeletionSe return foundAccounts; } + + public async isUniqueEmail( + email: string, + userId?: string | undefined, + accountId?: string | undefined, + systemId?: string | undefined + ): Promise { + const isUniqueEmailByKc = await this.accountIdm.isUniqueEmail(email, userId, accountId, systemId); + const isUniqueEmailByDb = await this.accountDb.isUniqueEmail(email, userId, accountId, systemId); + const isUniqueEmail = isUniqueEmailByKc && isUniqueEmailByDb; + + return isUniqueEmail; + } + + public async isUniqueEmailForUser(email: string, userId: EntityId): Promise { + const account = await this.accountRepo.findByUserId(userId); + return this.isUniqueEmail(email, userId, account?.id, account?.systemId?.toString()); + } + + public async isUniqueEmailForAccount(email: string, accountId: EntityId): Promise { + const account = await this.accountRepo.findById(accountId); + return this.isUniqueEmail(email, account.userId?.toString(), account.id, account.systemId?.toString()); + } } diff --git a/apps/server/src/modules/account/domain/services/account.validation.service.spec.ts b/apps/server/src/modules/account/domain/services/account.validation.service.spec.ts deleted file mode 100644 index d376b8c81e3..00000000000 --- a/apps/server/src/modules/account/domain/services/account.validation.service.spec.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; -import { Role } from '@shared/domain/entity'; -import { Permission, RoleName } from '@shared/domain/interface'; -import { UserRepo } from '@shared/repo'; -import { setupEntities, systemFactory, userFactory } from '@shared/testing'; -import { ObjectId } from '@mikro-orm/mongodb'; -import { AccountRepo } from '../../repo/micro-orm/account.repo'; -import { AccountValidationService } from './account.validation.service'; -import { accountDoFactory } from '../../testing'; - -describe('AccountValidationService', () => { - let module: TestingModule; - let accountValidationService: AccountValidationService; - - let userRepo: DeepMocked; - let accountRepo: DeepMocked; - - afterAll(async () => { - await module.close(); - }); - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - AccountValidationService, - { - provide: AccountRepo, - useValue: createMock(), - }, - { - provide: UserRepo, - useValue: createMock(), - }, - ], - }).compile(); - - accountValidationService = module.get(AccountValidationService); - - userRepo = module.get(UserRepo); - accountRepo = module.get(AccountRepo); - - await setupEntities(); - }); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('isUniqueEmail', () => { - describe('When new email is available', () => { - const setup = () => { - userRepo.findByEmail.mockResolvedValueOnce([]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); - }; - it('should return true', async () => { - setup(); - - const res = await accountValidationService.isUniqueEmail('an@available.email'); - expect(res).toBe(true); - }); - }); - - describe('When new email is available', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); - - return { mockStudentUser }; - }; - it('should return true and ignore current user', async () => { - const { mockStudentUser } = setup(); - const res = await accountValidationService.isUniqueEmail(mockStudentUser.email, mockStudentUser.id); - expect(res).toBe(true); - }); - }); - - describe('When new email is available', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); - - return { mockStudentUser, mockStudentAccount }; - }; - it('should return true and ignore current users account', async () => { - const { mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - mockStudentAccount.username, - mockStudentUser.id, - mockStudentAccount.id - ); - expect(res).toBe(true); - }); - }); - - describe('When new email already in use by another user', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - const mockAdminUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [ - Permission.TEACHER_EDIT, - Permission.STUDENT_EDIT, - Permission.STUDENT_LIST, - Permission.TEACHER_LIST, - Permission.TEACHER_CREATE, - Permission.STUDENT_CREATE, - Permission.TEACHER_DELETE, - Permission.STUDENT_DELETE, - ], - }), - ], - }); - const mockAdminAccount = accountDoFactory.build({ userId: mockAdminUser.id }); - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockAdminUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockAdminAccount], 1]); - - return { mockAdminUser, mockStudentUser, mockStudentAccount }; - }; - it('should return false', async () => { - const { mockAdminUser, mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - mockAdminUser.email, - mockStudentUser.id, - mockStudentAccount.id - ); - expect(res).toBe(false); - }); - }); - - describe('When new email already in use by any user and system id is given', () => { - const setup = () => { - const mockTeacherUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockTeacherAccount = accountDoFactory.build({ userId: mockTeacherUser.id }); - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockTeacherUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockTeacherAccount], 1]); - - return { mockTeacherAccount, mockStudentUser, mockStudentAccount }; - }; - it('should return false', async () => { - const { mockTeacherAccount, mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - mockTeacherAccount.username, - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); - }); - }); - - describe('When new email already in use by multiple users', () => { - const setup = () => { - const mockTeacherUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - const mockOtherTeacherUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - const mockUsers = [mockTeacherUser, mockStudentUser, mockOtherTeacherUser]; - - userRepo.findByEmail.mockResolvedValueOnce(mockUsers); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); - - return { mockStudentUser, mockStudentAccount }; - }; - it('should return false', async () => { - const { mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - 'multiple@user.email', - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); - }); - }); - - describe('When new email already in use by multiple accounts', () => { - const setup = () => { - const mockTeacherUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - const mockOtherTeacherUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - - const mockTeacherAccount = accountDoFactory.build({ userId: mockTeacherUser.id }); - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - const mockOtherTeacherAccount = accountDoFactory.build({ - userId: mockOtherTeacherUser.id, - }); - - const mockAccounts = [mockTeacherAccount, mockStudentAccount, mockOtherTeacherAccount]; - userRepo.findByEmail.mockResolvedValueOnce([]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([mockAccounts, mockAccounts.length]); - - return { mockStudentUser, mockStudentAccount }; - }; - it('should return false', async () => { - const { mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - 'multiple@account.username', - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); - }); - }); - - describe('When its another system', () => { - const setup = () => { - const mockExternalUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - const mockOtherExternalUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const externalSystemA = systemFactory.build(); - const externalSystemB = systemFactory.build(); - const mockExternalUserAccount = accountDoFactory.build({ - userId: mockExternalUser.id, - username: 'unique.within@system', - systemId: externalSystemA.id, - }); - const mockOtherExternalUserAccount = accountDoFactory.build({ - userId: mockOtherExternalUser.id, - username: 'unique.within@system', - systemId: externalSystemB.id, - }); - - userRepo.findByEmail.mockResolvedValueOnce([mockExternalUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockExternalUserAccount], 1]); - - return { mockExternalUser, mockExternalUserAccount, mockOtherExternalUserAccount }; - }; - it('should ignore existing username', async () => { - const { mockExternalUser, mockExternalUserAccount, mockOtherExternalUserAccount } = setup(); - const res = await accountValidationService.isUniqueEmail( - mockExternalUser.email, - mockExternalUser.id, - mockExternalUserAccount.id, - mockOtherExternalUserAccount.systemId?.toString() - ); - expect(res).toBe(true); - }); - }); - }); - - describe('isUniqueEmailForUser', () => { - describe('When its the email of the given user', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); - accountRepo.findByUserId.mockResolvedValueOnce(mockStudentAccount); - - return { mockStudentUser }; - }; - it('should return true', async () => { - const { mockStudentUser } = setup(); - const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockStudentUser.id); - expect(res).toBe(true); - }); - }); - - describe('When its not the given users email', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - const mockAdminUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [ - Permission.TEACHER_EDIT, - Permission.STUDENT_EDIT, - Permission.STUDENT_LIST, - Permission.TEACHER_LIST, - Permission.TEACHER_CREATE, - Permission.STUDENT_CREATE, - Permission.TEACHER_DELETE, - Permission.STUDENT_DELETE, - ], - }), - ], - }); - const mockAdminAccount = accountDoFactory.build({ userId: mockAdminUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); - accountRepo.findByUserIdOrFail.mockResolvedValueOnce(mockAdminAccount); - - return { mockStudentUser, mockAdminUser }; - }; - it('should return false', async () => { - const { mockStudentUser, mockAdminUser } = setup(); - const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockAdminUser.id); - expect(res).toBe(false); - }); - }); - }); - - describe('isUniqueEmailForAccount', () => { - describe('When its the email of the given user', () => { - const setup = () => { - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); - accountRepo.findById.mockResolvedValueOnce(mockStudentAccount); - - return { mockStudentUser, mockStudentAccount }; - }; - it('should return true', async () => { - const { mockStudentUser, mockStudentAccount } = setup(); - const res = await accountValidationService.isUniqueEmailForAccount( - mockStudentUser.email, - mockStudentAccount.id - ); - expect(res).toBe(true); - }); - }); - describe('When its not the given users email', () => { - const setup = () => { - const mockTeacherUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - const mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - - const mockTeacherAccount = accountDoFactory.build({ userId: mockTeacherUser.id }); - const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - - userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); - accountRepo.findById.mockResolvedValueOnce(mockTeacherAccount); - - return { mockStudentUser, mockTeacherAccount }; - }; - it('should return false', async () => { - const { mockStudentUser, mockTeacherAccount } = setup(); - const res = await accountValidationService.isUniqueEmailForAccount( - mockStudentUser.email, - mockTeacherAccount.id - ); - expect(res).toBe(false); - }); - }); - - describe('When user is missing in account', () => { - const setup = () => { - const oprhanAccount = accountDoFactory.build({ - username: 'orphan@account', - userId: undefined, - systemId: new ObjectId().toHexString(), - }); - - userRepo.findByEmail.mockResolvedValueOnce([]); - accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); - accountRepo.findById.mockResolvedValueOnce(oprhanAccount); - - return { oprhanAccount }; - }; - it('should ignore missing user for given account', async () => { - const { oprhanAccount } = setup(); - const res = await accountValidationService.isUniqueEmailForAccount(oprhanAccount.username, oprhanAccount.id); - expect(res).toBe(true); - }); - }); - }); -}); diff --git a/apps/server/src/modules/account/domain/services/account.validation.service.ts b/apps/server/src/modules/account/domain/services/account.validation.service.ts deleted file mode 100644 index 33ff32a15ad..00000000000 --- a/apps/server/src/modules/account/domain/services/account.validation.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; -import { UserRepo } from '@shared/repo'; -import { AccountRepo } from '../../repo/micro-orm/account.repo'; - -@Injectable() -export class AccountValidationService { - constructor(private accountRepo: AccountRepo, private userRepo: UserRepo) {} - - async isUniqueEmail(email: string, userId?: EntityId, accountId?: EntityId, systemId?: EntityId): Promise { - const foundUsers = await this.userRepo.findByEmail(email); - const [accounts] = await this.accountRepo.searchByUsernameExactMatch(email); - const filteredAccounts = accounts.filter((foundAccount) => foundAccount.systemId === systemId); - - const multipleUsers = foundUsers.length > 1; - const multipleAccounts = filteredAccounts.length > 1; - // paranoid 'toString': legacy code may call userId or accountId as ObjectID - const oneUserWithoutGivenId = foundUsers.length === 1 && foundUsers[0].id.toString() !== userId?.toString(); - const oneAccountWithoutGivenId = - filteredAccounts.length === 1 && filteredAccounts[0].id.toString() !== accountId?.toString(); - - const isUnique = !(multipleUsers || multipleAccounts || oneUserWithoutGivenId || oneAccountWithoutGivenId); - - return isUnique; - } - - async isUniqueEmailForUser(email: string, userId: EntityId): Promise { - const account = await this.accountRepo.findByUserId(userId); - return this.isUniqueEmail(email, userId, account?.id, account?.systemId?.toString()); - } - - async isUniqueEmailForAccount(email: string, accountId: EntityId): Promise { - const account = await this.accountRepo.findById(accountId); - return this.isUniqueEmail(email, account.userId?.toString(), account.id, account.systemId?.toString()); - } -} diff --git a/src/services/user/hooks/userService.js b/src/services/user/hooks/userService.js index b2b33a60e7f..049fed49446 100644 --- a/src/services/user/hooks/userService.js +++ b/src/services/user/hooks/userService.js @@ -1,632 +1,632 @@ -const { authenticate } = require('@feathersjs/authentication'); -const { keep } = require('feathers-hooks-common'); - -const { Forbidden, NotFound, BadRequest, GeneralError } = require('../../../errors'); -const logger = require('../../../logger'); -const { ObjectId } = require('../../../helper/compare'); -const { hasRoleNoHook, hasPermissionNoHook, hasPermission } = require('../../../hooks'); - -const { getAge } = require('../../../utils'); - -const constants = require('../../../utils/constants'); -const { CONSENT_WITHOUT_PARENTS_MIN_AGE_YEARS, SC_DOMAIN } = require('../../../../config/globals'); - -/** - * - * @param {object} hook - The hook of the server-request, containing req.params.query.roles as role-filter - * @returns {Promise } - */ -const mapRoleFilterQuery = (hook) => { - if (hook.params.query.roles) { - const rolesFilter = hook.params.query.roles; - hook.params.query.roles = {}; - hook.params.query.roles.$in = rolesFilter; - } - - return Promise.resolve(hook); -}; -const getProtectedRoles = (hook) => - hook.app.service('/roles').find({ - // load protected roles - query: { - // TODO: cache these - name: ['teacher', 'admin'], - }, - }); - -const checkUnique = (hook) => { - const userService = hook.service; - const { email } = hook.data; - if (email === undefined) { - return Promise.reject(new BadRequest('Fehler beim Auslesen der E-Mail-Adresse bei der Nutzererstellung.')); - } - return userService.find({ query: { email: email.toLowerCase() } }).then((result) => { - const { length } = result.data; - if (length === undefined || length >= 2) { - return Promise.reject(new BadRequest('Fehler beim Prüfen der Datenbankinformationen.')); - } - if (length === 0) { - return Promise.resolve(hook); - } - - const user = typeof result.data[0] === 'object' ? result.data[0] : {}; - const input = typeof hook.data === 'object' ? hook.data : {}; - const isLoggedIn = (hook.params || {}).account && hook.params.account.userId; - // eslint-disable-next-line no-underscore-dangle - const { asTask } = hook.params._additional || {}; - - if (isLoggedIn || asTask === undefined || asTask === 'student') { - return Promise.reject(new BadRequest(`Die E-Mail Adresse ist bereits in Verwendung!`)); - } - return Promise.resolve(hook); - }); -}; - -const checkUniqueEmail = async (hook) => { - const { email } = hook.data; - if (!email) { - // there is no email address given. Nothing to check... - return Promise.resolve(hook); - } - - // get userId of user entry to edit - const editUserId = hook.id ? hook.id.toString() : undefined; - const unique = await hook.app.service('nest-account-validation-service').isUniqueEmailForUser(email, editUserId); - - if (unique) { - return hook; - } - throw new BadRequest(`Die E-Mail Adresse ist bereits in Verwendung!`); -}; - -const checkUniqueAccount = (hook) => { - const { email } = hook.data; - return hook.app - .service('nest-account-service') - .searchByUsernameExactMatch(email.toLowerCase()) - .then(([result]) => { - if (result.length > 0) { - throw new BadRequest(`Ein Account mit dieser E-Mail Adresse ${email} existiert bereits!`); - } - return hook; - }); -}; - -const updateAccountUsername = async (context) => { - let { - params: { account }, - } = context; - const { - data: { email }, - app, - } = context; - - if (!email) { - return context; - } - - if (!context.id) { - throw new BadRequest('Id is required for email changes'); - } - - if (!account || !ObjectId.equal(context.id, account.userId)) { - account = await app.service('nest-account-service').findByUserId(context.id); - - if (!account) return context; - } - - if (email && account.systemId) { - delete context.data.email; - return context; - } - - await app - .service('nest-account-service') - .updateUsername(account.id ? account.id : account._id.toString(), email) - .catch((err) => { - throw new BadRequest('Can not update account username.', err); - }); - return context; -}; - -const removeStudentFromClasses = async (hook) => { - // todo: move this functionality into classes, using events. - // todo: what about teachers? - const classesService = hook.app.service('/classes'); - const userIds = hook.id || (hook.result || []).map((u) => u._id); - if (userIds === undefined) { - throw new BadRequest( - 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.' - ); - } - - try { - const usersClasses = await classesService.find({ query: { userIds: { $in: userIds } } }); - await Promise.all( - usersClasses.data.map((klass) => classesService.patch(klass._id, { $pull: { userIds: { $in: userIds } } })) - ); - } catch (err) { - throw new Forbidden( - 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.', - err - ); - } - - return hook; -}; - -const removeStudentFromCourses = async (hook) => { - // todo: move this functionality into courses, using events. - // todo: what about teachers? - const coursesService = hook.app.service('/courses'); - const userIds = hook.id || (hook.result || []).map((u) => u._id); - if (userIds === undefined) { - throw new BadRequest( - 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.' - ); - } - - try { - const usersCourses = await coursesService.find({ query: { userIds: { $in: userIds } } }); - await Promise.all( - usersCourses.data.map((course) => - hook.app.service('courseModel').patch(course._id, { $pull: { userIds: { $in: userIds } } }) - ) - ); - } catch (err) { - throw new Forbidden( - 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.', - err - ); - } -}; - -const sanitizeData = (hook) => { - if ('email' in hook.data) { - if (!constants.expressions.email.test(hook.data.email)) { - return Promise.reject(new BadRequest('Bitte gib eine valide E-Mail Adresse an!')); - } - } - const idRegExp = RegExp('^[0-9a-fA-F]{24}$'); - if ('schoolId' in hook.data) { - if (!idRegExp.test(hook.data.schoolId)) { - return Promise.reject(new BadRequest('invalid Id')); - } - } - if ('classId' in hook.data) { - if (!idRegExp.test(hook.data.classId)) { - return Promise.reject(new BadRequest('invalid Id')); - } - } - return Promise.resolve(hook); -}; - -const checkJwt = () => - function checkJwtfnc(hook) { - if (((hook.params || {}).headers || {}).authorization !== undefined) { - return authenticate('jwt').call(this, hook); - } - return Promise.resolve(hook); - }; - -const pinIsVerified = (hook) => { - if ((hook.params || {}).account && hook.params.account.userId) { - return hasPermission(['STUDENT_CREATE', 'TEACHER_CREATE', 'ADMIN_CREATE']).call(this, hook); - } - // eslint-disable-next-line no-underscore-dangle - const email = (hook.params._additional || {}).parentEmail || hook.data.email; - return hook.app - .service('/registrationPins') - .find({ query: { email, verified: true } }) - .then((pins) => { - if (pins.data.length === 1 && pins.data[0].pin) { - const age = getAge(hook.data.birthday); - - if (!((hook.data.roles || []).includes('student') && age < CONSENT_WITHOUT_PARENTS_MIN_AGE_YEARS)) { - hook.app.service('/registrationPins').remove(pins.data[0]._id); - } - - return Promise.resolve(hook); - } - return Promise.reject(new BadRequest('Der Pin wurde noch nicht bei der Registrierung eingetragen.')); - }); -}; - -const protectImmutableAttributes = async (context) => { - const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); - if (userIsSuperhero) return context; - - delete context.data.roles; - delete (context.data.$push || {}).roles; - delete (context.data.$pull || {}).roles; - delete (context.data.$pop || {}).roles; - delete (context.data.$addToSet || {}).roles; - delete (context.data.$pullAll || {}).roles; - delete (context.data.$set || {}).roles; - - delete context.data.schoolId; - delete (context.data.$set || {}).schoolId; - - return context; -}; - -const securePatching = async (context) => { - const targetUser = await context.app.service('users').get(context.id, { query: { $populate: 'roles' } }); - const actingUser = await context.app - .service('users') - .get(context.params.account.userId, { query: { $populate: 'roles' } }); - const isSuperHero = actingUser.roles.find((r) => r.name === 'superhero'); - const isAdmin = actingUser.roles.find((r) => r.name === 'administrator'); - const isTeacher = actingUser.roles.find((r) => r.name === 'teacher'); - const targetIsStudent = targetUser.roles.find((r) => r.name === 'student'); - - if (isSuperHero) { - return context; - } - - if (!ObjectId.equal(targetUser.schoolId, actingUser.schoolId)) { - return Promise.reject(new NotFound(`no record found for id '${context.id.toString()}'`)); - } - - if (!ObjectId.equal(context.id, context.params.account.userId)) { - if (!(isAdmin || (isTeacher && targetIsStudent))) { - return Promise.reject(new BadRequest('You have not the permissions to change other users')); - } - } - return Promise.resolve(context); -}; - -const formatLastName = (name, isOutdated) => `${name}${isOutdated ? ' ~~' : ''}`; - -/** - * - * @param user {object} - the user the display name has to be generated - * @param app {object} - the global feathers-app - * @returns {string} - a display name of the given user - */ -const getDisplayName = (user, protectedRoles) => { - const protectedRoleIds = (protectedRoles.data || []).map((role) => role._id); - const isProtectedUser = protectedRoleIds.find((role) => (user.roles || []).includes(role)); - - const isOutdated = !!user.outdatedSince; - - user.age = getAge(user.birthday); - - if (isProtectedUser) { - return user.lastName ? formatLastName(user.lastName, isOutdated) : user._id; - } - return user.lastName ? `${user.firstName} ${formatLastName(user.lastName, isOutdated)}` : user._id; -}; - -/** - * - * @param hook {object} - the hook of the server-request - * @returns {object} - the hook with the decorated user - */ -const decorateUser = async (hook) => { - const protectedRoles = await getProtectedRoles(hook); - const displayName = getDisplayName(hook.result, protectedRoles); - hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; - hook.result.displayName = displayName; - return hook; -}; - -/** - * - * @param user {object} - a user - * @returns {object} - a user with avatar info - */ -const setAvatarData = (user) => { - if (user.firstName && user.lastName) { - user.avatarInitials = user.firstName.charAt(0) + user.lastName.charAt(0); - } else { - user.avatarInitials = '?'; - } - // css readable value like "#ff0000" needed - const colors = ['#4a4e4d', '#0e9aa7', '#3da4ab', '#f6cd61', '#fe8a71']; - if (user.customAvatarBackgroundColor) { - user.avatarBackgroundColor = user.customAvatarBackgroundColor; - } else { - // choose colors based on initials - const index = (user.avatarInitials.charCodeAt(0) + user.avatarInitials.charCodeAt(1)) % colors.length; - user.avatarBackgroundColor = colors[index]; - } - return user; -}; - -/** - * - * @param hook {object} - the hook of the server-request - * @returns {object} - the hook with the decorated user avatar - */ -const decorateAvatar = (hook) => { - if (hook.result.total) { - hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; - (hook.result.data || []).forEach((user) => setAvatarData(user)); - } else { - // run and find with only one user - hook.result = setAvatarData(hook.result); - } - - return Promise.resolve(hook); -}; - -/** - * - * @param hook {object} - the hook of the server-request - * @returns {object} - the hook with the decorated users - */ -const decorateUsers = async (hook) => { - hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; - const protectedRoles = await getProtectedRoles(hook); - const users = (hook.result.data || []).map((user) => { - user.displayName = getDisplayName(user, protectedRoles); - return user; - }); - hook.result.data = users; - return hook; -}; - -const handleClassId = (hook) => { - if (!('classId' in hook.data)) { - return Promise.resolve(hook); - } - return hook.app - .service('/classes') - .patch(hook.data.classId, { - $push: { userIds: hook.result._id }, - }) - .then((res) => Promise.resolve(hook)); -}; - -const pushRemoveEvent = (hook) => { - hook.app.emit('users:after:remove', hook); - return hook; -}; - -const enforceRoleHierarchyOnDeleteSingle = async (context) => { - try { - const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); - if (userIsSuperhero) return context; - - const [targetIsStudent, targetIsTeacher, targetIsAdmin] = await Promise.all([ - hasRoleNoHook(context, context.id, 'student'), - hasRoleNoHook(context, context.id, 'teacher'), - hasRoleNoHook(context, context.id, 'administrator'), - ]); - let permissionChecks = [true]; - if (targetIsStudent) { - permissionChecks.push(hasPermissionNoHook(context, context.params.account.userId, 'STUDENT_DELETE')); - } - if (targetIsTeacher) { - permissionChecks.push(hasPermissionNoHook(context, context.params.account.userId, 'TEACHER_DELETE')); - } - if (targetIsAdmin) { - permissionChecks.push(hasRoleNoHook(context, context.params.account.userId, 'superhero')); - } - permissionChecks = await Promise.all(permissionChecks); - - if (!permissionChecks.reduce((accumulator, val) => accumulator && val)) { - throw new Forbidden('you dont have permission to delete this user!'); - } - - return context; - } catch (error) { - logger.error(error); - throw new Forbidden('you dont have permission to delete this user!'); - } -}; - -const enforceRoleHierarchyOnDeleteBulk = async (context) => { - const user = await context.app.service('users').get(context.params.account.userId); - const canDeleteStudent = user.permissions.includes('STUDENT_DELETE'); - const canDeleteTeacher = user.permissions.includes('TEACHER_DELETE'); - const rolePromises = []; - if (canDeleteStudent) { - rolePromises.push( - context.app - .service('roles') - .find({ query: { name: 'student' } }) - .then((r) => r.data[0]._id) - ); - } - if (canDeleteTeacher) { - rolePromises.push( - context.app - .service('roles') - .find({ query: { name: 'teacher' } }) - .then((r) => r.data[0]._id) - ); - } - const allowedRoles = await Promise.all(rolePromises); - - // there may not be any role in user.roles that is not in rolesToDelete - const roleQuery = { $nor: [{ roles: { $elemMatch: { $nin: allowedRoles } } }] }; - context.params.query = { $and: [context.params.query, roleQuery] }; - return context; -}; - -const enforceRoleHierarchyOnDelete = async (context) => { - if (context.id) return enforceRoleHierarchyOnDeleteSingle(context); - return enforceRoleHierarchyOnDeleteBulk(context); -}; - -/** - * Check that the authenticated user posseses the rights to create a user with the given roles. - * This is only checked for external requests. - * @param {*} context - */ -const enforceRoleHierarchyOnCreate = async (context) => { - const user = await context.app.service('users').get(context.params.account.userId, { query: { $populate: 'roles' } }); - - // superhero may create users with every role - if (user.roles.filter((u) => u.name === 'superhero').length > 0) { - return Promise.resolve(context); - } - - // created user has no role - if (!context.data || !context.data.roles) { - return Promise.resolve(context); - } - await Promise.all( - context.data.roles.map(async (roleId) => { - // Roles are given by ID or by name. - // For IDs we load the name from the DB. - // If it is not an ID we assume, it is a name. Invalid names are rejected in the switch anyways. - let roleName = ''; - if (!ObjectId.isValid(roleId)) { - roleName = roleId; - } else { - try { - const role = await context.app.service('roles').get(roleId); - roleName = role.name; - } catch (exception) { - return Promise.reject(new BadRequest('No such role exists')); - } - } - switch (roleName) { - case 'teacher': - if (!user.permissions.find((permission) => permission === 'TEACHER_CREATE')) { - return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); - } - break; - case 'student': - if (!user.permissions.find((permission) => permission === 'STUDENT_CREATE')) { - return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); - } - break; - case 'parent': - break; - default: - return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); - } - return Promise.resolve(context); - }) - ); - - return Promise.resolve(context); -}; - -const generateRegistrationLink = async (context) => { - const { data, app } = context; - if (data.generateRegistrationLink === true) { - delete data.generateRegistrationLink; - if (!data.roles || data.roles.length > 1) { - throw new BadRequest('Roles must be exactly of length one if generateRegistrationLink=true is set.'); - } - const { hash } = await app - .service('/registrationlink') - // set account in params to context.parmas.account to reference the current user - .create({ - role: data.roles[0], - save: true, - patchUser: true, - host: SC_DOMAIN, - schoolId: data.schoolId, - toHash: data.email, - }) - .catch((err) => { - throw new GeneralError(`Can not create registrationlink. ${err}`); - }); - context.data.importHash = hash; - } -}; - -const sendRegistrationLink = async (context) => { - const { result, data, app } = context; - if (data.sendRegistration === true) { - delete data.sendRegistration; - await app.service('/users/mail/registrationLink').create({ - users: [result], - }); - } - return context; -}; - -const filterResult = async (context) => { - const userCallingHimself = context.id && ObjectId.equal(context.id, context.params.account.userId); - const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); - if (userCallingHimself || userIsSuperhero) { - return context; - } - - const allowedAttributes = [ - '_id', - 'roles', - 'schoolId', - 'firstName', - 'middleName', - 'lastName', - 'namePrefix', - 'nameSuffix', - 'discoverable', - 'fullName', - 'displayName', - 'avatarInitials', - 'avatarBackgroundColor', - 'outdatedSince', - ]; - return keep(...allowedAttributes)(context); -}; - -let roleCache = null; -const includeOnlySchoolRoles = async (context) => { - if (context.params && context.params.query) { - const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); - if (userIsSuperhero) { - return context; - } - - // todo: remove with static role service (SC-3731) - if (!Array.isArray(roleCache)) { - roleCache = ( - await context.app.service('roles').find({ - query: { - name: { $in: ['administrator', 'teacher', 'student'] }, - }, - paginate: false, - }) - ).map((r) => r._id); - } - const allowedRoles = roleCache; - - if (context.params.query.roles && context.params.query.roles.$in) { - // when querying for specific roles, filter them - context.params.query.roles.$in = context.params.query.roles.$in.filter((r) => - allowedRoles.some((a) => ObjectId.equal(r, a)) - ); - } else { - // otherwise, overwrite them with whitelist - context.params.query.roles = { - $in: allowedRoles, - }; - } - } - return context; -}; - -module.exports = { - mapRoleFilterQuery, - checkUnique, - checkUniqueEmail, - checkJwt, - checkUniqueAccount, - updateAccountUsername, - removeStudentFromClasses, - removeStudentFromCourses, - sanitizeData, - pinIsVerified, - protectImmutableAttributes, - securePatching, - decorateUser, - decorateAvatar, - decorateUsers, - handleClassId, - pushRemoveEvent, - enforceRoleHierarchyOnDelete, - enforceRoleHierarchyOnCreate, - filterResult, - generateRegistrationLink, - sendRegistrationLink, - includeOnlySchoolRoles, -}; +const { authenticate } = require('@feathersjs/authentication'); +const { keep } = require('feathers-hooks-common'); + +const { Forbidden, NotFound, BadRequest, GeneralError } = require('../../../errors'); +const logger = require('../../../logger'); +const { ObjectId } = require('../../../helper/compare'); +const { hasRoleNoHook, hasPermissionNoHook, hasPermission } = require('../../../hooks'); + +const { getAge } = require('../../../utils'); + +const constants = require('../../../utils/constants'); +const { CONSENT_WITHOUT_PARENTS_MIN_AGE_YEARS, SC_DOMAIN } = require('../../../../config/globals'); + +/** + * + * @param {object} hook - The hook of the server-request, containing req.params.query.roles as role-filter + * @returns {Promise } + */ +const mapRoleFilterQuery = (hook) => { + if (hook.params.query.roles) { + const rolesFilter = hook.params.query.roles; + hook.params.query.roles = {}; + hook.params.query.roles.$in = rolesFilter; + } + + return Promise.resolve(hook); +}; +const getProtectedRoles = (hook) => + hook.app.service('/roles').find({ + // load protected roles + query: { + // TODO: cache these + name: ['teacher', 'admin'], + }, + }); + +const checkUnique = (hook) => { + const userService = hook.service; + const { email } = hook.data; + if (email === undefined) { + return Promise.reject(new BadRequest('Fehler beim Auslesen der E-Mail-Adresse bei der Nutzererstellung.')); + } + return userService.find({ query: { email: email.toLowerCase() } }).then((result) => { + const { length } = result.data; + if (length === undefined || length >= 2) { + return Promise.reject(new BadRequest('Fehler beim Prüfen der Datenbankinformationen.')); + } + if (length === 0) { + return Promise.resolve(hook); + } + + const user = typeof result.data[0] === 'object' ? result.data[0] : {}; + const input = typeof hook.data === 'object' ? hook.data : {}; + const isLoggedIn = (hook.params || {}).account && hook.params.account.userId; + // eslint-disable-next-line no-underscore-dangle + const { asTask } = hook.params._additional || {}; + + if (isLoggedIn || asTask === undefined || asTask === 'student') { + return Promise.reject(new BadRequest(`Die E-Mail Adresse ist bereits in Verwendung!`)); + } + return Promise.resolve(hook); + }); +}; + +const checkUniqueEmail = async (hook) => { + const { email } = hook.data; + if (!email) { + // there is no email address given. Nothing to check... + return Promise.resolve(hook); + } + + // get userId of user entry to edit + const editUserId = hook.id ? hook.id.toString() : undefined; + const unique = await hook.app.service('nest-account-service').isUniqueEmailForUser(email, editUserId); + + if (unique) { + return hook; + } + throw new BadRequest(`Die E-Mail Adresse ist bereits in Verwendung!`); +}; + +const checkUniqueAccount = (hook) => { + const { email } = hook.data; + return hook.app + .service('nest-account-service') + .searchByUsernameExactMatch(email.toLowerCase()) + .then(([result]) => { + if (result.length > 0) { + throw new BadRequest(`Ein Account mit dieser E-Mail Adresse ${email} existiert bereits!`); + } + return hook; + }); +}; + +const updateAccountUsername = async (context) => { + let { + params: { account }, + } = context; + const { + data: { email }, + app, + } = context; + + if (!email) { + return context; + } + + if (!context.id) { + throw new BadRequest('Id is required for email changes'); + } + + if (!account || !ObjectId.equal(context.id, account.userId)) { + account = await app.service('nest-account-service').findByUserId(context.id); + + if (!account) return context; + } + + if (email && account.systemId) { + delete context.data.email; + return context; + } + + await app + .service('nest-account-service') + .updateUsername(account.id ? account.id : account._id.toString(), email) + .catch((err) => { + throw new BadRequest('Can not update account username.', err); + }); + return context; +}; + +const removeStudentFromClasses = async (hook) => { + // todo: move this functionality into classes, using events. + // todo: what about teachers? + const classesService = hook.app.service('/classes'); + const userIds = hook.id || (hook.result || []).map((u) => u._id); + if (userIds === undefined) { + throw new BadRequest( + 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.' + ); + } + + try { + const usersClasses = await classesService.find({ query: { userIds: { $in: userIds } } }); + await Promise.all( + usersClasses.data.map((klass) => classesService.patch(klass._id, { $pull: { userIds: { $in: userIds } } })) + ); + } catch (err) { + throw new Forbidden( + 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.', + err + ); + } + + return hook; +}; + +const removeStudentFromCourses = async (hook) => { + // todo: move this functionality into courses, using events. + // todo: what about teachers? + const coursesService = hook.app.service('/courses'); + const userIds = hook.id || (hook.result || []).map((u) => u._id); + if (userIds === undefined) { + throw new BadRequest( + 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.' + ); + } + + try { + const usersCourses = await coursesService.find({ query: { userIds: { $in: userIds } } }); + await Promise.all( + usersCourses.data.map((course) => + hook.app.service('courseModel').patch(course._id, { $pull: { userIds: { $in: userIds } } }) + ) + ); + } catch (err) { + throw new Forbidden( + 'Der Nutzer wurde gelöscht, konnte aber eventuell nicht aus allen Klassen/Kursen entfernt werden.', + err + ); + } +}; + +const sanitizeData = (hook) => { + if ('email' in hook.data) { + if (!constants.expressions.email.test(hook.data.email)) { + return Promise.reject(new BadRequest('Bitte gib eine valide E-Mail Adresse an!')); + } + } + const idRegExp = RegExp('^[0-9a-fA-F]{24}$'); + if ('schoolId' in hook.data) { + if (!idRegExp.test(hook.data.schoolId)) { + return Promise.reject(new BadRequest('invalid Id')); + } + } + if ('classId' in hook.data) { + if (!idRegExp.test(hook.data.classId)) { + return Promise.reject(new BadRequest('invalid Id')); + } + } + return Promise.resolve(hook); +}; + +const checkJwt = () => + function checkJwtfnc(hook) { + if (((hook.params || {}).headers || {}).authorization !== undefined) { + return authenticate('jwt').call(this, hook); + } + return Promise.resolve(hook); + }; + +const pinIsVerified = (hook) => { + if ((hook.params || {}).account && hook.params.account.userId) { + return hasPermission(['STUDENT_CREATE', 'TEACHER_CREATE', 'ADMIN_CREATE']).call(this, hook); + } + // eslint-disable-next-line no-underscore-dangle + const email = (hook.params._additional || {}).parentEmail || hook.data.email; + return hook.app + .service('/registrationPins') + .find({ query: { email, verified: true } }) + .then((pins) => { + if (pins.data.length === 1 && pins.data[0].pin) { + const age = getAge(hook.data.birthday); + + if (!((hook.data.roles || []).includes('student') && age < CONSENT_WITHOUT_PARENTS_MIN_AGE_YEARS)) { + hook.app.service('/registrationPins').remove(pins.data[0]._id); + } + + return Promise.resolve(hook); + } + return Promise.reject(new BadRequest('Der Pin wurde noch nicht bei der Registrierung eingetragen.')); + }); +}; + +const protectImmutableAttributes = async (context) => { + const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); + if (userIsSuperhero) return context; + + delete context.data.roles; + delete (context.data.$push || {}).roles; + delete (context.data.$pull || {}).roles; + delete (context.data.$pop || {}).roles; + delete (context.data.$addToSet || {}).roles; + delete (context.data.$pullAll || {}).roles; + delete (context.data.$set || {}).roles; + + delete context.data.schoolId; + delete (context.data.$set || {}).schoolId; + + return context; +}; + +const securePatching = async (context) => { + const targetUser = await context.app.service('users').get(context.id, { query: { $populate: 'roles' } }); + const actingUser = await context.app + .service('users') + .get(context.params.account.userId, { query: { $populate: 'roles' } }); + const isSuperHero = actingUser.roles.find((r) => r.name === 'superhero'); + const isAdmin = actingUser.roles.find((r) => r.name === 'administrator'); + const isTeacher = actingUser.roles.find((r) => r.name === 'teacher'); + const targetIsStudent = targetUser.roles.find((r) => r.name === 'student'); + + if (isSuperHero) { + return context; + } + + if (!ObjectId.equal(targetUser.schoolId, actingUser.schoolId)) { + return Promise.reject(new NotFound(`no record found for id '${context.id.toString()}'`)); + } + + if (!ObjectId.equal(context.id, context.params.account.userId)) { + if (!(isAdmin || (isTeacher && targetIsStudent))) { + return Promise.reject(new BadRequest('You have not the permissions to change other users')); + } + } + return Promise.resolve(context); +}; + +const formatLastName = (name, isOutdated) => `${name}${isOutdated ? ' ~~' : ''}`; + +/** + * + * @param user {object} - the user the display name has to be generated + * @param app {object} - the global feathers-app + * @returns {string} - a display name of the given user + */ +const getDisplayName = (user, protectedRoles) => { + const protectedRoleIds = (protectedRoles.data || []).map((role) => role._id); + const isProtectedUser = protectedRoleIds.find((role) => (user.roles || []).includes(role)); + + const isOutdated = !!user.outdatedSince; + + user.age = getAge(user.birthday); + + if (isProtectedUser) { + return user.lastName ? formatLastName(user.lastName, isOutdated) : user._id; + } + return user.lastName ? `${user.firstName} ${formatLastName(user.lastName, isOutdated)}` : user._id; +}; + +/** + * + * @param hook {object} - the hook of the server-request + * @returns {object} - the hook with the decorated user + */ +const decorateUser = async (hook) => { + const protectedRoles = await getProtectedRoles(hook); + const displayName = getDisplayName(hook.result, protectedRoles); + hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; + hook.result.displayName = displayName; + return hook; +}; + +/** + * + * @param user {object} - a user + * @returns {object} - a user with avatar info + */ +const setAvatarData = (user) => { + if (user.firstName && user.lastName) { + user.avatarInitials = user.firstName.charAt(0) + user.lastName.charAt(0); + } else { + user.avatarInitials = '?'; + } + // css readable value like "#ff0000" needed + const colors = ['#4a4e4d', '#0e9aa7', '#3da4ab', '#f6cd61', '#fe8a71']; + if (user.customAvatarBackgroundColor) { + user.avatarBackgroundColor = user.customAvatarBackgroundColor; + } else { + // choose colors based on initials + const index = (user.avatarInitials.charCodeAt(0) + user.avatarInitials.charCodeAt(1)) % colors.length; + user.avatarBackgroundColor = colors[index]; + } + return user; +}; + +/** + * + * @param hook {object} - the hook of the server-request + * @returns {object} - the hook with the decorated user avatar + */ +const decorateAvatar = (hook) => { + if (hook.result.total) { + hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; + (hook.result.data || []).forEach((user) => setAvatarData(user)); + } else { + // run and find with only one user + hook.result = setAvatarData(hook.result); + } + + return Promise.resolve(hook); +}; + +/** + * + * @param hook {object} - the hook of the server-request + * @returns {object} - the hook with the decorated users + */ +const decorateUsers = async (hook) => { + hook.result = hook.result.constructor.name === 'model' ? hook.result.toObject() : hook.result; + const protectedRoles = await getProtectedRoles(hook); + const users = (hook.result.data || []).map((user) => { + user.displayName = getDisplayName(user, protectedRoles); + return user; + }); + hook.result.data = users; + return hook; +}; + +const handleClassId = (hook) => { + if (!('classId' in hook.data)) { + return Promise.resolve(hook); + } + return hook.app + .service('/classes') + .patch(hook.data.classId, { + $push: { userIds: hook.result._id }, + }) + .then((res) => Promise.resolve(hook)); +}; + +const pushRemoveEvent = (hook) => { + hook.app.emit('users:after:remove', hook); + return hook; +}; + +const enforceRoleHierarchyOnDeleteSingle = async (context) => { + try { + const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); + if (userIsSuperhero) return context; + + const [targetIsStudent, targetIsTeacher, targetIsAdmin] = await Promise.all([ + hasRoleNoHook(context, context.id, 'student'), + hasRoleNoHook(context, context.id, 'teacher'), + hasRoleNoHook(context, context.id, 'administrator'), + ]); + let permissionChecks = [true]; + if (targetIsStudent) { + permissionChecks.push(hasPermissionNoHook(context, context.params.account.userId, 'STUDENT_DELETE')); + } + if (targetIsTeacher) { + permissionChecks.push(hasPermissionNoHook(context, context.params.account.userId, 'TEACHER_DELETE')); + } + if (targetIsAdmin) { + permissionChecks.push(hasRoleNoHook(context, context.params.account.userId, 'superhero')); + } + permissionChecks = await Promise.all(permissionChecks); + + if (!permissionChecks.reduce((accumulator, val) => accumulator && val)) { + throw new Forbidden('you dont have permission to delete this user!'); + } + + return context; + } catch (error) { + logger.error(error); + throw new Forbidden('you dont have permission to delete this user!'); + } +}; + +const enforceRoleHierarchyOnDeleteBulk = async (context) => { + const user = await context.app.service('users').get(context.params.account.userId); + const canDeleteStudent = user.permissions.includes('STUDENT_DELETE'); + const canDeleteTeacher = user.permissions.includes('TEACHER_DELETE'); + const rolePromises = []; + if (canDeleteStudent) { + rolePromises.push( + context.app + .service('roles') + .find({ query: { name: 'student' } }) + .then((r) => r.data[0]._id) + ); + } + if (canDeleteTeacher) { + rolePromises.push( + context.app + .service('roles') + .find({ query: { name: 'teacher' } }) + .then((r) => r.data[0]._id) + ); + } + const allowedRoles = await Promise.all(rolePromises); + + // there may not be any role in user.roles that is not in rolesToDelete + const roleQuery = { $nor: [{ roles: { $elemMatch: { $nin: allowedRoles } } }] }; + context.params.query = { $and: [context.params.query, roleQuery] }; + return context; +}; + +const enforceRoleHierarchyOnDelete = async (context) => { + if (context.id) return enforceRoleHierarchyOnDeleteSingle(context); + return enforceRoleHierarchyOnDeleteBulk(context); +}; + +/** + * Check that the authenticated user posseses the rights to create a user with the given roles. + * This is only checked for external requests. + * @param {*} context + */ +const enforceRoleHierarchyOnCreate = async (context) => { + const user = await context.app.service('users').get(context.params.account.userId, { query: { $populate: 'roles' } }); + + // superhero may create users with every role + if (user.roles.filter((u) => u.name === 'superhero').length > 0) { + return Promise.resolve(context); + } + + // created user has no role + if (!context.data || !context.data.roles) { + return Promise.resolve(context); + } + await Promise.all( + context.data.roles.map(async (roleId) => { + // Roles are given by ID or by name. + // For IDs we load the name from the DB. + // If it is not an ID we assume, it is a name. Invalid names are rejected in the switch anyways. + let roleName = ''; + if (!ObjectId.isValid(roleId)) { + roleName = roleId; + } else { + try { + const role = await context.app.service('roles').get(roleId); + roleName = role.name; + } catch (exception) { + return Promise.reject(new BadRequest('No such role exists')); + } + } + switch (roleName) { + case 'teacher': + if (!user.permissions.find((permission) => permission === 'TEACHER_CREATE')) { + return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); + } + break; + case 'student': + if (!user.permissions.find((permission) => permission === 'STUDENT_CREATE')) { + return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); + } + break; + case 'parent': + break; + default: + return Promise.reject(new BadRequest('Your are not allowed to create a user with the given role')); + } + return Promise.resolve(context); + }) + ); + + return Promise.resolve(context); +}; + +const generateRegistrationLink = async (context) => { + const { data, app } = context; + if (data.generateRegistrationLink === true) { + delete data.generateRegistrationLink; + if (!data.roles || data.roles.length > 1) { + throw new BadRequest('Roles must be exactly of length one if generateRegistrationLink=true is set.'); + } + const { hash } = await app + .service('/registrationlink') + // set account in params to context.parmas.account to reference the current user + .create({ + role: data.roles[0], + save: true, + patchUser: true, + host: SC_DOMAIN, + schoolId: data.schoolId, + toHash: data.email, + }) + .catch((err) => { + throw new GeneralError(`Can not create registrationlink. ${err}`); + }); + context.data.importHash = hash; + } +}; + +const sendRegistrationLink = async (context) => { + const { result, data, app } = context; + if (data.sendRegistration === true) { + delete data.sendRegistration; + await app.service('/users/mail/registrationLink').create({ + users: [result], + }); + } + return context; +}; + +const filterResult = async (context) => { + const userCallingHimself = context.id && ObjectId.equal(context.id, context.params.account.userId); + const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); + if (userCallingHimself || userIsSuperhero) { + return context; + } + + const allowedAttributes = [ + '_id', + 'roles', + 'schoolId', + 'firstName', + 'middleName', + 'lastName', + 'namePrefix', + 'nameSuffix', + 'discoverable', + 'fullName', + 'displayName', + 'avatarInitials', + 'avatarBackgroundColor', + 'outdatedSince', + ]; + return keep(...allowedAttributes)(context); +}; + +let roleCache = null; +const includeOnlySchoolRoles = async (context) => { + if (context.params && context.params.query) { + const userIsSuperhero = await hasRoleNoHook(context, context.params.account.userId, 'superhero'); + if (userIsSuperhero) { + return context; + } + + // todo: remove with static role service (SC-3731) + if (!Array.isArray(roleCache)) { + roleCache = ( + await context.app.service('roles').find({ + query: { + name: { $in: ['administrator', 'teacher', 'student'] }, + }, + paginate: false, + }) + ).map((r) => r._id); + } + const allowedRoles = roleCache; + + if (context.params.query.roles && context.params.query.roles.$in) { + // when querying for specific roles, filter them + context.params.query.roles.$in = context.params.query.roles.$in.filter((r) => + allowedRoles.some((a) => ObjectId.equal(r, a)) + ); + } else { + // otherwise, overwrite them with whitelist + context.params.query.roles = { + $in: allowedRoles, + }; + } + } + return context; +}; + +module.exports = { + mapRoleFilterQuery, + checkUnique, + checkUniqueEmail, + checkJwt, + checkUniqueAccount, + updateAccountUsername, + removeStudentFromClasses, + removeStudentFromCourses, + sanitizeData, + pinIsVerified, + protectImmutableAttributes, + securePatching, + decorateUser, + decorateAvatar, + decorateUsers, + handleClassId, + pushRemoveEvent, + enforceRoleHierarchyOnDelete, + enforceRoleHierarchyOnCreate, + filterResult, + generateRegistrationLink, + sendRegistrationLink, + includeOnlySchoolRoles, +}; diff --git a/test/utils/setup.nest.services.js b/test/utils/setup.nest.services.js index 3b1fedd050b..377a700b20b 100644 --- a/test/utils/setup.nest.services.js +++ b/test/utils/setup.nest.services.js @@ -8,9 +8,6 @@ const { ConfigModule } = require('@nestjs/config'); const { AccountApiModule } = require('../../dist/apps/server/modules/account/account-api.module'); const { AccountUc } = require('../../dist/apps/server/modules/account/api/account.uc'); const { AccountService } = require('../../dist/apps/server/modules/account/domain/services/account.service'); -const { - AccountValidationService, -} = require('../../dist/apps/server/modules/account/domain/services/account.validation.service'); const { DB_PASSWORD, DB_URL, DB_USERNAME } = require('../../dist/apps/server/config/database.config'); const { ALL_ENTITIES } = require('../../dist/apps/server/shared/domain/entity/all-entities'); const { TeamService } = require('../../dist/apps/server/modules/teams/service/team.service'); @@ -42,13 +39,11 @@ const setupNestServices = async (app) => { const orm = nestApp.get(MikroORM); const accountUc = nestApp.get(AccountUc); const accountService = nestApp.get(AccountService); - const accountValidationService = nestApp.get(AccountValidationService); const teamService = nestApp.get(TeamService); const systemRule = nestApp.get(SystemRule); app.services['nest-account-uc'] = accountUc; app.services['nest-account-service'] = accountService; - app.services['nest-account-validation-service'] = accountValidationService; app.services['nest-team-service'] = teamService; app.services['nest-system-rule'] = systemRule; app.services['nest-orm'] = orm;