diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts b/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts index e7d5e6b23b6..709f4a1ea71 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts @@ -1,4 +1,5 @@ export interface SchulconnexClientConfig { + SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS: number; SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: number; SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS: number; SCHULCONNEX_CLIENT__API_URL?: string; diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts b/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts index b16a7f55458..bff42d9bbdb 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts @@ -8,7 +8,7 @@ import { SchulconnexRestClientOptions } from './schulconnex-rest-client-options' @Module({}) export class SchulconnexClientModule { - static registerAsync(): DynamicModule { + public static registerAsync(): DynamicModule { return { imports: [HttpModule, LoggerModule], module: SchulconnexClientModule, @@ -27,6 +27,7 @@ export class SchulconnexClientModule { tokenEndpoint: configService.get('SCHULCONNEX_CLIENT__TOKEN_ENDPOINT'), clientId: configService.get('SCHULCONNEX_CLIENT__CLIENT_ID'), clientSecret: configService.get('SCHULCONNEX_CLIENT__CLIENT_SECRET'), + personInfoTimeoutInMs: configService.get('SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS'), personenInfoTimeoutInMs: configService.get('SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS'), policiesInfoTimeoutInMs: configService.get('SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS'), }; diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts index 01391ec207e..5316df7e74a 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts @@ -7,6 +7,8 @@ export interface SchulconnexRestClientOptions { clientSecret?: string; + personInfoTimeoutInMs?: number; + personenInfoTimeoutInMs?: number; policiesInfoTimeoutInMs?: number; diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts index 5af753d8554..49ad5e2fa29 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts @@ -25,8 +25,9 @@ describe(SchulconnexRestClient.name, () => { clientId: 'clientId', clientSecret: 'clientSecret', tokenEndpoint: 'https://schulconnex.url/token', - personenInfoTimeoutInMs: 30000, - policiesInfoTimeoutInMs: 30000, + personInfoTimeoutInMs: 30001, + personenInfoTimeoutInMs: 30002, + policiesInfoTimeoutInMs: 30003, }; beforeAll(() => { @@ -100,6 +101,7 @@ describe(SchulconnexRestClient.name, () => { Authorization: `Bearer ${accessToken}`, 'Accept-Encoding': 'gzip', }, + timeout: options.personInfoTimeoutInMs, }); }); diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts index 820668c16ce..d9a3b829cd3 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts @@ -30,10 +30,14 @@ export class SchulconnexRestClient implements SchulconnexApiInterface { this.SCHULCONNEX_API_BASE_URL = options.apiUrl || ''; } - public async getPersonInfo(accessToken: string, options?: { overrideUrl: string }): Promise { + public getPersonInfo(accessToken: string, options?: { overrideUrl: string }): Promise { const url: URL = new URL(options?.overrideUrl ?? `${this.SCHULCONNEX_API_BASE_URL}/person-info`); - const response: Promise = this.getRequest(url, accessToken); + const response: Promise = this.getRequest( + url, + accessToken, + this.options.personInfoTimeoutInMs + ); return response; } diff --git a/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts b/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts index d233b984596..4bfb49bbbe2 100644 --- a/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts +++ b/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts @@ -46,7 +46,7 @@ export class Migration20241113100535 extends Migration { ); if (teacherRoleUpdate.modifiedCount > 0) { - console.info('Rollback: Permission ROOM_CREATE added to role teacher.'); + console.info('Rollback: Permission ROOM_CREATE removed from role teacher.'); } const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( @@ -61,7 +61,7 @@ export class Migration20241113100535 extends Migration { ); if (roomEditorRoleUpdate.modifiedCount > 0) { - console.info('Rollback: Permission ROOM_DELETE added to role roomeditor.'); + console.info('Rollback: Permission ROOM_DELETE removed from role roomeditor.'); } } } diff --git a/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts b/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts new file mode 100644 index 00000000000..ffa54bbc778 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241209165812 extends Migration { + async up(): Promise { + // Add ROOM_OWNER role + await this.getCollection('roles').insertOne({ + name: 'roomowner', + permissions: [ + 'ROOM_VIEW', + 'ROOM_EDIT', + 'ROOM_DELETE', + 'ROOM_MEMBERS_ADD', + 'ROOM_MEMBERS_REMOVE', + 'ROOM_CHANGE_OWNER', + ], + }); + console.info( + 'Added ROOM_OWNER role with ROOM_VIEW, -_EDIT, _DELETE, -_MEMBERS_ADD, -_MEMBERS_REMOVE AND -_CHANGE_OWNER permission' + ); + + // Add ROOM_ADMIN role + await this.getCollection('roles').insertOne({ + name: 'roomadmin', + permissions: ['ROOM_VIEW', 'ROOM_EDIT', 'ROOM_MEMBERS_ADD', 'ROOM_MEMBERS_REMOVE'], + }); + console.info( + 'Added ROOM_ADMIN role with ROOM_VIEW, ROOM_EDIT, ROOM_MEMBERS_ADD AND ROOM_MEMBERS_REMOVE permissions' + ); + } + + async down(): Promise { + // Remove ROOM_OWNER role + await this.getCollection('roles').deleteOne({ name: 'roomowner' }); + console.info('Rollback: Removed ROOM_OWNER role'); + + // Remove ROOM_ADMIN role + await this.getCollection('roles').deleteOne({ name: 'roomadmin' }); + console.info('Rollback: Removed ROOM_ADMIN role'); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts b/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts new file mode 100644 index 00000000000..4bd331b5057 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts @@ -0,0 +1,35 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241210152600 extends Migration { + async up(): Promise { + const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'roomeditor' }, + { + $set: { + permissions: ['ROOM_VIEW', 'ROOM_EDIT'], + }, + } + ); + + if (roomEditorRoleUpdate.modifiedCount > 0) { + console.info('Permission ROOM_DELETE removed from role roomeditor.'); + } + } + + async down(): Promise { + const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'roomeditor' }, + { + $set: { + permissions: ['ROOM_VIEW', 'ROOM_EDIT', 'ROOM_DELETE'], + }, + } + ); + + if (roomEditorRoleUpdate.modifiedCount > 0) { + console.info( + 'Rollback: Permissions ROOM_DELETE added to and ROOM_MEMBERS_ADD and ROOM_MEMBERS_REMOVE removed from role roomeditor.' + ); + } + } +} diff --git a/apps/server/src/modules/idp-console/idp-console.config.ts b/apps/server/src/modules/idp-console/idp-console.config.ts index 08a1e9fe301..30b14264858 100644 --- a/apps/server/src/modules/idp-console/idp-console.config.ts +++ b/apps/server/src/modules/idp-console/idp-console.config.ts @@ -1,12 +1,12 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; import { ConsoleWriterConfig } from '@infra/console'; -import { LoggerConfig } from '@src/core/logger'; +import { RabbitMqConfig } from '@infra/rabbitmq'; +import { SchulconnexClientConfig } from '@infra/schulconnex-client'; import { AccountConfig } from '@modules/account'; -import { UserConfig } from '@modules/user'; import { SynchronizationConfig } from '@modules/synchronization'; -import { SchulconnexClientConfig } from '@infra/schulconnex-client'; -import { Configuration } from '@hpi-schul-cloud/commons'; +import { UserConfig } from '@modules/user'; import { LanguageType } from '@shared/domain/interface'; -import { RabbitMqConfig } from '@infra/rabbitmq'; +import { LoggerConfig } from '@src/core/logger'; export interface IdpConsoleConfig extends ConsoleWriterConfig, @@ -33,6 +33,9 @@ const config: IdpConsoleConfig = { TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION: Configuration.get( 'TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION' ) as string, + SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS: Configuration.get( + 'SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS' + ) as number, SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: Configuration.get( 'SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS' ) as number, diff --git a/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.spec.ts new file mode 100644 index 00000000000..fb67f57ea03 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.spec.ts @@ -0,0 +1,38 @@ +import { externalGroupDtoFactory, externalGroupUserDtoFactory } from '../testing'; +import { GroupProvisioningInfoLoggable } from './group-provisioning-info.loggable'; + +describe(GroupProvisioningInfoLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const groupCount = 2; + const otherUserCount = 5; + const totalUserCount = groupCount * otherUserCount + groupCount; + const externalGroups = externalGroupDtoFactory.buildList(groupCount, { + otherUsers: externalGroupUserDtoFactory.buildList(otherUserCount), + }); + + const loggable = new GroupProvisioningInfoLoggable(externalGroups, 100); + + return { + loggable, + totalUserCount, + groupCount, + }; + }; + + it('should return a loggable message', () => { + const { loggable, totalUserCount, groupCount } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Group provisioning has finished.', + data: { + groupCount, + userCount: totalUserCount, + durationMs: 100, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.ts b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.ts new file mode 100644 index 00000000000..537a31e7855 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.ts @@ -0,0 +1,22 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ExternalGroupDto } from '../dto'; + +export class GroupProvisioningInfoLoggable implements Loggable { + constructor(private readonly groups: ExternalGroupDto[], private readonly durationMs: number) {} + + public getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const userCount = this.groups.reduce( + (count: number, group: ExternalGroupDto) => count + (group.otherUsers?.length ?? 0), + this.groups.length + ); + + return { + message: 'Group provisioning has finished.', + data: { + groupCount: this.groups.length, + userCount, + durationMs: this.durationMs, + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/loggable/index.ts b/apps/server/src/modules/provisioning/loggable/index.ts index 01e7c2ae5cd..93010e22353 100644 --- a/apps/server/src/modules/provisioning/loggable/index.ts +++ b/apps/server/src/modules/provisioning/loggable/index.ts @@ -8,3 +8,4 @@ export { FetchingPoliciesInfoFailedLoggable } from './fetching-policies-info-fai export { PoliciesInfoErrorResponseLoggable } from './policies-info-error-response-loggable'; export { UserRoleUnknownLoggableException } from './user-role-unknown.loggable-exception'; export { SchoolMissingLoggableException } from './school-missing.loggable-exception'; +export { GroupProvisioningInfoLoggable } from './group-provisioning-info.loggable'; diff --git a/apps/server/src/modules/provisioning/provisioning.config.ts b/apps/server/src/modules/provisioning/provisioning.config.ts index 0314bf8b277..9ba480fbcea 100644 --- a/apps/server/src/modules/provisioning/provisioning.config.ts +++ b/apps/server/src/modules/provisioning/provisioning.config.ts @@ -2,6 +2,7 @@ export interface ProvisioningConfig { FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED: boolean; FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED: boolean; PROVISIONING_SCHULCONNEX_POLICIES_INFO_URL: string; + PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT?: number; FEATURE_SANIS_GROUP_PROVISIONING_ENABLED: boolean; FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED: boolean; } diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts index bc57f6fee50..6a441c35909 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts @@ -46,9 +46,9 @@ export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { protected readonly schulconnexLicenseProvisioningService: SchulconnexLicenseProvisioningService, protected readonly schulconnexToolProvisioningService: SchulconnexToolProvisioningService, protected readonly configService: ConfigService, + protected readonly logger: Logger, private readonly responseMapper: SchulconnexResponseMapper, - private readonly schulconnexRestClient: SchulconnexRestClient, - private readonly logger: Logger + private readonly schulconnexRestClient: SchulconnexRestClient ) { super( schulconnexSchoolProvisioningService, @@ -58,7 +58,8 @@ export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { schulconnexLicenseProvisioningService, schulconnexToolProvisioningService, groupService, - configService + configService, + logger ); } diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts index 36ad4321943..1d413fd4aad 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts @@ -47,6 +47,11 @@ describe(SchulconnexResponseMapper.name, () => { mapper = module.get(SchulconnexResponseMapper); }); + beforeEach(() => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = false; + config.PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT = undefined; + }); + describe('mapToExternalSchoolDto', () => { describe('when a schulconnex response is provided', () => { const setup = () => { @@ -316,6 +321,8 @@ describe(SchulconnexResponseMapper.name, () => { describe('when other participants have unknown roles', () => { const setup = () => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); schulconnexResponse.personenkontexte[0].gruppen![0]!.sonstige_gruppenzugehoerige = [ { @@ -514,6 +521,56 @@ describe(SchulconnexResponseMapper.name, () => { ); }); }); + + describe('when there are too many users in groups', () => { + const setup = () => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; + config.PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT = 1; + + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + + return { + schulconnexResponse, + }; + }; + + it('should not map other group users', () => { + const { schulconnexResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(schulconnexResponse); + + expect(result).toEqual([ + expect.objectContaining>({ + otherUsers: undefined, + }), + ]); + }); + }); + + describe('when there are not too many users in groups', () => { + const setup = () => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; + config.PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT = 10; + + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + + return { + schulconnexResponse, + }; + }; + + it('should not map other group users', () => { + const { schulconnexResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(schulconnexResponse); + + expect(result).not.toEqual([ + expect.objectContaining({ + otherUsers: undefined, + }), + ]); + }); + }); }); describe('mapLernperiode', () => { diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts index 4a7543cac70..07ce885a1b9 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts @@ -120,14 +120,25 @@ export class SchulconnexResponseMapper { return undefined; } + const usersInGroupsCount: number = groups.reduce( + (count: number, group: SchulconnexGruppenResponse) => count + (group.sonstige_gruppenzugehoerige?.length ?? 0), + groups.length + ); + const limit: number | undefined = this.configService.get('PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT'); + const shouldProvisionOtherUsers: boolean = limit === undefined || usersInGroupsCount < limit; + const mapped: ExternalGroupDto[] = groups - .map((group) => this.mapExternalGroup(source, group)) - .filter((group): group is ExternalGroupDto => group !== null); + .map((group: SchulconnexGruppenResponse) => this.mapExternalGroup(source, group, shouldProvisionOtherUsers)) + .filter((group: ExternalGroupDto | null): group is ExternalGroupDto => group !== null); return mapped; } - private mapExternalGroup(source: SchulconnexResponse, group: SchulconnexGruppenResponse): ExternalGroupDto | null { + private mapExternalGroup( + source: SchulconnexResponse, + group: SchulconnexGruppenResponse, + shouldProvisionOtherUsers: boolean + ): ExternalGroupDto | null { const groupType: GroupTypes | undefined = GroupTypeMapping[group.gruppe.typ]; if (!groupType) { @@ -144,7 +155,7 @@ export class SchulconnexResponseMapper { } let otherUsers: ExternalGroupUserDto[] | undefined; - if (this.configService.get('FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED')) { + if (this.configService.get('FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED') && shouldProvisionOtherUsers) { otherUsers = group.sonstige_gruppenzugehoerige ? group.sonstige_gruppenzugehoerige .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts index 26fbc0202df..f86346d37eb 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts @@ -14,6 +14,7 @@ import { legacySchoolDoFactory, userDoFactory, } from '@shared/testing'; +import { Logger } from '@src/core/logger'; import { ExternalGroupDto, ExternalSchoolDto, @@ -98,6 +99,10 @@ describe(SchulconnexProvisioningStrategy.name, () => { get: jest.fn().mockImplementation((key: keyof ProvisioningConfig) => config[key]), }, }, + { + provide: Logger, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts index b965aabebcd..1c3737a6877 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts @@ -2,7 +2,9 @@ import { Group, GroupService } from '@modules/group'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; +import { Logger } from '@src/core/logger'; import { ExternalGroupDto, OauthDataDto, ProvisioningDto } from '../../dto'; +import { GroupProvisioningInfoLoggable } from '../../loggable'; import { ProvisioningConfig } from '../../provisioning.config'; import { ProvisioningStrategy } from '../base.strategy'; import { @@ -24,7 +26,8 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate protected readonly schulconnexLicenseProvisioningService: SchulconnexLicenseProvisioningService, protected readonly schulconnexToolProvisioningService: SchulconnexToolProvisioningService, protected readonly groupService: GroupService, - protected readonly configService: ConfigService + protected readonly configService: ConfigService, + protected readonly logger: Logger ) { super(); } @@ -61,6 +64,8 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate } private async provisionGroups(data: OauthDataDto, school?: LegacySchoolDo): Promise { + const startTime = performance.now(); + await this.removeUserFromGroups(data); if (data.externalGroups) { @@ -96,6 +101,9 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate await Promise.all(groupProvisioningPromises); } + + const endTime = performance.now(); + this.logger.warning(new GroupProvisioningInfoLoggable(data.externalGroups ?? [], endTime - startTime)); } private async removeUserFromGroups(data: OauthDataDto): Promise { diff --git a/apps/server/src/modules/provisioning/testing/external-group-dto.factory.ts b/apps/server/src/modules/provisioning/testing/external-group-dto.factory.ts new file mode 100644 index 00000000000..d33808d811a --- /dev/null +++ b/apps/server/src/modules/provisioning/testing/external-group-dto.factory.ts @@ -0,0 +1,18 @@ +import { UUID } from 'bson'; +import { Factory } from 'fishery'; +import { GroupTypes } from '../../group'; +import { ExternalGroupDto } from '../dto'; +import { externalGroupUserDtoFactory } from './external-group-user-dto.factory'; + +export const externalGroupDtoFactory = Factory.define( + ({ sequence }) => + new ExternalGroupDto({ + type: GroupTypes.CLASS, + name: `External Group ${sequence}`, + externalId: new UUID().toString(), + user: externalGroupUserDtoFactory.build(), + otherUsers: externalGroupUserDtoFactory.buildList(2), + from: new Date(), + until: new Date(), + }) +); diff --git a/apps/server/src/modules/provisioning/testing/external-group-user-dto.factory.ts b/apps/server/src/modules/provisioning/testing/external-group-user-dto.factory.ts new file mode 100644 index 00000000000..938eff3e073 --- /dev/null +++ b/apps/server/src/modules/provisioning/testing/external-group-user-dto.factory.ts @@ -0,0 +1,12 @@ +import { RoleName } from '@shared/domain/interface'; +import { UUID } from 'bson'; +import { Factory } from 'fishery'; +import { ExternalGroupUserDto } from '../dto'; + +export const externalGroupUserDtoFactory = Factory.define( + () => + new ExternalGroupUserDto({ + externalUserId: new UUID().toString(), + roleName: RoleName.TEACHER, + }) +); diff --git a/apps/server/src/modules/provisioning/testing/index.ts b/apps/server/src/modules/provisioning/testing/index.ts index 770f3e74f37..32854894142 100644 --- a/apps/server/src/modules/provisioning/testing/index.ts +++ b/apps/server/src/modules/provisioning/testing/index.ts @@ -1 +1,3 @@ export { externalUserDtoFactory } from './external-user-dto.factory'; +export { externalGroupDtoFactory } from './external-group-dto.factory'; +export { externalGroupUserDtoFactory } from './external-group-user-dto.factory'; diff --git a/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts b/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts index 0326bb2d02b..24384cd6b2b 100644 --- a/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts +++ b/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts @@ -116,6 +116,17 @@ describe(RoomMembershipRule.name, () => { expect(res).toBe(false); }); + + it('should return false for change owner action', () => { + const { user, roomMembershipAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMembershipAuthorizable, { + action: Action.read, + requiredPermissions: [Permission.ROOM_CHANGE_OWNER], + }); + + expect(res).toBe(false); + }); }); describe('when user is not member of room', () => { diff --git a/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts b/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts index 3336e93892f..544a8bdfacf 100644 --- a/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts +++ b/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts @@ -10,18 +10,18 @@ export class RoomMembershipRule implements Rule { this.authorisationInjectionService.injectAuthorizationRule(this); } - public isApplicable(user: User, object: unknown): boolean { + public isApplicable(_: User, object: unknown): boolean { const isMatched = object instanceof RoomMembershipAuthorizable; return isMatched; } public hasPermission(user: User, object: RoomMembershipAuthorizable, context: AuthorizationContext): boolean { - const primarySchoolId = user.school.id; - const secondarySchools = user.secondarySchools ?? []; - const secondarySchoolIds = secondarySchools.map(({ school }) => school.id); + if (!this.hasAccessToSchool(user, object.schoolId)) { + return false; + } - if (![primarySchoolId, ...secondarySchoolIds].includes(object.schoolId)) { + if (!this.hasRequiredRoomPermissions(user, object, context.requiredPermissions)) { return false; } @@ -36,4 +36,30 @@ export class RoomMembershipRule implements Rule { } return permissionsThisUserHas.includes(Permission.ROOM_EDIT); } + + private hasAccessToSchool(user: User, schoolId: string): boolean { + const primarySchoolId = user.school.id; + const secondarySchools = user.secondarySchools ?? []; + const secondarySchoolIds = secondarySchools.map(({ school }) => school.id); + + return [primarySchoolId, ...secondarySchoolIds].includes(schoolId); + } + + private hasRequiredRoomPermissions( + user: User, + object: RoomMembershipAuthorizable, + requiredPermissions: string[] + ): boolean { + const roomPermissionsOfUser = this.resolveRoomPermissions(user, object); + const missingPermissions = requiredPermissions.filter((permission) => !roomPermissionsOfUser.includes(permission)); + return missingPermissions.length === 0; + } + + private resolveRoomPermissions(user: User, object: RoomMembershipAuthorizable): string[] { + const member = object.members.find((m) => m.userId === user.id); + if (!member) { + return []; + } + return member.roles.flatMap((role) => role.permissions ?? []); + } } diff --git a/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts index 98763e33358..c6249661dc4 100644 --- a/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts +++ b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts @@ -87,26 +87,6 @@ describe('RoomMembershipService', () => { }; }; - it('should create new roomMembership when not exists', async () => { - const { user, room } = setup(); - - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); - - expect(roomMembershipRepo.save).toHaveBeenCalled(); - }); - - it('should save the schoolId of the room in the roomMembership', async () => { - const { user, room } = setup(); - - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); - - expect(roomMembershipRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ - schoolId: room.schoolId, - }) - ); - }); - describe('when no user is provided', () => { it('should throw an exception', async () => { const { room } = setup(); @@ -189,118 +169,148 @@ describe('RoomMembershipService', () => { }); describe('when roomMembership exists', () => { - const setup = () => { - const user = userFactory.buildWithId(); + const setupGroupAndRoom = (schoolId: string) => { const group = groupFactory.build({ type: GroupTypes.ROOM }); - const room = roomFactory.build(); - const roomMembership = roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const room = roomFactory.build({ schoolId }); + const roomMembership = roomMembershipFactory.build({ + roomId: room.id, + userGroupId: group.id, + schoolId, + }); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); groupService.findById.mockResolvedValue(group); - groupService.findGroups.mockResolvedValue({ total: 1, data: [group] }); + roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - return { - user, - room, - roomMembership, - group, - }; + return { group, room, roomMembership }; }; - it('should remove roomMembership', async () => { - const { user, room, group } = setup(); + const mockGroupsAtSchoolAfterRemoval = (groups: Group[]) => { + groupService.findGroups.mockResolvedValue({ total: groups.length, data: groups }); + }; - await service.removeMembersFromRoom(room.id, [user.id]); + const setupRoomRoles = () => { + const roomOwnerRole = roleFactory.buildWithId({ name: RoleName.ROOMOWNER }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + roleService.findByName.mockResolvedValue(roomOwnerRole); - expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [user.id]); - }); - }); + return { roomOwnerRole, roomEditorRole }; + }; - const setupUserWithSecondarySchool = () => { - const secondarySchool = schoolFactory.build(); - const otherSchool = schoolFactory.build(); - const role = roleFactory.buildWithId({ name: RoleName.TEACHER }); - const guestTeacher = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); - const externalUser = userDoFactory.buildWithId({ - roles: [role], - secondarySchools: [{ schoolId: secondarySchool.id, role: new RoleDto(guestTeacher) }], - }); + const setupUserWithSecondarySchool = () => { + const secondarySchool = schoolFactory.build(); + const otherSchool = schoolFactory.build(); + const role = roleFactory.buildWithId({ name: RoleName.TEACHER }); + const guestTeacher = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); + const externalUser = userDoFactory.buildWithId({ + roles: [role], + secondarySchools: [{ schoolId: secondarySchool.id, role: new RoleDto(guestTeacher) }], + }); + const externalUserId = externalUser.id as string; - return { secondarySchool, externalUser, otherSchool }; - }; + return { secondarySchool, externalUser, externalUserId, otherSchool }; + }; - const setupGroupAndRoom = (schoolId: string) => { - const group = groupFactory.build({ type: GroupTypes.ROOM }); - const room = roomFactory.build({ schoolId }); - const roomMembership = roomMembershipFactory.build({ - roomId: room.id, - userGroupId: group.id, - schoolId, - }); + describe('when removing user from a different school, with no further groups on host school', () => { + const setup = () => { + const { secondarySchool, externalUserId } = setupUserWithSecondarySchool(); + const { roomEditorRole } = setupRoomRoles(); - return { group, room, roomMembership }; - }; + const { room, group } = setupGroupAndRoom(secondarySchool.id); + group.addUser({ userId: externalUserId, roleId: roomEditorRole.id }); - const mockGroupsAtSchoolAfterRemoval = (groups: Group[]) => { - groupService.findGroups.mockResolvedValue({ total: groups.length, data: groups }); - }; + mockGroupsAtSchoolAfterRemoval([]); - it('should pass the schoolId of the room', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + return { secondarySchool, externalUserId, room, group }; + }; - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + it('should pass the schoolId of the room', async () => { + const { secondarySchool, externalUserId, room } = setup(); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + await service.removeMembersFromRoom(room.id, [externalUserId]); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([]); + expect(groupService.findGroups).toHaveBeenCalledWith( + expect.objectContaining({ schoolId: secondarySchool.id }) + ); + }); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + it('should remove user from room', async () => { + const { group, externalUserId, room } = setup(); - expect(groupService.findGroups).toHaveBeenCalledWith(expect.objectContaining({ schoolId: secondarySchool.id })); - }); + await service.removeMembersFromRoom(room.id, [externalUserId]); + + expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [externalUserId]); + }); - describe('when after removal: user is not in any room of that secondary school', () => { - it('should remove user from secondary school', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + it('should remove user from secondary school', async () => { + const { secondarySchool, externalUserId, room } = setup(); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + await service.removeMembersFromRoom(room.id, [externalUserId]); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([]); + expect(userService.removeSecondarySchoolFromUsers).toHaveBeenCalledWith([externalUserId], secondarySchool.id); + }); + }); + + describe('when removing user from a different school, with further groups on host school', () => { + const setup = () => { + const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + const { roomEditorRole } = setupRoomRoles(); + + const { room, group } = setupGroupAndRoom(secondarySchool.id); + group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + const { group: group2 } = setupGroupAndRoom(secondarySchool.id); + group2.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + + mockGroupsAtSchoolAfterRemoval([group2]); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + return { externalUser, room }; + }; + + it('should not remove user from secondary school', async () => { + const { externalUser, room } = setup(); + + await service.removeMembersFromRoom(room.id, [externalUser.id as string]); - expect(userService.removeSecondarySchoolFromUsers).toHaveBeenCalledWith([externalUser.id], secondarySchool.id); + expect(userService.removeSecondarySchoolFromUsers).not.toHaveBeenCalled(); + }); }); - }); - describe('when after removal: user is still in a room of that secondary school', () => { - it('should not remove user from secondary school', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + describe('when removing user from the same school', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const { roomEditorRole } = setupRoomRoles(); + const { room, group } = setupGroupAndRoom(user.school.id); + group.addUser({ userId: user.id, roleId: roomEditorRole.id }); - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + mockGroupsAtSchoolAfterRemoval([group]); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); - const { group: group2 } = setupGroupAndRoom(secondarySchool.id); - group2.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + return { user, room, group }; + }; - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([group2]); + it('should remove user from room', async () => { + const { user, group, room } = setup(); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + await service.removeMembersFromRoom(room.id, [user.id]); - expect(userService.removeSecondarySchoolFromUsers).not.toHaveBeenCalled(); + expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [user.id]); + }); + }); + + describe('when removing the owner of the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const { room, group } = setupGroupAndRoom(user.school.id); + const { roomOwnerRole } = setupRoomRoles(); + + group.addUser({ userId: user.id, roleId: roomOwnerRole.id }); + + return { user, room }; + }; + + it('should throw a badrequest exception', async () => { + const { user, room } = setup(); + + await expect(service.removeMembersFromRoom(room.id, [user.id])).rejects.toThrowError(BadRequestException); + }); }); }); }); diff --git a/apps/server/src/modules/room-membership/service/room-membership.service.ts b/apps/server/src/modules/room-membership/service/room-membership.service.ts index 8baa23dd643..fd978721d04 100644 --- a/apps/server/src/modules/room-membership/service/room-membership.service.ts +++ b/apps/server/src/modules/room-membership/service/room-membership.service.ts @@ -20,11 +20,7 @@ export class RoomMembershipService { private readonly userService: UserService ) {} - private async createNewRoomMembership( - roomId: EntityId, - userId: EntityId, - roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER - ): Promise { + public async createNewRoomMembership(roomId: EntityId, ownerUserId: EntityId): Promise { const room = await this.roomService.getSingleRoom(roomId); const group = await this.groupService.createGroup( @@ -32,7 +28,7 @@ export class RoomMembershipService { GroupTypes.ROOM, room.schoolId ); - await this.groupService.addUsersToGroup(group.id, [{ userId, roleName }]); + await this.groupService.addUsersToGroup(group.id, [{ userId: ownerUserId, roleName: RoleName.ROOMOWNER }]); const roomMembership = new RoomMembership({ id: new ObjectId().toHexString(), @@ -79,16 +75,14 @@ export class RoomMembershipService { public async addMembersToRoom( roomId: EntityId, - userIdsAndRoles: Array<{ userId: EntityId; roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER }> + userIdsAndRoles: Array<{ + userId: EntityId; + roleName: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; + }> ): Promise { const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId); if (roomMembership === null) { - const firstUser = userIdsAndRoles.shift(); - if (firstUser === undefined) { - throw new BadRequestException('No user provided'); - } - const newRoomMembership = await this.createNewRoomMembership(roomId, firstUser.userId, firstUser.roleName); - return newRoomMembership.id; + throw new Error('Room membership not found'); } await this.groupService.addUsersToGroup(roomMembership.userGroupId, userIdsAndRoles); @@ -106,6 +100,8 @@ export class RoomMembershipService { } const group = await this.groupService.findById(roomMembership.userGroupId); + + await this.ensureOwnerIsNotRemoved(group, userIds); await this.groupService.removeUsersFromGroup(group.id, userIds); await this.handleGuestRoleRemoval(userIds, roomMembership.schoolId); @@ -151,6 +147,17 @@ export class RoomMembershipService { return roomMembershipAuthorizable; } + private async ensureOwnerIsNotRemoved(group: Group, userIds: EntityId[]): Promise { + const role = await this.roleService.findByName(RoleName.ROOMOWNER); + const includedOwner = group.users + .filter((groupUser) => userIds.includes(groupUser.userId)) + .find((groupUser) => groupUser.roleId === role.id); + + if (includedOwner) { + throw new BadRequestException('Cannot remove owner from room'); + } + } + private async handleGuestRoleRemoval(userIds: EntityId[], schoolId: EntityId): Promise { const { data: groups } = await this.groupService.findGroups({ userIds, groupTypes: [GroupTypes.ROOM], schoolId }); diff --git a/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts b/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts index 93cb5556460..9980d106fb9 100644 --- a/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts +++ b/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsMongoId, IsString, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; -import { RoomRole, RoomRoleArray } from '@shared/domain/interface'; +import { RoleName, RoomRoleArray } from '@shared/domain/interface'; class UserIdAndRole { @ApiProperty({ @@ -17,7 +17,7 @@ class UserIdAndRole { enum: RoomRoleArray, }) @IsString() - roleName!: RoomRole; + roleName!: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; } export class AddRoomMembersBodyParams { diff --git a/apps/server/src/modules/room/api/room.uc.spec.ts b/apps/server/src/modules/room/api/room.uc.spec.ts index 8910130093e..95cd6f7f6cd 100644 --- a/apps/server/src/modules/room/api/room.uc.spec.ts +++ b/apps/server/src/modules/room/api/room.uc.spec.ts @@ -117,7 +117,7 @@ describe('RoomUc', () => { authorizationService.checkOneOfPermissions.mockReturnValue(undefined); const room = roomFactory.build(); roomService.createRoom.mockResolvedValue(room); - roomMembershipService.addMembersToRoom.mockRejectedValue(new Error('test')); + roomMembershipService.createNewRoomMembership.mockRejectedValue(new Error('test')); return { user, room }; }; diff --git a/apps/server/src/modules/room/api/room.uc.ts b/apps/server/src/modules/room/api/room.uc.ts index a80e2838c66..1de7fd11b5c 100644 --- a/apps/server/src/modules/room/api/room.uc.ts +++ b/apps/server/src/modules/room/api/room.uc.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; import { Page, UserDO } from '@shared/domain/domainobject'; -import { IFindOptions, Permission, RoleName, RoomRole } from '@shared/domain/interface'; +import { IFindOptions, Permission, RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { BoardExternalReferenceType, ColumnBoard, ColumnBoardService } from '@modules/board'; import { Room, RoomService } from '../domain'; @@ -40,14 +40,13 @@ export class RoomUc { this.authorizationService.checkOneOfPermissions(user, [Permission.ROOM_CREATE]); - await this.roomMembershipService - .addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]) - .catch(async (err) => { - await this.roomService.deleteRoom(room); - throw err; - }); - - return room; + try { + await this.roomMembershipService.createNewRoomMembership(room.id, userId); + return room; + } catch (err) { + await this.roomService.deleteRoom(room); + throw err; + } } public async getSingleRoom(userId: EntityId, roomId: EntityId): Promise<{ room: Room; permissions: Permission[] }> { @@ -129,14 +128,17 @@ export class RoomUc { public async addMembersToRoom( currentUserId: EntityId, roomId: EntityId, - userIdsAndRoles: Array<{ userId: EntityId; roleName: RoomRole }> + userIdsAndRoles: Array<{ + userId: EntityId; + roleName: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; + }> ): Promise { this.checkFeatureEnabled(); - await this.checkRoomAuthorization(currentUserId, roomId, Action.write); + await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_ADD]); await this.roomMembershipService.addMembersToRoom(roomId, userIdsAndRoles); } - private mapToMember(member: UserWithRoomRoles, user: UserDO) { + private mapToMember(member: UserWithRoomRoles, user: UserDO): RoomMemberResponse { return new RoomMemberResponse({ userId: member.userId, firstName: user.firstName, @@ -148,7 +150,7 @@ export class RoomUc { public async removeMembersFromRoom(currentUserId: EntityId, roomId: EntityId, userIds: EntityId[]): Promise { this.checkFeatureEnabled(); - await this.checkRoomAuthorization(currentUserId, roomId, Action.write); + await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_REMOVE]); await this.roomMembershipService.removeMembersFromRoom(roomId, userIds); } diff --git a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts index ad8f5e6a3b7..d4d5761ad5f 100644 --- a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts @@ -54,6 +54,15 @@ describe('Room Controller (API)', () => { const teacherGuestRole = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); const studentGuestRole = roleFactory.buildWithId({ name: RoleName.GUESTSTUDENT }); const role = roleFactory.buildWithId({ + name: RoleName.ROOMADMIN, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], }); @@ -77,6 +86,7 @@ describe('Room Controller (API)', () => { teacherUser, teacherGuestRole, studentGuestRole, + roomEditorRole, otherTeacherUser, otherTeacherAccount, userGroupEntity, diff --git a/apps/server/src/modules/room/api/test/room-create.api.spec.ts b/apps/server/src/modules/room/api/test/room-create.api.spec.ts index eeca260725b..47cecf68d32 100644 --- a/apps/server/src/modules/room/api/test/room-create.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-create.api.spec.ts @@ -69,10 +69,20 @@ describe('Room Controller (API)', () => { const setup = async () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const role = roleFactory.buildWithId({ - name: RoleName.ROOMEDITOR, - permissions: [Permission.ROOM_EDIT, Permission.ROOM_VIEW], + name: RoleName.TEACHER, + permissions: [Permission.ROOM_CREATE, Permission.ROOM_EDIT, Permission.ROOM_VIEW], }); - await em.persistAndFlush([teacherAccount, teacherUser, role]); + const roomOwnerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [ + Permission.ROOM_CREATE, + Permission.ROOM_EDIT, + Permission.ROOM_VIEW, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + await em.persistAndFlush([teacherAccount, teacherUser, role, roomOwnerRole]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); diff --git a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts index 4e8be194dfe..a088b76b872 100644 --- a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts @@ -96,32 +96,50 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { const setup = async () => { const room = roomEntityFactory.build(); - const role = roleFactory.buildWithId({ + const roomOwnerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [Permission.ROOM_EDIT, Permission.ROOM_DELETE], + }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); const school = schoolEntityFactory.buildWithId(); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const { teacherAccount: teacherOwnerAccount, teacherUser: teacherOwnerUser } = + UserAndAccountTestFactory.buildTeacher({ school }); + const { teacherAccount: teacherEditorAccount, teacherUser: teacherEditorUser } = + UserAndAccountTestFactory.buildTeacher({ school }); const userGroup = groupEntityFactory.buildWithId({ type: GroupEntityTypes.ROOM, - users: [{ role, user: teacherUser }], + users: [ + { role: roomOwnerRole, user: teacherOwnerUser }, + { role: roomEditorRole, user: teacherEditorUser }, + ], }); const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id, - schoolId: teacherUser.school.id, + schoolId: teacherOwnerUser.school.id, }); - await em.persistAndFlush([room, roomMembership, teacherAccount, teacherUser, userGroup, role]); + await em.persistAndFlush([ + room, + roomMembership, + teacherOwnerAccount, + teacherOwnerUser, + teacherEditorAccount, + teacherEditorUser, + userGroup, + roomOwnerRole, + ]); em.clear(); - const loggedInClient = await testApiClient.login(teacherAccount); - - return { loggedInClient, room }; + return { teacherOwnerAccount, teacherEditorAccount, room }; }; describe('when the room exists', () => { it('should delete the room', async () => { - const { loggedInClient, room } = await setup(); + const { teacherOwnerAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); const response = await loggedInClient.delete(room.id); expect(response.status).toBe(HttpStatus.NO_CONTENT); @@ -129,7 +147,8 @@ describe('Room Controller (API)', () => { }); it('should delete the roomMembership', async () => { - const { loggedInClient, room } = await setup(); + const { teacherOwnerAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); await expect(em.findOneOrFail(RoomMembershipEntity, { roomId: room.id })).resolves.not.toThrow(); @@ -137,11 +156,23 @@ describe('Room Controller (API)', () => { expect(response.status).toBe(HttpStatus.NO_CONTENT); await expect(em.findOneOrFail(RoomMembershipEntity, { roomId: room.id })).rejects.toThrow(NotFoundException); }); + + describe('when user is not the roomowner', () => { + it('should fail', async () => { + const { teacherEditorAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherEditorAccount); + + const response = await loggedInClient.delete(room.id); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); }); describe('when the room does not exist', () => { it('should return a 404 error', async () => { - const { loggedInClient } = await setup(); + const { teacherOwnerAccount } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); const someId = new ObjectId().toHexString(); const response = await loggedInClient.delete(someId); diff --git a/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts index 3810a9f4f39..f52dfc0bf2d 100644 --- a/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts @@ -46,15 +46,30 @@ describe('Room Controller (API)', () => { describe('PATCH /rooms/:roomId/members/remove', () => { const setupRoomRoles = () => { - const editorRole = roleFactory.buildWithId({ - name: RoleName.ROOMEDITOR, - permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], + const ownerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_DELETE, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + const adminRole = roleFactory.buildWithId({ + name: RoleName.ROOMADMIN, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], }); const viewerRole = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); - return { editorRole, viewerRole }; + return { ownerRole, adminRole, viewerRole }; }; const setupRoomWithMembers = async () => { @@ -62,17 +77,17 @@ describe('Room Controller (API)', () => { const room = roomEntityFactory.buildWithId({ schoolId: school.id }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); - const { teacherUser: inRoomEditor2 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); - const { teacherUser: inRoomEditor3 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); + const { teacherUser: inRoomAdmin2 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); + const { teacherUser: inRoomAdmin3 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); const { teacherUser: inRoomViewer } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); const { teacherUser: outTeacher } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); - const users = { teacherUser, inRoomEditor2, inRoomEditor3, inRoomViewer, outTeacher }; + const users = { teacherUser, inRoomAdmin2, inRoomAdmin3, inRoomViewer, outTeacher }; - const { editorRole, viewerRole } = setupRoomRoles(); + const { ownerRole, adminRole, viewerRole } = setupRoomRoles(); - const roomUsers = [teacherUser, inRoomEditor2, inRoomEditor3].map((user) => { - return { role: editorRole, user }; + const roomUsers = [teacherUser, inRoomAdmin2, inRoomAdmin3].map((user) => { + return { role: adminRole, user }; }); roomUsers.push({ role: viewerRole, user: inRoomViewer }); @@ -89,7 +104,14 @@ describe('Room Controller (API)', () => { schoolId: school.id, }); - await em.persistAndFlush([...Object.values(users), room, roomMemberships, teacherAccount, userGroupEntity]); + await em.persistAndFlush([ + ...Object.values(users), + room, + roomMemberships, + teacherAccount, + userGroupEntity, + ownerRole, + ]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); @@ -137,9 +159,9 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { describe('when removing a user from the room', () => { it('should return OK', async () => { - const { loggedInClient, room, inRoomEditor2 } = await setupRoomWithMembers(); + const { loggedInClient, room, inRoomAdmin2 } = await setupRoomWithMembers(); - const userIds = [inRoomEditor2.id]; + const userIds = [inRoomAdmin2.id]; const response = await loggedInClient.patch(`/${room.id}/members/remove`, { userIds }); expect(response.status).toBe(HttpStatus.OK); @@ -148,9 +170,9 @@ describe('Room Controller (API)', () => { describe('when removing several users from the room', () => { it('should return OK', async () => { - const { loggedInClient, room, inRoomEditor2, inRoomEditor3 } = await setupRoomWithMembers(); + const { loggedInClient, room, inRoomAdmin2, inRoomAdmin3 } = await setupRoomWithMembers(); - const userIds = [inRoomEditor2.id, inRoomEditor3.id]; + const userIds = [inRoomAdmin2.id, inRoomAdmin3.id]; const response = await loggedInClient.patch(`/${room.id}/members/remove`, { userIds }); expect(response.status).toBe(HttpStatus.OK); diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 4f09ff5fe30..078662d11f4 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -255,12 +255,18 @@ const config: ServerConfig = { SCHULCONNEX_CLIENT__CLIENT_SECRET: Configuration.has('SCHULCONNEX_CLIENT__CLIENT_SECRET') ? (Configuration.get('SCHULCONNEX_CLIENT__CLIENT_SECRET') as string) : undefined, + SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS: Configuration.get( + 'SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS' + ) as number, SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: Configuration.get( 'SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS' ) as number, SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS: Configuration.get( 'SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS' ) as number, + PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT: Configuration.has('PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT') + ? (Configuration.get('PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT') as number) + : undefined, FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED: Configuration.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED') as boolean, FEATURE_MEDIA_SHELF_ENABLED: Configuration.get('FEATURE_MEDIA_SHELF_ENABLED') as boolean, FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED: Configuration.get( diff --git a/apps/server/src/modules/user-import/loggable/index.ts b/apps/server/src/modules/user-import/loggable/index.ts index 5866aa22e61..3d18659984b 100644 --- a/apps/server/src/modules/user-import/loggable/index.ts +++ b/apps/server/src/modules/user-import/loggable/index.ts @@ -12,3 +12,4 @@ export { UserMigrationIsNotEnabledLoggableException } from './user-migration-not export { UserMigrationCanceledLoggable } from './user-migration-canceled.loggable'; export { UserAlreadyMigratedLoggable } from './user-already-migrated.loggable'; export { UserLoginMigrationNotActiveLoggableException } from './user-login-migration-not-active.loggable-exception'; +export { UserMigrationFailedLoggable } from './user-migration-failed.loggable'; diff --git a/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.spec.ts b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.spec.ts new file mode 100644 index 00000000000..96363bb1ec9 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.spec.ts @@ -0,0 +1,38 @@ +import { NotFoundException } from '@nestjs/common'; +import { importUserFactory, setupEntities } from '@shared/testing'; +import { UserMigrationFailedLoggable } from './user-migration-failed.loggable'; + +describe(UserMigrationFailedLoggable.name, () => { + describe('getLogMessage', () => { + const setup = async () => { + await setupEntities(); + const importUser = importUserFactory.build(); + const error = new NotFoundException('user not found'); + const loggable = new UserMigrationFailedLoggable(importUser, error); + + return { + loggable, + importUser, + error, + }; + }; + + it('should return the correct log message', async () => { + const { loggable, importUser, error } = await setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'USER_MIGRATION_FAILED', + message: 'An error occurred while migrating a user with the migration wizard.', + stack: error.stack, + data: { + externalUserId: importUser.externalId, + userId: importUser.user?.id, + errorName: error.name, + errorMsg: error.message, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.ts b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.ts new file mode 100644 index 00000000000..8f382e8424e --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.ts @@ -0,0 +1,20 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ImportUser } from '../entity'; + +export class UserMigrationFailedLoggable implements Loggable { + constructor(private readonly importUser: ImportUser, private readonly error: Error) {} + + public getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'USER_MIGRATION_FAILED', + message: 'An error occurred while migrating a user with the migration wizard.', + stack: this.error.stack, + data: { + externalUserId: this.importUser.externalId, + userId: this.importUser.user?.id, + errorName: this.error.name, + errorMsg: this.error.message, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts index af5c6d96fca..b924f67f54f 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts @@ -33,7 +33,11 @@ import { import { Logger } from '@src/core/logger'; import { ImportUserFilter, ImportUserMatchCreatorScope } from '../domain/interface'; import { ImportUser, MatchCreator } from '../entity'; -import { SchoolNotMigratedLoggableException, UserAlreadyMigratedLoggable } from '../loggable'; +import { + SchoolNotMigratedLoggableException, + UserAlreadyMigratedLoggable, + UserMigrationFailedLoggable, +} from '../loggable'; import { ImportUserRepo } from '../repo'; import { UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; @@ -699,6 +703,7 @@ describe('[ImportUserModule]', () => { ); }); }); + describe('when user is already migrated', () => { const setup = () => { const system = systemEntityFactory.buildWithId(); @@ -762,6 +767,66 @@ describe('[ImportUserModule]', () => { expect(logger.notice).toHaveBeenCalledWith(new UserAlreadyMigratedLoggable(importUser.user!.id)); }); }); + + describe('when a user migration fails', () => { + const setup = () => { + const system = systemEntityFactory.buildWithId(); + const schoolEntity = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ + school: schoolEntity, + }); + const school = legacySchoolDoFactory.build({ + id: schoolEntity.id, + externalId: 'externalId', + officialSchoolNumber: 'officialSchoolNumber', + inUserMigration: true, + inMaintenanceSince: new Date(), + systems: [system.id], + }); + const importUser = importUserFactory.buildWithId({ + school: schoolEntity, + user: userFactory.buildWithId({ + school: schoolEntity, + }), + matchedBy: MatchCreator.AUTO, + system, + externalId: 'externalId', + }); + const importUserWithoutUser = importUserFactory.buildWithId({ + school: schoolEntity, + system, + }); + const error = new Error(); + + userRepo.findById.mockResolvedValueOnce(user); + userService.findByExternalId.mockResolvedValueOnce(null); + schoolService.getSchoolById.mockResolvedValueOnce(school); + importUserRepo.findImportUsers.mockResolvedValueOnce([[importUser, importUserWithoutUser], 2]); + userMigrationService.migrateUser.mockRejectedValueOnce(error); + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; + + return { + user, + importUser, + importUserWithoutUser, + error, + }; + }; + + it('should not throw', async () => { + const { user } = setup(); + + await expect(uc.saveAllUsersMatches(user.id)).resolves.not.toThrow(); + }); + + it('should log information for skipped user ', async () => { + const { user, importUser, error } = setup(); + + await uc.saveAllUsersMatches(user.id); + + expect(logger.warning).toHaveBeenCalledWith(new UserMigrationFailedLoggable(importUser, error)); + }); + }); }); describe('when the user does not have an account', () => { diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.ts b/apps/server/src/modules/user-import/uc/user-import.uc.ts index b33363583c4..69e8868ce7e 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.ts @@ -15,6 +15,10 @@ import { IFindOptions, Permission } from '@shared/domain/interface'; import { Counted, EntityId } from '@shared/domain/types'; import { UserRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; +import { isError } from 'lodash'; + +import { ImportUserFilter, ImportUserMatchCreatorScope, ImportUserNameMatchFilter } from '../domain/interface'; +import { ImportUser, MatchCreator } from '../entity'; import { MigrationMayBeCompleted, MigrationMayNotBeCompleted, @@ -23,10 +27,8 @@ import { SchoolInUserMigrationStartLoggable, SchoolNotMigratedLoggableException, UserAlreadyMigratedLoggable, + UserMigrationFailedLoggable, } from '../loggable'; - -import { ImportUserMatchCreatorScope, ImportUserNameMatchFilter, ImportUserFilter } from '../domain/interface'; -import { ImportUser, MatchCreator } from '../entity'; import { ImportUserRepo } from '../repo'; import { UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; @@ -200,12 +202,18 @@ export class UserImportUc { }, }); for (const importUser of importUsers) { - // TODO: Find a better solution for this loop - // this needs to be synchronous, because otherwise it was leading to - // server crush when working with larger number of users (e.g. 1000) - // eslint-disable-next-line no-await-in-loop - await this.updateUserAndAccount(importUser, school); - migratedUser += 1; + try { + // TODO: Find a better solution for this loop + // this needs to be synchronous, because otherwise it was leading to + // server crush when working with larger number of users (e.g. 1000) + // eslint-disable-next-line no-await-in-loop + await this.updateUserAndAccount(importUser, school); + migratedUser += 1; + } catch (error: unknown) { + if (isError(error)) { + this.logger.warning(new UserMigrationFailedLoggable(importUser, error)); + } + } } } diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index c5bed37ad11..bd1c2b0d255 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -104,6 +104,9 @@ export enum Permission { ROOM_EDIT = 'ROOM_EDIT', ROOM_VIEW = 'ROOM_VIEW', ROOM_DELETE = 'ROOM_DELETE', + ROOM_MEMBERS_ADD = 'ROOM_MEMBERS_ADD', + ROOM_MEMBERS_REMOVE = 'ROOM_MEMBERS_REMOVE', + ROOM_CHANGE_OWNER = 'ROOM_CHANGE_OWNER', SCHOOL_CHAT_MANAGE = 'SCHOOL_CHAT_MANAGE', SCHOOL_CREATE = 'SCHOOL_CREATE', SCHOOL_EDIT = 'SCHOOL_EDIT', diff --git a/apps/server/src/shared/domain/interface/rolename.enum.ts b/apps/server/src/shared/domain/interface/rolename.enum.ts index e354109efd3..310f80cf84f 100644 --- a/apps/server/src/shared/domain/interface/rolename.enum.ts +++ b/apps/server/src/shared/domain/interface/rolename.enum.ts @@ -13,6 +13,8 @@ export enum RoleName { HELPDESK = 'helpdesk', ROOMVIEWER = 'roomviewer', ROOMEDITOR = 'roomeditor', + ROOMADMIN = 'roomadmin', + ROOMOWNER = 'roomowner', STUDENT = 'student', SUPERHERO = 'superhero', TEACHER = 'teacher', @@ -32,7 +34,12 @@ export type IUserRoleName = | RoleName.DEMOSTUDENT | RoleName.DEMOTEACHER; -export const RoomRoleArray = [RoleName.ROOMEDITOR, RoleName.ROOMVIEWER] as const; +export const RoomRoleArray = [ + RoleName.ROOMOWNER, + RoleName.ROOMADMIN, + RoleName.ROOMEDITOR, + RoleName.ROOMVIEWER, +] as const; export type RoomRole = typeof RoomRoleArray[number]; export const GuestRoleArray = [RoleName.GUESTSTUDENT, RoleName.GUESTTEACHER] as const; diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index d99686e576e..9babec5e269 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -278,6 +278,15 @@ "$date": "2024-11-13T10:13:12.411Z" } }, + { + "_id": { + "$oid": "673fca34cc4a3264457c8ad1" + }, + "name": "Migration20241120100616", + "created_at": { + "$date": "2024-11-20T17:03:31.473Z" + } + }, { "_id": { "$oid": "674444262ba8186272dc8abd" @@ -298,11 +307,20 @@ }, { "_id": { - "$oid": "673fca34cc4a3264457c8ad1" + "$oid": "675abdb4e76b1142cd4c89e5" }, - "name": "Migration20241120100616", + "name": "Migration20241209165812", "created_at": { - "$date": "2024-11-20T17:03:31.473Z" + "$date": "2024-12-12T10:40:52.027Z" + } + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e6" + }, + "name": "Migration20241210152600", + "created_at": { + "$date": "2024-12-12T10:40:52.029Z" } } ] diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 81c1b5bc4af..0c494cb441f 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -599,8 +599,7 @@ "name": "roomeditor", "permissions": [ "ROOM_VIEW", - "ROOM_EDIT", - "ROOM_DELETE" + "ROOM_EDIT" ] }, { @@ -616,5 +615,31 @@ }, "name": "guestTeacher", "permissions": [] + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e3" + }, + "name": "roomowner", + "permissions": [ + "ROOM_VIEW", + "ROOM_EDIT", + "ROOM_DELETE", + "ROOM_MEMBERS_ADD", + "ROOM_MEMBERS_REMOVE", + "ROOM_CHANGE_OWNER" + ] + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e4" + }, + "name": "roomadmin", + "permissions": [ + "ROOM_VIEW", + "ROOM_EDIT", + "ROOM_MEMBERS_ADD", + "ROOM_MEMBERS_REMOVE" + ] } ] diff --git a/config/default.schema.json b/config/default.schema.json index 38af2238ce2..4c51ff2338c 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1574,6 +1574,11 @@ "type": "string", "description": "Client secret for accessing the schulconnex API (from server vault)" }, + "PERSON_INFO_TIMEOUT_IN_MS": { + "type": "integer", + "description": "Timeout in milliseconds for fetching person info from schulconnex", + "default": 3000 + }, "PERSONEN_INFO_TIMEOUT_IN_MS": { "type": "integer", "description": "Timeout in milliseconds for fetching personen info from schulconnex", @@ -1637,6 +1642,10 @@ "description": "URL for fetching policies info from moin.schule schulconnex", "examples": ["https://api-dienste.stage.niedersachsen-login.schule/v1/policies-info"] }, + "PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT": { + "type": "number", + "description": "Maximum number of users in group that still get processed during schulconnex provisioning" + }, "BOARD_COLLABORATION_URI": { "type": "string", "default": "ws://localhost:4450",