diff --git a/apps/server/src/modules/system/controller/system.controller.ts b/apps/server/src/modules/system/controller/system.controller.ts index 6eabcc53762..a0630a8119a 100644 --- a/apps/server/src/modules/system/controller/system.controller.ts +++ b/apps/server/src/modules/system/controller/system.controller.ts @@ -48,6 +48,6 @@ export class SystemController { @ApiOperation({ summary: 'Deletes a system.' }) @HttpCode(HttpStatus.NO_CONTENT) async deleteSystem(@CurrentUser() currentUser: ICurrentUser, @Param() params: SystemIdParams): Promise { - await this.systemUc.delete(currentUser.userId, params.systemId); + await this.systemUc.delete(currentUser.userId, currentUser.schoolId, params.systemId); } } diff --git a/apps/server/src/modules/system/domain/index.ts b/apps/server/src/modules/system/domain/index.ts index 16e2a044335..f86a6c84dce 100644 --- a/apps/server/src/modules/system/domain/index.ts +++ b/apps/server/src/modules/system/domain/index.ts @@ -1,3 +1,4 @@ export { System, SystemProps } from './system.do'; export { LdapConfig } from './ldap-config'; export { OauthConfig } from './oauth-config'; +export { SystemType } from './system-type.enum'; diff --git a/apps/server/src/modules/system/domain/system-type.enum.ts b/apps/server/src/modules/system/domain/system-type.enum.ts new file mode 100644 index 00000000000..75ba87b2a1d --- /dev/null +++ b/apps/server/src/modules/system/domain/system-type.enum.ts @@ -0,0 +1,13 @@ +export enum SystemType { + OAUTH = 'oauth', + LDAP = 'ldap', + OIDC = 'oidc', + TSP_BASE = 'tsp-base', + TSP_SCHOOL = 'tsp-school', + // Legacy + LOCAL = 'local', + ISERV = 'iserv', + LERNSAX = 'lernsax', + ITSLEARNING = 'itslearning', + MOODLE = 'moodle', +} diff --git a/apps/server/src/modules/system/domain/system.do.ts b/apps/server/src/modules/system/domain/system.do.ts index 915cd22b3e0..35614ff8643 100644 --- a/apps/server/src/modules/system/domain/system.do.ts +++ b/apps/server/src/modules/system/domain/system.do.ts @@ -2,6 +2,7 @@ import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { LdapConfig } from './ldap-config'; import { OauthConfig } from './oauth-config'; +import { SystemType } from './system-type.enum'; export interface SystemProps extends AuthorizableObject { type: string; @@ -22,6 +23,10 @@ export interface SystemProps extends AuthorizableObject { } export class System extends DomainObject { + get type(): SystemType | string { + return this.props.type; + } + get ldapConfig(): LdapConfig | undefined { return this.props.ldapConfig; } diff --git a/apps/server/src/modules/system/system-api.module.ts b/apps/server/src/modules/system/system-api.module.ts index e9201f376b8..54592a5c4e6 100644 --- a/apps/server/src/modules/system/system-api.module.ts +++ b/apps/server/src/modules/system/system-api.module.ts @@ -1,11 +1,12 @@ import { AuthorizationModule } from '@modules/authorization'; +import { LegacySchoolModule } from '@modules/legacy-school'; import { SystemController } from '@modules/system/controller/system.controller'; import { SystemUc } from '@modules/system/uc/system.uc'; import { Module } from '@nestjs/common'; import { SystemModule } from './system.module'; @Module({ - imports: [SystemModule, AuthorizationModule], + imports: [SystemModule, AuthorizationModule, LegacySchoolModule], providers: [SystemUc], controllers: [SystemController], }) diff --git a/apps/server/src/modules/system/uc/system.uc.spec.ts b/apps/server/src/modules/system/uc/system.uc.spec.ts index 96ca3518b71..80202175beb 100644 --- a/apps/server/src/modules/system/uc/system.uc.spec.ts +++ b/apps/server/src/modules/system/uc/system.uc.spec.ts @@ -1,15 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; import { SystemDto } from '@modules/system/service/dto/system.dto'; import { SystemUc } from '@modules/system/uc/system.uc'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { LegacySchoolDo } from '@shared/domain/domainobject'; import { SystemEntity } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId, SystemTypeEnum } from '@shared/domain/types'; -import { setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; -import { AuthorizationContextBuilder, AuthorizationService } from '../../authorization'; +import { legacySchoolDoFactory, setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; +import { SystemType } from '../domain'; import { SystemMapper } from '../mapper'; import { LegacySystemService, SystemService } from '../service'; @@ -25,6 +28,7 @@ describe('SystemUc', () => { let legacySystemService: DeepMocked; let systemService: DeepMocked; let authorizationService: DeepMocked; + let schoolService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -44,6 +48,10 @@ describe('SystemUc', () => { provide: AuthorizationService, useValue: createMock(), }, + { + provide: LegacySchoolService, + useValue: createMock(), + }, ], }).compile(); @@ -51,6 +59,7 @@ describe('SystemUc', () => { legacySystemService = module.get(LegacySystemService); systemService = module.get(SystemService); authorizationService = module.get(AuthorizationService); + schoolService = module.get(LegacySchoolService); }); afterAll(async () => { @@ -161,20 +170,29 @@ describe('SystemUc', () => { const setup = () => { const user = userFactory.buildWithId(); const system = systemFactory.build(); + const otherSystemId = new ObjectId().toHexString(); + const school = legacySchoolDoFactory.build({ + systems: [system.id, otherSystemId], + ldapLastSync: new Date().toString(), + externalId: 'test', + }); systemService.findById.mockResolvedValueOnce(system); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); return { user, system, + school, + otherSystemId, }; }; it('should check the permission', async () => { const { user, system } = setup(); - await systemUc.delete(user.id, system.id); + await systemUc.delete(user.id, user.school.id, system.id); expect(authorizationService.checkPermission).toHaveBeenCalledWith( user, @@ -186,10 +204,57 @@ describe('SystemUc', () => { it('should delete the system', async () => { const { user, system } = setup(); - await systemUc.delete(user.id, system.id); + await systemUc.delete(user.id, user.school.id, system.id); expect(systemService.delete).toHaveBeenCalledWith(system); }); + + it('should remove the system from the school', async () => { + const { user, system, school, otherSystemId } = setup(); + + await systemUc.delete(user.id, user.school.id, system.id); + + expect(schoolService.save).toHaveBeenCalledWith( + expect.objectContaining>({ + systems: [otherSystemId], + ldapLastSync: undefined, + externalId: school.externalId, + }) + ); + }); + }); + + describe('when the system is the last ldap system at the school', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const system = systemFactory.build({ type: SystemType.LDAP }); + const school = legacySchoolDoFactory.build({ + systems: [system.id], + ldapLastSync: new Date().toString(), + externalId: 'test', + }); + + systemService.findById.mockResolvedValueOnce(system); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); + + return { + user, + system, + }; + }; + + it('should remove the external id of the school', async () => { + const { user, system } = setup(); + + await systemUc.delete(user.id, user.school.id, system.id); + + expect(schoolService.save).toHaveBeenCalledWith( + expect.objectContaining>({ + externalId: undefined, + }) + ); + }); }); describe('when the system does not exist', () => { @@ -200,15 +265,17 @@ describe('SystemUc', () => { it('should throw a not found exception', async () => { setup(); - await expect(systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString())).rejects.toThrow( - NotFoundLoggableException - ); + await expect( + systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString(), new ObjectId().toHexString()) + ).rejects.toThrow(NotFoundLoggableException); }); it('should not delete any system', async () => { setup(); - await expect(systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString())).rejects.toThrow(); + await expect( + systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString(), new ObjectId().toHexString()) + ).rejects.toThrow(); expect(systemService.delete).not.toHaveBeenCalled(); }); @@ -236,13 +303,13 @@ describe('SystemUc', () => { it('should throw an error', async () => { const { user, system, error } = setup(); - await expect(systemUc.delete(user.id, system.id)).rejects.toThrow(error); + await expect(systemUc.delete(user.id, user.school.id, system.id)).rejects.toThrow(error); }); it('should not delete any system', async () => { const { user, system } = setup(); - await expect(systemUc.delete(user.id, system.id)).rejects.toThrow(); + await expect(systemUc.delete(user.id, user.school.id, system.id)).rejects.toThrow(); expect(systemService.delete).not.toHaveBeenCalled(); }); diff --git a/apps/server/src/modules/system/uc/system.uc.ts b/apps/server/src/modules/system/uc/system.uc.ts index 2f28fa6957a..9a13ad77214 100644 --- a/apps/server/src/modules/system/uc/system.uc.ts +++ b/apps/server/src/modules/system/uc/system.uc.ts @@ -1,11 +1,13 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; import { Injectable } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { LegacySchoolDo } from '@shared/domain/domainobject'; import { SystemEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { EntityId, SystemType, SystemTypeEnum } from '@shared/domain/types'; -import { System } from '../domain'; +import { EntityId, SystemTypeEnum } from '@shared/domain/types'; +import { System, SystemType } from '../domain'; import { LegacySystemService, SystemDto, SystemService } from '../service'; @Injectable() @@ -13,10 +15,11 @@ export class SystemUc { constructor( private readonly legacySystemService: LegacySystemService, private readonly systemService: SystemService, - private readonly authorizationService: AuthorizationService + private readonly authorizationService: AuthorizationService, + private readonly schoolService: LegacySchoolService ) {} - async findByFilter(type?: SystemType, onlyOauth = false): Promise { + async findByFilter(type?: SystemTypeEnum, onlyOauth = false): Promise { let systems: SystemDto[]; if (onlyOauth) { @@ -40,7 +43,7 @@ export class SystemUc { return system; } - async delete(userId: EntityId, systemId: EntityId): Promise { + async delete(userId: EntityId, schoolId: EntityId, systemId: EntityId): Promise { const system: System | null = await this.systemService.findById(systemId); if (!system) { @@ -55,5 +58,20 @@ export class SystemUc { ); await this.systemService.delete(system); + + await this.removeSystemFromSchool(schoolId, system); + } + + private async removeSystemFromSchool(schoolId: string, system: System) { + const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); + + school.systems = school.systems?.filter((schoolSystemId: string) => schoolSystemId !== system.id); + school.ldapLastSync = undefined; + + if (system.type === SystemType.LDAP && school.systems?.length === 0) { + school.externalId = undefined; + } + + await this.schoolService.save(school); } } diff --git a/apps/server/src/shared/domain/domainobject/legacy-school.do.ts b/apps/server/src/shared/domain/domainobject/legacy-school.do.ts index 25ac51e494e..89d41f7b8e2 100644 --- a/apps/server/src/shared/domain/domainobject/legacy-school.do.ts +++ b/apps/server/src/shared/domain/domainobject/legacy-school.do.ts @@ -30,6 +30,8 @@ export class LegacySchoolDo extends BaseDO { // TODO: N21-990 Refactoring: Create domain objects for schoolYear and federalState federalState: FederalStateEntity; + ldapLastSync?: string; + constructor(params: LegacySchoolDo) { super(); this.id = params.id; @@ -44,5 +46,6 @@ export class LegacySchoolDo extends BaseDO { this.systems = params.systems; this.userLoginMigrationId = params.userLoginMigrationId; this.federalState = params.federalState; + this.ldapLastSync = params.ldapLastSync; } } diff --git a/apps/server/src/shared/domain/entity/school.entity.ts b/apps/server/src/shared/domain/entity/school.entity.ts index 3daedf36403..f4478dcc449 100644 --- a/apps/server/src/shared/domain/entity/school.entity.ts +++ b/apps/server/src/shared/domain/entity/school.entity.ts @@ -42,6 +42,7 @@ export interface SchoolProperties { fileStorageType?: FileStorageType; language?: string; timezone?: string; + ldapLastSync?: string; } @Embeddable() @@ -77,6 +78,9 @@ export class SchoolEntity extends BaseEntityWithTimestamps { @Property({ nullable: true, fieldName: 'ldapSchoolIdentifier' }) externalId?: string; + @Property({ nullable: true }) + ldapLastSync?: string; + @Property({ nullable: true }) previousExternalId?: string; @@ -159,5 +163,6 @@ export class SchoolEntity extends BaseEntityWithTimestamps { this.fileStorageType = props.fileStorageType; this.language = props.language; this.timezone = props.timezone; + this.ldapLastSync = props.ldapLastSync; } } diff --git a/apps/server/src/shared/domain/types/system.type.ts b/apps/server/src/shared/domain/types/system.type.ts index f1302b8ca79..2f6064922a4 100644 --- a/apps/server/src/shared/domain/types/system.type.ts +++ b/apps/server/src/shared/domain/types/system.type.ts @@ -3,5 +3,3 @@ export enum SystemTypeEnum { OAUTH = 'oauth', // systems for direct authentication via OAuth, OIDC = 'oidc', // systems for direct authentication via OpenID Connect, } - -export type SystemType = SystemTypeEnum; diff --git a/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts b/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts index 3e66e4cd71a..6bf0f502a04 100644 --- a/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts @@ -21,7 +21,7 @@ import { userLoginMigrationFactory, } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { LegacySchoolRepo } from '..'; +import { LegacySchoolRepo } from './legacy-school.repo'; describe('LegacySchoolRepo', () => { let module: TestingModule; diff --git a/apps/server/src/shared/repo/school/legacy-school.repo.ts b/apps/server/src/shared/repo/school/legacy-school.repo.ts index 83fb34cec33..e61ec482f99 100644 --- a/apps/server/src/shared/repo/school/legacy-school.repo.ts +++ b/apps/server/src/shared/repo/school/legacy-school.repo.ts @@ -51,6 +51,7 @@ export class LegacySchoolRepo extends BaseDORepo { systems: entity.systems.isInitialized() ? entity.systems.getItems().map((system: SystemEntity) => system.id) : [], userLoginMigrationId: entity.userLoginMigration?.id, federalState: entity.federalState, + ldapLastSync: entity.ldapLastSync, }); } @@ -71,6 +72,7 @@ export class LegacySchoolRepo extends BaseDORepo { ? this._em.getReference(UserLoginMigrationEntity, entityDO.userLoginMigrationId) : undefined, federalState: entityDO.federalState, + ldapLastSync: entityDO.ldapLastSync, }; } } diff --git a/config/default.schema.json b/config/default.schema.json index 3decae4858b..876bcd6dd97 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1397,6 +1397,11 @@ "default": false, "description": "Enables groups of type class in courses" }, + "FEATURE_NEST_SYSTEMS_API_ENABLED": { + "type": "boolean", + "default": true, + "description": "Uses the v3 api over the v1 api for systems" + }, "FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED": { "type": "boolean", "default": false, diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index db781bac0ae..1724beaa2ff 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -71,6 +71,7 @@ const exposedVars = [ 'TLDRAW__ASSETS_ENABLED', 'TLDRAW__ASSETS_MAX_SIZE', 'TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST', + 'FEATURE_NEST_SYSTEMS_API_ENABLED', ]; /**