diff --git a/jslib/angular/src/services/jslib-services.module.ts b/jslib/angular/src/services/jslib-services.module.ts index 20331a658..82094fccf 100644 --- a/jslib/angular/src/services/jslib-services.module.ts +++ b/jslib/angular/src/services/jslib-services.module.ts @@ -5,6 +5,7 @@ import { AppIdService as AppIdServiceAbstraction } from "@/jslib/common/src/abst import { BroadcasterService as BroadcasterServiceAbstraction } from "@/jslib/common/src/abstractions/broadcaster.service"; import { CryptoService as CryptoServiceAbstraction } from "@/jslib/common/src/abstractions/crypto.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@/jslib/common/src/abstractions/cryptoFunction.service"; +import { DirectoryFactoryAbstraction } from "@/jslib/common/src/abstractions/directory-factory.service"; import { EnvironmentService as EnvironmentServiceAbstraction } from "@/jslib/common/src/abstractions/environment.service"; import { I18nService as I18nServiceAbstraction } from "@/jslib/common/src/abstractions/i18n.service"; import { LogService } from "@/jslib/common/src/abstractions/log.service"; @@ -19,13 +20,17 @@ import { Account } from "@/jslib/common/src/models/domain/account"; import { GlobalState } from "@/jslib/common/src/models/domain/globalState"; import { ApiService } from "@/jslib/common/src/services/api.service"; import { AppIdService } from "@/jslib/common/src/services/appId.service"; +import { BatchRequestBuilder } from "@/jslib/common/src/services/batch-requests.service"; import { ConsoleLogService } from "@/jslib/common/src/services/consoleLog.service"; import { CryptoService } from "@/jslib/common/src/services/crypto.service"; import { EnvironmentService } from "@/jslib/common/src/services/environment.service"; +import { SingleRequestBuilder } from "@/jslib/common/src/services/single-request.service"; import { StateService } from "@/jslib/common/src/services/state.service"; import { StateMigrationService } from "@/jslib/common/src/services/stateMigration.service"; import { TokenService } from "@/jslib/common/src/services/token.service"; +import { DirectoryFactoryService } from "@/src/services/directory-factory.service"; + import { SafeInjectionToken, SECURE_STORAGE, @@ -138,6 +143,21 @@ import { ValidationService } from "./validation.service"; ), deps: [StorageServiceAbstraction, SECURE_STORAGE], }), + safeProvider({ + provide: BatchRequestBuilder, + useClass: BatchRequestBuilder, + useAngularDecorators: true, + }), + safeProvider({ + provide: SingleRequestBuilder, + useClass: SingleRequestBuilder, + useAngularDecorators: true, + }), + safeProvider({ + provide: DirectoryFactoryAbstraction, + useClass: DirectoryFactoryService, + useAngularDecorators: true, + }), ] satisfies SafeProvider[], }) export class JslibServicesModule {} diff --git a/jslib/common/src/abstractions/api.service.ts b/jslib/common/src/abstractions/api.service.ts index e091a7aae..4ebbc550d 100644 --- a/jslib/common/src/abstractions/api.service.ts +++ b/jslib/common/src/abstractions/api.service.ts @@ -2,9 +2,9 @@ import { ApiTokenRequest } from "../models/request/identityToken/apiTokenRequest import { PasswordTokenRequest } from "../models/request/identityToken/passwordTokenRequest"; import { SsoTokenRequest } from "../models/request/identityToken/ssoTokenRequest"; import { OrganizationImportRequest } from "../models/request/organizationImportRequest"; -import { IdentityCaptchaResponse } from '../models/response/identityCaptchaResponse'; -import { IdentityTokenResponse } from '../models/response/identityTokenResponse'; -import { IdentityTwoFactorResponse } from '../models/response/identityTwoFactorResponse'; +import { IdentityCaptchaResponse } from "../models/response/identityCaptchaResponse"; +import { IdentityTokenResponse } from "../models/response/identityTokenResponse"; +import { IdentityTwoFactorResponse } from "../models/response/identityTwoFactorResponse"; export abstract class ApiService { postIdentityToken: ( diff --git a/jslib/common/src/abstractions/directory-factory.service.ts b/jslib/common/src/abstractions/directory-factory.service.ts new file mode 100644 index 000000000..0bc0b4e30 --- /dev/null +++ b/jslib/common/src/abstractions/directory-factory.service.ts @@ -0,0 +1,15 @@ +import { DirectoryType } from "@/src/enums/directoryType"; +import { IDirectoryService } from "@/src/services/directory.service"; + +import { I18nService } from "./i18n.service"; +import { LogService } from "./log.service"; +import { StateService } from "./state.service"; + +export abstract class DirectoryFactoryAbstraction { + abstract createService( + type: DirectoryType, + logService: LogService, + i18nService: I18nService, + stateService: StateService, + ): IDirectoryService; +} diff --git a/jslib/common/src/abstractions/request-builder.service.ts b/jslib/common/src/abstractions/request-builder.service.ts new file mode 100644 index 000000000..643e2fd8e --- /dev/null +++ b/jslib/common/src/abstractions/request-builder.service.ts @@ -0,0 +1,13 @@ +import { GroupEntry } from "@/src/models/groupEntry"; +import { UserEntry } from "@/src/models/userEntry"; + +import { OrganizationImportRequest } from "../models/request/organizationImportRequest"; + +export abstract class RequestBuilderAbstratction { + buildRequest: ( + groups: GroupEntry[], + users: UserEntry[], + removeDisabled: boolean, + overwriteExisting: boolean, + ) => OrganizationImportRequest[]; +} diff --git a/jslib/common/src/services/batch-requests.service.spec.ts b/jslib/common/src/services/batch-requests.service.spec.ts new file mode 100644 index 000000000..0a0e22559 --- /dev/null +++ b/jslib/common/src/services/batch-requests.service.spec.ts @@ -0,0 +1,55 @@ +import { GroupEntry } from "@/src/models/groupEntry"; +import { UserEntry } from "@/src/models/userEntry"; + +import { BatchRequestBuilder } from "./batch-requests.service"; +import { SingleRequestBuilder } from "./single-request.service"; + +describe("BatchingService", () => { + let batchRequestBuilder: BatchRequestBuilder; + let singleRequestBuilder: SingleRequestBuilder; + + function userSimulator(userCount: number) { + const simulatedArray: UserEntry[] = []; + for (let i = 0; i <= userCount; i++) { + simulatedArray.push(new UserEntry()); + } + return simulatedArray; + } + + function groupSimulator(groupCount: number) { + const simulatedArray: GroupEntry[] = []; + for (let i = 0; i <= groupCount; i++) { + simulatedArray.push(new GroupEntry()); + } + return simulatedArray; + } + + beforeEach(async () => { + batchRequestBuilder = new BatchRequestBuilder(); + singleRequestBuilder = new SingleRequestBuilder(); + }); + + it("BatchRequestBuilder batches requests for > 2000 users", () => { + const mockGroups = groupSimulator(11000); + const mockUsers = userSimulator(11000); + + const requests = batchRequestBuilder.buildRequest(mockGroups, mockUsers, true, true); + + expect(requests.length).toEqual(12); + }); + + it("SingleRequestBuilder returns single request for 200 users", () => { + const mockGroups = groupSimulator(200); + const mockUsers = userSimulator(200); + + const requests = singleRequestBuilder.buildRequest(mockGroups, mockUsers, true, true); + + expect(requests.length).toEqual(1); + }); + + it("BatchRequestBuilder retuns an empty array when there are no users or groups", () => { + const requests = batchRequestBuilder.buildRequest([], [], true, true); + + expect(requests).toEqual([]); + }); +}); diff --git a/jslib/common/src/services/batch-requests.service.ts b/jslib/common/src/services/batch-requests.service.ts new file mode 100644 index 000000000..c90ccfdca --- /dev/null +++ b/jslib/common/src/services/batch-requests.service.ts @@ -0,0 +1,65 @@ +import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest"; + +import { GroupEntry } from "@/src/models/groupEntry"; +import { UserEntry } from "@/src/models/userEntry"; + +import { RequestBuilderAbstratction } from "../abstractions/request-builder.service"; + +export class BatchRequestBuilder implements RequestBuilderAbstratction { + batchSize = 2000; + + buildRequest( + groups: GroupEntry[], + users: UserEntry[], + removeDisabled: boolean, + overwriteExisting: boolean, + ): OrganizationImportRequest[] { + const requests: OrganizationImportRequest[] = []; + + if (users.length > 0) { + const usersRequest = users.map((u) => { + return { + email: u.email, + externalId: u.externalId, + deleted: u.deleted || (removeDisabled && u.disabled), + }; + }); + + // Partition users + for (let i = 0; i < usersRequest.length; i += this.batchSize) { + const u = usersRequest.slice(i, i + this.batchSize); + const req = new OrganizationImportRequest({ + groups: [], + users: u, + largeImport: true, + overwriteExisting, + }); + requests.push(req); + } + } + + if (groups.length > 0) { + const groupRequest = groups.map((g) => { + return { + name: g.name, + externalId: g.externalId, + memberExternalIds: Array.from(g.userMemberExternalIds), + }; + }); + + // Partition groups + for (let i = 0; i < groupRequest.length; i += this.batchSize) { + const g = groupRequest.slice(i, i + this.batchSize); + const req = new OrganizationImportRequest({ + groups: g, + users: [], + largeImport: true, + overwriteExisting, + }); + requests.push(req); + } + } + + return requests; + } +} diff --git a/jslib/common/src/services/single-request.service.ts b/jslib/common/src/services/single-request.service.ts new file mode 100644 index 000000000..c5a2816bb --- /dev/null +++ b/jslib/common/src/services/single-request.service.ts @@ -0,0 +1,36 @@ +import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest"; + +import { GroupEntry } from "@/src/models/groupEntry"; +import { UserEntry } from "@/src/models/userEntry"; + +import { RequestBuilderAbstratction } from "../abstractions/request-builder.service"; + +export class SingleRequestBuilder implements RequestBuilderAbstratction { + buildRequest( + groups: GroupEntry[], + users: UserEntry[], + removeDisabled: boolean, + overwriteExisting: boolean, + ): OrganizationImportRequest[] { + return [ + new OrganizationImportRequest({ + groups: (groups ?? []).map((g) => { + return { + name: g.name, + externalId: g.externalId, + memberExternalIds: Array.from(g.userMemberExternalIds), + }; + }), + users: (users ?? []).map((u) => { + return { + email: u.email, + externalId: u.externalId, + deleted: u.deleted || (removeDisabled && u.disabled), + }; + }), + overwriteExisting: overwriteExisting, + largeImport: false, + }), + ]; + } +} diff --git a/src/app/services/services.module.ts b/src/app/services/services.module.ts index a3b219b22..900b28b00 100644 --- a/src/app/services/services.module.ts +++ b/src/app/services/services.module.ts @@ -6,6 +6,7 @@ import { AppIdService as AppIdServiceAbstraction } from "@/jslib/common/src/abst import { BroadcasterService as BroadcasterServiceAbstraction } from "@/jslib/common/src/abstractions/broadcaster.service"; import { CryptoService as CryptoServiceAbstraction } from "@/jslib/common/src/abstractions/crypto.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@/jslib/common/src/abstractions/cryptoFunction.service"; +import { DirectoryFactoryAbstraction } from "@/jslib/common/src/abstractions/directory-factory.service"; import { EnvironmentService as EnvironmentServiceAbstraction } from "@/jslib/common/src/abstractions/environment.service"; import { I18nService as I18nServiceAbstraction } from "@/jslib/common/src/abstractions/i18n.service"; import { LogService as LogServiceAbstraction } from "@/jslib/common/src/abstractions/log.service"; @@ -16,7 +17,9 @@ import { StorageService as StorageServiceAbstraction } from "@/jslib/common/src/ import { TokenService as TokenServiceAbstraction } from "@/jslib/common/src/abstractions/token.service"; import { StateFactory } from "@/jslib/common/src/factories/stateFactory"; import { GlobalState } from "@/jslib/common/src/models/domain/globalState"; +import { BatchRequestBuilder as BatchRequestAbstraction } from "@/jslib/common/src/services/batch-requests.service"; import { ContainerService } from "@/jslib/common/src/services/container.service"; +import { SingleRequestBuilder as SingleRequestAbstraction } from "@/jslib/common/src/services/single-request.service"; import { ElectronLogService } from "@/jslib/electron/src/services/electronLog.service"; import { ElectronPlatformUtilsService } from "@/jslib/electron/src/services/electronPlatformUtils.service"; import { ElectronRendererMessagingService } from "@/jslib/electron/src/services/electronRendererMessaging.service"; @@ -175,6 +178,9 @@ export function initFactory( I18nServiceAbstraction, EnvironmentServiceAbstraction, StateServiceAbstraction, + BatchRequestAbstraction, + SingleRequestAbstraction, + DirectoryFactoryAbstraction, ], }), safeProvider(AuthGuardService), diff --git a/src/bwdc.ts b/src/bwdc.ts index 84abf65bb..c8f3486af 100644 --- a/src/bwdc.ts +++ b/src/bwdc.ts @@ -7,10 +7,12 @@ import { LogLevelType } from "@/jslib/common/src/enums/logLevelType"; import { StateFactory } from "@/jslib/common/src/factories/stateFactory"; import { GlobalState } from "@/jslib/common/src/models/domain/globalState"; import { AppIdService } from "@/jslib/common/src/services/appId.service"; +import { BatchRequestBuilder } from "@/jslib/common/src/services/batch-requests.service"; import { ContainerService } from "@/jslib/common/src/services/container.service"; import { CryptoService } from "@/jslib/common/src/services/crypto.service"; import { EnvironmentService } from "@/jslib/common/src/services/environment.service"; import { NoopMessagingService } from "@/jslib/common/src/services/noopMessaging.service"; +import { SingleRequestBuilder } from "@/jslib/common/src/services/single-request.service"; import { TokenService } from "@/jslib/common/src/services/token.service"; import { CliPlatformUtilsService } from "@/jslib/node/src/cli/services/cliPlatformUtils.service"; import { ConsoleLogService } from "@/jslib/node/src/cli/services/consoleLog.service"; @@ -20,6 +22,7 @@ import { NodeCryptoFunctionService } from "@/jslib/node/src/services/nodeCryptoF import { Account } from "./models/account"; import { Program } from "./program"; import { AuthService } from "./services/auth.service"; +import { DirectoryFactoryService } from "./services/directory-factory.service"; import { I18nService } from "./services/i18n.service"; import { KeytarSecureStorageService } from "./services/keytarSecureStorage.service"; import { LowdbStorageService } from "./services/lowdbStorage.service"; @@ -51,6 +54,9 @@ export class Main { syncService: SyncService; stateService: StateService; stateMigrationService: StateMigrationService; + directoryFactoryService: DirectoryFactoryService; + batchRequestBuilder: BatchRequestBuilder; + singleRequestBuilder: SingleRequestBuilder; constructor() { const applicationName = "Bitwarden Directory Connector"; @@ -154,6 +160,9 @@ export class Main { this.i18nService, this.environmentService, this.stateService, + this.batchRequestBuilder, + this.singleRequestBuilder, + this.directoryFactoryService, ); this.program = new Program(this); diff --git a/src/models/hashResult.ts b/src/models/hashResult.ts new file mode 100644 index 000000000..ab9220949 --- /dev/null +++ b/src/models/hashResult.ts @@ -0,0 +1,4 @@ +export class HashResult { + hash: string; + hashLegacy: string; +} diff --git a/src/services/directory-factory.service.ts b/src/services/directory-factory.service.ts new file mode 100644 index 000000000..b30197833 --- /dev/null +++ b/src/services/directory-factory.service.ts @@ -0,0 +1,36 @@ +import { DirectoryFactoryAbstraction } from "@/jslib/common/src/abstractions/directory-factory.service"; +import { I18nService } from "@/jslib/common/src/abstractions/i18n.service"; +import { LogService } from "@/jslib/common/src/abstractions/log.service"; + +import { StateService } from "../abstractions/state.service"; +import { DirectoryType } from "../enums/directoryType"; + +import { AzureDirectoryService } from "./azure-directory.service"; +import { GSuiteDirectoryService } from "./gsuite-directory.service"; +import { LdapDirectoryService } from "./ldap-directory.service"; +import { OktaDirectoryService } from "./okta-directory.service"; +import { OneLoginDirectoryService } from "./onelogin-directory.service"; + +export class DirectoryFactoryService implements DirectoryFactoryAbstraction { + createService( + directoryType: DirectoryType, + logService: LogService, + i18nService: I18nService, + stateService: StateService, + ) { + switch (directoryType) { + case DirectoryType.GSuite: + return new GSuiteDirectoryService(logService, i18nService, stateService); + case DirectoryType.AzureActiveDirectory: + return new AzureDirectoryService(logService, i18nService, stateService); + case DirectoryType.Ldap: + return new LdapDirectoryService(logService, i18nService, stateService); + case DirectoryType.Okta: + return new OktaDirectoryService(logService, i18nService, stateService); + case DirectoryType.OneLogin: + return new OneLoginDirectoryService(logService, i18nService, stateService); + default: + return null; + } + } +} diff --git a/src/services/sync.service.spec.ts b/src/services/sync.service.spec.ts new file mode 100644 index 000000000..c41173da4 --- /dev/null +++ b/src/services/sync.service.spec.ts @@ -0,0 +1,188 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "@/jslib/common/src/abstractions/api.service"; +import { CryptoFunctionService } from "@/jslib/common/src/abstractions/cryptoFunction.service"; +import { DirectoryFactoryAbstraction } from "@/jslib/common/src/abstractions/directory-factory.service"; +import { EnvironmentService } from "@/jslib/common/src/abstractions/environment.service"; +import { LogService } from "@/jslib/common/src/abstractions/log.service"; +import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service"; +import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest"; +import { BatchRequestBuilder } from "@/jslib/common/src/services/batch-requests.service"; +import { SingleRequestBuilder } from "@/jslib/common/src/services/single-request.service"; + +import { DirectoryType } from "../enums/directoryType"; +import { LdapConfiguration } from "../models/ldapConfiguration"; +import { SyncConfiguration } from "../models/syncConfiguration"; + +import { I18nService } from "./i18n.service"; +import { LdapDirectoryService } from "./ldap-directory.service"; +import { StateService } from "./state.service"; +import { SyncService } from "./sync.service"; + +describe("SyncService", () => { + let logService: MockProxy; + let cryptoFunctionService: MockProxy; + let apiService: MockProxy; + let messagingService: MockProxy; + let i18nService: MockProxy; + let environmentService: MockProxy; + let stateService: MockProxy; + let directoryFactory: MockProxy; + let batchRequestBuilder: MockProxy; + let singleRequestBuilder: MockProxy; + + let syncService: SyncService; + + beforeEach(async () => { + logService = mock(); + cryptoFunctionService = mock(); + apiService = mock(); + messagingService = mock(); + i18nService = mock(); + environmentService = mock(); + stateService = mock(); + directoryFactory = mock(); + batchRequestBuilder = mock(); + singleRequestBuilder = mock(); + + stateService.getDirectoryType.mockResolvedValue(DirectoryType.Ldap); + stateService.getOrganizationId.mockResolvedValue("fakeId"); + directoryFactory.createService.mockReturnValue( + new LdapDirectoryService(logService, i18nService, stateService), + ); + + syncService = new SyncService( + logService, + cryptoFunctionService, + apiService, + messagingService, + i18nService, + environmentService, + stateService, + batchRequestBuilder, + singleRequestBuilder, + directoryFactory, + ); + }); + + it("Sync posts single request successfully for unique hashes", async () => { + stateService.getDirectory + .calledWith(DirectoryType.Ldap) + .mockResolvedValue(getLdapConfiguration()); + + stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true })); + cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1)); + + const mockRequest: OrganizationImportRequest[] = [ + { + members: [], + groups: [], + overwriteExisting: true, + largeImport: true, + }, + ]; + + singleRequestBuilder.buildRequest.mockReturnValue(mockRequest); + + await syncService.sync(true, false); + + expect(apiService.postPublicImportDirectory).toHaveBeenCalledTimes(1); + }); + + it("Sync posts multiple request successfully for unique hashes", async () => { + stateService.getDirectory + .calledWith(DirectoryType.Ldap) + .mockResolvedValue(getLdapConfiguration()); + + stateService.getSync.mockResolvedValue(getLargeSyncConfiguration()); + cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1)); + + const batchSize = 4; + const totalUsers = 20; + const mockRequests = []; + + for (let i = 0; i <= totalUsers; i += batchSize) { + mockRequests.push({ + members: [], + groups: [], + overwriteExisting: true, + largeImport: true, + }); + } + + batchRequestBuilder.buildRequest.mockReturnValue(mockRequests); + + await syncService.sync(true, false); + + expect(apiService.postPublicImportDirectory).toHaveBeenCalledTimes(6); + }); + + it("does not post for the same hash", async () => { + stateService.getDirectory + .calledWith(DirectoryType.Ldap) + .mockResolvedValue(getLdapConfiguration()); + + stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true })); + cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(0)); + + await syncService.sync(true, false); + + expect(apiService.postPublicImportDirectory).toHaveBeenCalledTimes(0); + }); +}); + +/** + * @returns a basic ldap configuration without TLS/SSL enabled. Can be overridden by passing in a partial configuration. + */ +const getLdapConfiguration = (config?: Partial): LdapConfiguration => ({ + ssl: false, + startTls: false, + tlsCaPath: null, + sslAllowUnauthorized: false, + sslCertPath: null, + sslKeyPath: null, + sslCaPath: null, + hostname: "localhost", + port: 1389, + domain: null, + rootPath: "dc=bitwarden,dc=com", + currentUser: false, + username: "cn=admin,dc=bitwarden,dc=com", + password: "admin", + ad: false, + pagedSearch: false, + ...(config ?? {}), +}); + +/** + * @returns a basic sync configuration. Can be overridden by passing in a partial configuration. + */ +const getSyncConfiguration = (config?: Partial): SyncConfiguration => ({ + users: false, + groups: false, + interval: 5, + userFilter: null, + groupFilter: null, + removeDisabled: false, + overwriteExisting: false, + largeImport: false, + // Ldap properties + groupObjectClass: "posixGroup", + userObjectClass: "person", + groupPath: null, + userPath: null, + groupNameAttribute: "cn", + userEmailAttribute: "mail", + memberAttribute: "memberUid", + useEmailPrefixSuffix: false, + emailPrefixAttribute: "sAMAccountName", + emailSuffix: null, + creationDateAttribute: "whenCreated", + revisionDateAttribute: "whenChanged", + ...(config ?? {}), +}); + +const getLargeSyncConfiguration = () => ({ + ...getSyncConfiguration({ groups: true, users: true }), + largeImport: true, +}); diff --git a/src/services/sync.service.ts b/src/services/sync.service.ts index db966d0ae..92d63d5ba 100644 --- a/src/services/sync.service.ts +++ b/src/services/sync.service.ts @@ -1,25 +1,22 @@ import { ApiService } from "@/jslib/common/src/abstractions/api.service"; import { CryptoFunctionService } from "@/jslib/common/src/abstractions/cryptoFunction.service"; +import { DirectoryFactoryAbstraction } from "@/jslib/common/src/abstractions/directory-factory.service"; import { EnvironmentService } from "@/jslib/common/src/abstractions/environment.service"; import { I18nService } from "@/jslib/common/src/abstractions/i18n.service"; import { LogService } from "@/jslib/common/src/abstractions/log.service"; import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service"; import { Utils } from "@/jslib/common/src/misc/utils"; import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest"; +import { BatchRequestBuilder } from "@/jslib/common/src/services/batch-requests.service"; +import { SingleRequestBuilder } from "@/jslib/common/src/services/single-request.service"; import { StateService } from "../abstractions/state.service"; import { DirectoryType } from "../enums/directoryType"; import { GroupEntry } from "../models/groupEntry"; +import { HashResult } from "../models/hashResult"; import { SyncConfiguration } from "../models/syncConfiguration"; import { UserEntry } from "../models/userEntry"; -import { AzureDirectoryService } from "./azure-directory.service"; -import { IDirectoryService } from "./directory.service"; -import { GSuiteDirectoryService } from "./gsuite-directory.service"; -import { LdapDirectoryService } from "./ldap-directory.service"; -import { OktaDirectoryService } from "./okta-directory.service"; -import { OneLoginDirectoryService } from "./onelogin-directory.service"; - export class SyncService { private dirType: DirectoryType; @@ -31,6 +28,9 @@ export class SyncService { private i18nService: I18nService, private environmentService: EnvironmentService, private stateService: StateService, + private batchRequestBuilder: BatchRequestBuilder, + private singleRequestBuilder: SingleRequestBuilder, + private directoryFactory: DirectoryFactoryAbstraction, ) {} async sync(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> { @@ -39,7 +39,12 @@ export class SyncService { throw new Error("No directory configured."); } - const directoryService = this.getDirectoryService(); + const directoryService = this.directoryFactory.createService( + this.dirType, + this.logService, + this.i18nService, + this.stateService, + ); if (directoryService == null) { throw new Error("Cannot load directory service."); } @@ -78,42 +83,23 @@ export class SyncService { return [groups, users]; } - const req = this.buildRequest( + const reqs = this.buildRequest( groups, users, syncConfig.removeDisabled, syncConfig.overwriteExisting, syncConfig.largeImport, ); - const reqJson = JSON.stringify(req); - const orgId = await this.stateService.getOrganizationId(); - if (orgId == null) { - throw new Error("Organization not set."); - } + const reqJson = JSON.stringify(reqs); - // TODO: Remove hashLegacy once we're sure clients have had time to sync new hashes - let hashLegacy: string = null; - const hashBuffLegacy = await this.cryptoFunctionService.hash( - this.environmentService.getApiUrl() + reqJson, - "sha256", - ); - if (hashBuffLegacy != null) { - hashLegacy = Utils.fromBufferToB64(hashBuffLegacy); - } - let hash: string = null; - const hashBuff = await this.cryptoFunctionService.hash( - this.environmentService.getApiUrl() + orgId + reqJson, - "sha256", - ); - if (hashBuff != null) { - hash = Utils.fromBufferToB64(hashBuff); - } - const lastHash = await this.stateService.getLastSyncHash(); + const result: HashResult = await this.generateHash(reqJson); - if (lastHash == null || (hash !== lastHash && hashLegacy !== lastHash)) { - await this.apiService.postPublicImportDirectory(req); - await this.stateService.setLastSyncHash(hash); + if (result.hash && (await this.compareToLastHash(result))) { + for (const req of reqs) { + await this.apiService.postPublicImportDirectory(req); + } + await this.stateService.setLastSyncHash(result.hash); } else { groups = null; users = null; @@ -133,6 +119,39 @@ export class SyncService { } } + async generateHash(reqJson: string): Promise { + const orgId = await this.stateService.getOrganizationId(); + if (orgId == null) { + throw new Error("Organization not set."); + } + + // TODO: Remove hashLegacy once we're sure clients have had time to sync new hashes + let hashLegacy: string = null; + const hashBuffLegacy = await this.cryptoFunctionService.hash( + this.environmentService.getApiUrl() + reqJson, + "sha256", + ); + if (hashBuffLegacy != null) { + hashLegacy = Utils.fromBufferToB64(hashBuffLegacy); + } + let hash: string = null; + const hashBuff = await this.cryptoFunctionService.hash( + this.environmentService.getApiUrl() + orgId + reqJson, + "sha256", + ); + if (hashBuff != null) { + hash = Utils.fromBufferToB64(hashBuff); + } + + return { hash, hashLegacy }; + } + + async compareToLastHash(hashResult: HashResult): Promise { + const lastHash = await this.stateService.getLastSyncHash(); + + return lastHash == null || (hashResult.hash !== lastHash && hashResult.hashLegacy !== lastHash); + } + private removeDuplicateUsers(users: UserEntry[]) { if (users == null) { return null; @@ -198,48 +217,28 @@ export class SyncService { return allUsers; } - private getDirectoryService(): IDirectoryService { - switch (this.dirType) { - case DirectoryType.GSuite: - return new GSuiteDirectoryService(this.logService, this.i18nService, this.stateService); - case DirectoryType.AzureActiveDirectory: - return new AzureDirectoryService(this.logService, this.i18nService, this.stateService); - case DirectoryType.Ldap: - return new LdapDirectoryService(this.logService, this.i18nService, this.stateService); - case DirectoryType.Okta: - return new OktaDirectoryService(this.logService, this.i18nService, this.stateService); - case DirectoryType.OneLogin: - return new OneLoginDirectoryService(this.logService, this.i18nService, this.stateService); - default: - return null; - } - } - private buildRequest( groups: GroupEntry[], users: UserEntry[], removeDisabled: boolean, overwriteExisting: boolean, largeImport = false, - ) { - return new OrganizationImportRequest({ - groups: (groups ?? []).map((g) => { - return { - name: g.name, - externalId: g.externalId, - memberExternalIds: Array.from(g.userMemberExternalIds), - }; - }), - users: (users ?? []).map((u) => { - return { - email: u.email, - externalId: u.externalId, - deleted: u.deleted || (removeDisabled && u.disabled), - }; - }), - overwriteExisting: overwriteExisting, - largeImport: largeImport, - }); + ): OrganizationImportRequest[] { + if (largeImport && groups.length + users.length > 2000) { + return this.batchRequestBuilder.buildRequest( + groups, + users, + overwriteExisting, + removeDisabled, + ); + } else { + return this.singleRequestBuilder.buildRequest( + groups, + users, + overwriteExisting, + removeDisabled, + ); + } } private async saveSyncTimes(syncConfig: SyncConfiguration, time: Date) {