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 e7d5e6b23b..709f4a1ea7 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 b16a7f5545..bff42d9bbd 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 01391ec207..5316df7e74 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 5af753d855..49ad5e2fa2 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 820668c16c..d9a3b829cd 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/modules/idp-console/idp-console.config.ts b/apps/server/src/modules/idp-console/idp-console.config.ts index 08a1e9fe30..30b1426485 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/provisioning.config.ts b/apps/server/src/modules/provisioning/provisioning.config.ts index 0314bf8b27..9ba480fbce 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/schulconnex.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts index 26fbc0202d..3f23f776eb 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 @@ -116,6 +116,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = false; config.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED = false; config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; + config.PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT = undefined; }); afterAll(async () => { @@ -336,6 +337,42 @@ describe(SchulconnexProvisioningStrategy.name, () => { }); }); + describe('when there are too many users in groups', () => { + const setup = () => { + config.PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT = 1; + + const externalUserId = 'externalUserId'; + const externalGroups: ExternalGroupDto[] = externalGroupDtoFactory.buildList(2); + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: new ObjectId().toHexString(), + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalSchool: externalSchoolDtoFactory.build(), + externalUser: externalUserDtoFactory.build({ externalId: externalUserId }), + externalGroups, + }); + + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + externalId: externalUserId, + }); + + schulconnexUserProvisioningService.provisionExternalUser.mockResolvedValueOnce(user); + + return { + oauthData, + }; + }; + + it('should not run group provisioning', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(schulconnexGroupProvisioningService.provisionExternalGroup).not.toHaveBeenCalled(); + }); + }); + describe('when group data is not provided', () => { const setup = () => { config.FEATURE_SANIS_GROUP_PROVISIONING_ENABLED = true; 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 b965aabebc..0cd90ff443 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts @@ -45,7 +45,16 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate ); if (this.configService.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED')) { - await this.provisionGroups(data, school); + const usersInGroupsCount: number = + data.externalGroups?.reduce( + (count: number, group: ExternalGroupDto) => count + (group.otherUsers?.length ?? 0), + data.externalGroups?.length ?? 0 + ) ?? 0; + + const limit: number | undefined = this.configService.get('PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT'); + if (!limit || usersInGroupsCount < limit) { + await this.provisionGroups(data, school); + } } if (this.configService.get('FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED') && user.id) { diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 3c8f9d8970..6d4c290684 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -267,12 +267,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, ...getTldrawClientConfig(), FEATURE_MEDIA_SHELF_ENABLED: Configuration.get('FEATURE_MEDIA_SHELF_ENABLED') as boolean, diff --git a/config/default.schema.json b/config/default.schema.json index 14cb3e99a1..8b44ccda4c 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -8,7 +8,12 @@ }, "NODE_ENV": { "type": "string", - "enum": ["development", "test", "production", "migration"], + "enum": [ + "development", + "test", + "production", + "migration" + ], "default": "production" }, "REQUEST_OPTION": { @@ -72,7 +77,10 @@ "DEFAULT_LANGUAGE": { "type": "string", "default": "de", - "enum": ["de", "en"], + "enum": [ + "de", + "en" + ], "description": "Value for the default language" }, "DEFAULT_TIMEZONE": { @@ -100,13 +108,23 @@ "TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION": { "type": "string", "default": "disabled", - "enum": ["disabled", "opt-in", "opt-out", "enabled"], + "enum": [ + "disabled", + "opt-in", + "opt-out", + "enabled" + ], "description": "defines wheter external team invitation shows teachers from different schools or not. if enabled system wide there are options general enabled or opt-in/-out by user required." }, "STUDENT_TEAM_CREATION": { "type": "string", "default": "opt-out", - "enum": ["disabled", "opt-in", "opt-out", "enabled"], + "enum": [ + "disabled", + "opt-in", + "opt-out", + "enabled" + ], "description": "defines wheter students may create teams or not. if enabled system wide there are options general enabled or opt-in/-out by school admin required." }, "REDIS_URI": { @@ -282,7 +300,13 @@ "FILES_STORAGE": { "type": "object", "description": "Files storage server properties, required always to be defined", - "required": ["S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"], + "required": [ + "S3_ENDPOINT", + "S3_REGION", + "S3_BUCKET", + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY" + ], "properties": { "SERVICE_BASE_URL": { "type": "string", @@ -339,7 +363,13 @@ "FWU_CONTENT": { "type": "object", "description": "Properties of the S3 storage containing FWU content", - "required": ["S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY"], + "required": [ + "S3_ENDPOINT", + "S3_REGION", + "S3_BUCKET", + "S3_ACCESS_KEY", + "S3_SECRET_KEY" + ], "properties": { "S3_ENDPOINT": { "type": "string", @@ -378,7 +408,12 @@ "H5P_EDITOR": { "type": "object", "description": "Properties of the H5P server microservice and library management job", - "required": ["S3_ENDPOINT", "S3_REGION", "S3_BUCKET_CONTENT", "S3_BUCKET_LIBRARIES"], + "required": [ + "S3_ENDPOINT", + "S3_REGION", + "S3_BUCKET_CONTENT", + "S3_BUCKET_LIBRARIES" + ], "default": {}, "properties": { "S3_ENDPOINT": { @@ -438,7 +473,12 @@ "H5P_Library": { "type": "object", "description": "Properties of the H5P server microservice", - "required": ["S3_ENDPOINT", "S3_BUCKET_LIBRARIES", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"], + "required": [ + "S3_ENDPOINT", + "S3_BUCKET_LIBRARIES", + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY" + ], "default": {}, "properties": { "S3_ENDPOINT": { @@ -872,7 +912,9 @@ "ETHERPAD": { "type": "object", "description": "Etherpad settings", - "required": ["PAD_URI"], + "required": [ + "PAD_URI" + ], "properties": { "URI": { "type": "string", @@ -1030,7 +1072,9 @@ "API_VALIDATION_WHITELIST_EXTENSION": { "type": "string", "description": "when set, this is interpreted as a regex to extend the ignorelist for the API validation with any routes matching the regex.", - "examples": [".*/courses/[0-9a-f]{24}($|/$)"] + "examples": [ + ".*/courses/[0-9a-f]{24}($|/$)" + ] }, "FEATURE_PROMETHEUS_METRICS_ENABLED": { "type": "boolean", @@ -1105,13 +1149,31 @@ "type": "string", "default": "error", "description": "Log level for api.", - "enum": ["emerg", "alert", "crit", "error", "warning", "notice", "info", "debug"] + "enum": [ + "emerg", + "alert", + "crit", + "error", + "warning", + "notice", + "info", + "debug" + ] }, "NEST_LOG_LEVEL": { "type": "string", "default": "notice", "description": "Nest Log level for api. The http flag is for request logging. The http flag do only work by api methods with added 'request logging interceptor'.", - "enum": ["emerg", "alert", "crit", "error", "warning", "notice", "info", "debug"] + "enum": [ + "emerg", + "alert", + "crit", + "error", + "warning", + "notice", + "info", + "debug" + ] }, "EXIT_ON_ERROR": { "type": "boolean", @@ -1122,7 +1184,12 @@ "type": "string", "default": "requestError", "description": "Special logs.", - "enum": ["requestError", "systemLogs", "request", "sendRequests"] + "enum": [ + "requestError", + "systemLogs", + "request", + "sendRequests" + ] }, "SYNC_QUEUE_NAME": { "type": "string", @@ -1309,7 +1376,11 @@ "SAME_SITE": { "type": "string", "default": "none", - "enum": ["none", "lax", "strict"], + "enum": [ + "none", + "lax", + "strict" + ], "description": "Value for cookies sameSite property. When SECURE flag is false, 'None' is not allowed in SAME_SITE and Lax should be used as default instead" }, "HTTP_ONLY": { @@ -1333,7 +1404,13 @@ "description": "Expiration in seconds from now" } }, - "required": ["SAME_SITE", "HTTP_ONLY", "HOST_ONLY", "SECURE", "EXPIRES_SECONDS"], + "required": [ + "SAME_SITE", + "HTTP_ONLY", + "HOST_ONLY", + "SECURE", + "EXPIRES_SECONDS" + ], "allOf": [ { "$ref": "#/properties/COOKIE/definitions/SAME_SITE_SECURE_VALID" @@ -1351,7 +1428,10 @@ "then": { "properties": { "SAME_SITE": { - "enum": ["lax", "strict"] + "enum": [ + "lax", + "strict" + ] } } } @@ -1623,7 +1703,9 @@ "type": "string", "default": "image/png,image/jpeg,image/gif,image/svg+xml", "description": "List with allowed assets MIME types, comma separated, empty if all MIME types supported by tldraw should be allowed", - "examples": ["image/gif,image/jpeg,video/webm"] + "examples": [ + "image/gif,image/jpeg,video/webm" + ] }, "PERFORMANCE_MEASURE_ENABLED": { "type": "boolean", @@ -1634,7 +1716,16 @@ "type": "string", "default": "info", "description": "Define log level for tldraw.", - "enum": ["emerg", "alert", "crit", "error", "warning", "notice", "info", "debug"] + "enum": [ + "emerg", + "alert", + "crit", + "error", + "warning", + "notice", + "info", + "debug" + ] } } }, @@ -1655,12 +1746,16 @@ "API_URL": { "type": "string", "description": "Base URL of the schulconnex API (from dof)", - "examples": ["https://api-dienste.stage.niedersachsen-login.schule/v1/"] + "examples": [ + "https://api-dienste.stage.niedersachsen-login.schule/v1/" + ] }, "TOKEN_ENDPOINT": { "type": "string", "description": "Token endpoint of the schulconnex API (from dof)", - "examples": ["https://api-dienste.stage.niedersachsen-login.schule/v1/oauth2/token"] + "examples": [ + "https://api-dienste.stage.niedersachsen-login.schule/v1/oauth2/token" + ] }, "CLIENT_ID": { "type": "string", @@ -1670,6 +1765,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", @@ -1731,7 +1831,13 @@ "type": "string", "default": "", "description": "URL for fetching policies info from moin.schule schulconnex", - "examples": ["https://api-dienste.stage.niedersachsen-login.schule/v1/policies-info"] + "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",