diff --git a/jslib/common/src/enums/stateVersion.ts b/jslib/common/src/enums/stateVersion.ts index 7f7879db..476d89ad 100644 --- a/jslib/common/src/enums/stateVersion.ts +++ b/jslib/common/src/enums/stateVersion.ts @@ -3,6 +3,6 @@ export enum StateVersion { Two = 2, // Move to a typed State object Three = 3, // Fix migration of users' premium status Four = 4, // Fix 'Never Lock' option by removing stale data - Five = 5, // DC: Flatten Account model, remove BaseAccount inheritance + Five = 5, // New state service implementation Latest = Five, } diff --git a/src/abstractions/state-vNext.service.ts b/src/abstractions/state-vNext.service.ts new file mode 100644 index 00000000..8d4827d0 --- /dev/null +++ b/src/abstractions/state-vNext.service.ts @@ -0,0 +1,54 @@ +import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions"; + +import { DirectoryType } from "@/src/enums/directoryType"; +import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration"; +import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; +import { LdapConfiguration } from "@/src/models/ldapConfiguration"; +import { OktaConfiguration } from "@/src/models/oktaConfiguration"; +import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; +import { SyncConfiguration } from "@/src/models/syncConfiguration"; + +export abstract class StateServiceVNext { + getDirectory: (type: DirectoryType) => Promise; + setDirectory: ( + type: DirectoryType, + config: + | LdapConfiguration + | GSuiteConfiguration + | EntraIdConfiguration + | OktaConfiguration + | OneLoginConfiguration, + ) => Promise; + getLdapConfiguration: (options?: StorageOptions) => Promise; + setLdapConfiguration: (value: LdapConfiguration, options?: StorageOptions) => Promise; + getGsuiteConfiguration: (options?: StorageOptions) => Promise; + setGsuiteConfiguration: (value: GSuiteConfiguration, options?: StorageOptions) => Promise; + getEntraConfiguration: (options?: StorageOptions) => Promise; + setEntraConfiguration: (value: EntraIdConfiguration, options?: StorageOptions) => Promise; + getOktaConfiguration: (options?: StorageOptions) => Promise; + setOktaConfiguration: (value: OktaConfiguration, options?: StorageOptions) => Promise; + getOneLoginConfiguration: (options?: StorageOptions) => Promise; + setOneLoginConfiguration: ( + value: OneLoginConfiguration, + options?: StorageOptions, + ) => Promise; + getOrganizationId: (options?: StorageOptions) => Promise; + setOrganizationId: (value: string, options?: StorageOptions) => Promise; + getSync: (options?: StorageOptions) => Promise; + setSync: (value: SyncConfiguration, options?: StorageOptions) => Promise; + getDirectoryType: (options?: StorageOptions) => Promise; + setDirectoryType: (value: DirectoryType, options?: StorageOptions) => Promise; + getUserDelta: (options?: StorageOptions) => Promise; + setUserDelta: (value: string, options?: StorageOptions) => Promise; + getLastUserSync: (options?: StorageOptions) => Promise; + setLastUserSync: (value: Date, options?: StorageOptions) => Promise; + getLastGroupSync: (options?: StorageOptions) => Promise; + setLastGroupSync: (value: Date, options?: StorageOptions) => Promise; + getGroupDelta: (options?: StorageOptions) => Promise; + setGroupDelta: (value: string, options?: StorageOptions) => Promise; + getLastSyncHash: (options?: StorageOptions) => Promise; + setLastSyncHash: (value: string, options?: StorageOptions) => Promise; + getSyncingDir: (options?: StorageOptions) => Promise; + setSyncingDir: (value: boolean, options?: StorageOptions) => Promise; + clearSyncSettings: (syncHashToo: boolean) => Promise; +} diff --git a/src/app/services/services.module.ts b/src/app/services/services.module.ts index 9457980e..1f5afd3c 100644 --- a/src/app/services/services.module.ts +++ b/src/app/services/services.module.ts @@ -31,12 +31,14 @@ import { DefaultDirectoryFactoryService } from "@/src/services/directory-factory import { SingleRequestBuilder } from "@/src/services/single-request-builder"; import { AuthService as AuthServiceAbstraction } from "../../abstractions/auth.service"; +import { StateServiceVNext } from "../../abstractions/state-vNext.service"; import { StateService as StateServiceAbstraction } from "../../abstractions/state.service"; import { Account } from "../../models/account"; import { AuthService } from "../../services/auth.service"; import { I18nService } from "../../services/i18n.service"; -import { StateService } from "../../services/state.service"; -import { StateMigrationService } from "../../services/stateMigration.service"; +import { StateServiceVNextImplementation } from "../../services/state-service/state-vNext.service"; +import { StateService } from "../../services/state-service/state.service"; +import { StateMigrationService } from "../../services/state-service/stateMigration.service"; import { SyncService } from "../../services/sync.service"; import { AuthGuardService } from "./auth-guard.service"; @@ -222,6 +224,29 @@ export function initFactory( StateMigrationServiceAbstraction, ], }), + // Use new StateServiceVNext with flat key-value structure (new interface) + safeProvider({ + provide: StateServiceVNext, + useFactory: ( + storageService: StorageServiceAbstraction, + secureStorageService: StorageServiceAbstraction, + logService: LogServiceAbstraction, + stateMigrationService: StateMigrationServiceAbstraction, + ) => + new StateServiceVNextImplementation( + storageService, + secureStorageService, + logService, + stateMigrationService, + true, + ), + deps: [ + StorageServiceAbstraction, + SECURE_STORAGE, + LogServiceAbstraction, + StateMigrationServiceAbstraction, + ], + }), safeProvider({ provide: SingleRequestBuilder, deps: [], @@ -233,7 +258,12 @@ export function initFactory( safeProvider({ provide: DirectoryFactoryService, useClass: DefaultDirectoryFactoryService, - deps: [LogServiceAbstraction, I18nServiceAbstraction, StateServiceAbstraction], + deps: [ + LogServiceAbstraction, + I18nServiceAbstraction, + StateServiceAbstraction, + StateServiceVNext, + ], }), ] satisfies SafeProvider[], }) diff --git a/src/bwdc.ts b/src/bwdc.ts index 030910d0..48d5f702 100644 --- a/src/bwdc.ts +++ b/src/bwdc.ts @@ -22,6 +22,7 @@ import { NodeCryptoFunctionService } from "@/jslib/node/src/services/nodeCryptoF import packageJson from "../package.json"; import { DirectoryFactoryService } from "./abstractions/directory-factory.service"; +import { StateServiceVNext } from "./abstractions/state-vNext.service"; import { Account } from "./models/account"; import { Program } from "./program"; import { AuthService } from "./services/auth.service"; @@ -31,8 +32,9 @@ import { I18nService } from "./services/i18n.service"; import { KeytarSecureStorageService } from "./services/keytarSecureStorage.service"; import { LowdbStorageService } from "./services/lowdbStorage.service"; import { SingleRequestBuilder } from "./services/single-request-builder"; -import { StateService } from "./services/state.service"; -import { StateMigrationService } from "./services/stateMigration.service"; +import { StateServiceVNextImplementation } from "./services/state-service/state-vNext.service"; +import { StateService } from "./services/state-service/state.service"; +import { StateMigrationService } from "./services/state-service/stateMigration.service"; import { SyncService } from "./services/sync.service"; // ESM __dirname polyfill for Node 20 @@ -59,6 +61,7 @@ export class Main { cryptoFunctionService: NodeCryptoFunctionService; authService: AuthService; syncService: SyncService; + stateServiceVNext: StateServiceVNext; stateService: StateService; stateMigrationService: StateMigrationService; directoryFactoryService: DirectoryFactoryService; @@ -122,6 +125,14 @@ export class Main { process.env.BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS !== "true", new StateFactory(GlobalState, Account), ); + // Use new StateServiceVNext with flat key-value structure + this.stateServiceVNext = new StateServiceVNextImplementation( + this.storageService, + this.secureStorageService, + this.logService, + this.stateMigrationService, + process.env.BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS !== "true", + ); this.cryptoService = new CryptoService( this.cryptoFunctionService, @@ -163,6 +174,7 @@ export class Main { this.logService, this.i18nService, this.stateService, + this.stateServiceVNext, ); this.batchRequestBuilder = new BatchRequestBuilder(); diff --git a/src/main.ts b/src/main.ts index 64906647..b37def18 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,12 +14,14 @@ import { TrayMain } from "@/jslib/electron/src/tray.main"; import { UpdaterMain } from "@/jslib/electron/src/updater.main"; import { WindowMain } from "@/jslib/electron/src/window.main"; +import { StateServiceVNext } from "./abstractions/state-vNext.service"; import { DCCredentialStorageListener } from "./main/credential-storage-listener"; import { MenuMain } from "./main/menu.main"; import { MessagingMain } from "./main/messaging.main"; import { Account } from "./models/account"; import { I18nService } from "./services/i18n.service"; -import { StateService } from "./services/state.service"; +import { StateServiceVNextImplementation } from "./services/state-service/state-vNext.service"; +import { StateService } from "./services/state-service/state.service"; // ESM __dirname polyfill for Node 20 @@ -34,6 +36,7 @@ export class Main { storageService: ElectronStorageService; messagingService: ElectronMainMessagingService; credentialStorageListener: DCCredentialStorageListener; + stateServiceVNext: StateServiceVNext; stateService: StateService; windowMain: WindowMain; @@ -75,6 +78,14 @@ export class Main { true, new StateFactory(GlobalState, Account), ); + // Use new StateServiceVNext with flat key-value structure + this.stateServiceVNext = new StateServiceVNextImplementation( + this.storageService, + null, + this.logService, + null, + true, + ); this.windowMain = new WindowMain( this.stateService, diff --git a/src/models/state.model.ts b/src/models/state.model.ts new file mode 100644 index 00000000..12610312 --- /dev/null +++ b/src/models/state.model.ts @@ -0,0 +1,108 @@ +// =================================================================== +// vNext Storage Keys (Flat key-value structure) +// =================================================================== + +export const StorageKeysVNext = { + stateVersion: "stateVersion", + directoryType: "directoryType", + organizationId: "organizationId", + directory_ldap: "directory_ldap", + directory_gsuite: "directory_gsuite", + directory_entra: "directory_entra", + directory_okta: "directory_okta", + directory_onelogin: "directory_onelogin", + sync: "sync", + syncingDir: "syncingDir", +}; + +export const SecureStorageKeysVNext: { [key: string]: any } = { + ldap: "secret_ldap", + gsuite: "secret_gsuite", + // Azure Active Directory was renamed to Entra ID, but we've kept the old property name + // to be backwards compatible with existing configurations. + azure: "secret_azure", + entra: "secret_entra", + okta: "secret_okta", + oneLogin: "secret_oneLogin", + userDelta: "userDeltaToken", + groupDelta: "groupDeltaToken", + lastUserSync: "lastUserSync", + lastGroupSync: "lastGroupSync", + lastSyncHash: "lastSyncHash", +}; + +// =================================================================== +// Legacy Storage Keys (Account-based hierarchy) +// =================================================================== + +export const SecureStorageKeysLegacy = { + ldap: "ldapPassword", + gsuite: "gsuitePrivateKey", + // Azure Active Directory was renamed to Entra ID, but we've kept the old property name + // to be backwards compatible with existing configurations. + azure: "azureKey", + entra: "entraKey", + okta: "oktaToken", + oneLogin: "oneLoginClientSecret", + userDelta: "userDeltaToken", + groupDelta: "groupDeltaToken", + lastUserSync: "lastUserSync", + lastGroupSync: "lastGroupSync", + lastSyncHash: "lastSyncHash", +}; + +export const TempKeys = { + tempAccountSettings: "tempAccountSettings", + tempDirectoryConfigs: "tempDirectoryConfigs", + tempDirectorySettings: "tempDirectorySettings", +}; + +// =================================================================== +// Migration Storage Keys +// =================================================================== + +export const SecureStorageKeysMigration: { [key: string]: any } = { + ldap: "ldapPassword", + gsuite: "gsuitePrivateKey", + azure: "azureKey", + entra: "entraIdKey", + okta: "oktaToken", + oneLogin: "oneLoginClientSecret", + directoryConfigPrefix: "directoryConfig_", + sync: "syncConfig", + directoryType: "directoryType", + organizationId: "organizationId", +}; + +export const MigrationKeys: { [key: string]: any } = { + entityId: "entityId", + directoryType: "directoryType", + organizationId: "organizationId", + lastUserSync: "lastUserSync", + lastGroupSync: "lastGroupSync", + lastSyncHash: "lastSyncHash", + syncingDir: "syncingDir", + syncConfig: "syncConfig", + userDelta: "userDeltaToken", + groupDelta: "groupDeltaToken", + tempDirectoryConfigs: "tempDirectoryConfigs", + tempDirectorySettings: "tempDirectorySettings", +}; + +export const MigrationStateKeys = { + global: "global", + authenticatedAccounts: "authenticatedAccounts", +}; + +export const MigrationClientKeys: { [key: string]: any } = { + clientIdOld: "clientId", + clientId: "apikey_clientId", + clientSecretOld: "clientSecret", + clientSecret: "apikey_clientSecret", +}; + +// =================================================================== +// Shared Constants +// =================================================================== + +export const StoredSecurely = "[STORED SECURELY]"; diff --git a/src/services/authService.spec.ts b/src/services/authService.spec.ts index 7d2a8161..bff9c920 100644 --- a/src/services/authService.spec.ts +++ b/src/services/authService.spec.ts @@ -10,7 +10,7 @@ import { IdentityTokenResponse } from "@/jslib/common/src/models/response/identi import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account"; import { AuthService } from "./auth.service"; -import { StateService } from "./state.service"; +import { StateService } from "./state-service/state.service"; const clientId = "organization.CLIENT_ID"; const clientSecret = "CLIENT_SECRET"; diff --git a/src/services/directory-factory.service.ts b/src/services/directory-factory.service.ts index eccea5c2..0d1e80a4 100644 --- a/src/services/directory-factory.service.ts +++ b/src/services/directory-factory.service.ts @@ -2,6 +2,7 @@ import { I18nService } from "@/jslib/common/src/abstractions/i18n.service"; import { LogService } from "@/jslib/common/src/abstractions/log.service"; import { DirectoryFactoryService } from "../abstractions/directory-factory.service"; +import { StateServiceVNext } from "../abstractions/state-vNext.service"; import { StateService } from "../abstractions/state.service"; import { DirectoryType } from "../enums/directoryType"; @@ -16,12 +17,18 @@ export class DefaultDirectoryFactoryService implements DirectoryFactoryService { private logService: LogService, private i18nService: I18nService, private stateService: StateService, + private stateServiceVNext: StateServiceVNext, ) {} createService(directoryType: DirectoryType) { switch (directoryType) { case DirectoryType.GSuite: - return new GSuiteDirectoryService(this.logService, this.i18nService, this.stateService); + return new GSuiteDirectoryService( + this.logService, + this.i18nService, + this.stateService, + this.stateServiceVNext, + ); case DirectoryType.EntraID: return new EntraIdDirectoryService(this.logService, this.i18nService, this.stateService); case DirectoryType.Ldap: diff --git a/src/services/directory-services/gsuite-directory.service.integration.spec.ts b/src/services/directory-services/gsuite-directory.service.integration.spec.ts index 397e594c..07521a19 100644 --- a/src/services/directory-services/gsuite-directory.service.integration.spec.ts +++ b/src/services/directory-services/gsuite-directory.service.integration.spec.ts @@ -1,6 +1,8 @@ import { config as dotenvConfig } from "dotenv"; import { mock, MockProxy } from "jest-mock-extended"; +import { StateServiceVNext } from "@/src/abstractions/state-vNext.service"; + import { I18nService } from "../../../jslib/common/src/abstractions/i18n.service"; import { LogService } from "../../../jslib/common/src/abstractions/log.service"; import { @@ -10,7 +12,7 @@ import { import { groupFixtures } from "../../../utils/google-workspace/group-fixtures"; import { userFixtures } from "../../../utils/google-workspace/user-fixtures"; import { DirectoryType } from "../../enums/directoryType"; -import { StateService } from "../state.service"; +import { StateService } from "../state-service/state.service"; import { GSuiteDirectoryService } from "./gsuite-directory.service"; @@ -35,6 +37,7 @@ describe("gsuiteDirectoryService", () => { let logService: MockProxy; let i18nService: MockProxy; let stateService: MockProxy; + let stateServiceVNext: MockProxy; let directoryService: GSuiteDirectoryService; @@ -42,23 +45,31 @@ describe("gsuiteDirectoryService", () => { logService = mock(); i18nService = mock(); stateService = mock(); + stateServiceVNext = mock(); - stateService.getDirectoryType.mockResolvedValue(DirectoryType.GSuite); + stateServiceVNext.getDirectoryType.mockResolvedValue(DirectoryType.GSuite); stateService.getLastUserSync.mockResolvedValue(null); // do not filter results by last modified date i18nService.t.mockImplementation((id) => id); // passthrough implementation for any error messages - directoryService = new GSuiteDirectoryService(logService, i18nService, stateService); + directoryService = new GSuiteDirectoryService( + logService, + i18nService, + stateService, + stateServiceVNext, + ); }); it("syncs without using filters (includes test data)", async () => { const directoryConfig = getGSuiteConfiguration(); - stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + stateServiceVNext.getDirectory + .calledWith(DirectoryType.GSuite) + .mockResolvedValue(directoryConfig); const syncConfig = getSyncConfiguration({ groups: true, users: true, }); - stateService.getSync.mockResolvedValue(syncConfig); + stateServiceVNext.getSync.mockResolvedValue(syncConfig); const result = await directoryService.getEntries(true, true); @@ -68,7 +79,9 @@ describe("gsuiteDirectoryService", () => { it("syncs using user and group filters (exact match for test data)", async () => { const directoryConfig = getGSuiteConfiguration(); - stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + stateServiceVNext.getDirectory + .calledWith(DirectoryType.GSuite) + .mockResolvedValue(directoryConfig); const syncConfig = getSyncConfiguration({ groups: true, @@ -76,7 +89,7 @@ describe("gsuiteDirectoryService", () => { userFilter: INTEGRATION_USER_FILTER, groupFilter: INTEGRATION_GROUP_FILTER, }); - stateService.getSync.mockResolvedValue(syncConfig); + stateServiceVNext.getSync.mockResolvedValue(syncConfig); const result = await directoryService.getEntries(true, true); diff --git a/src/services/directory-services/gsuite-directory.service.ts b/src/services/directory-services/gsuite-directory.service.ts index 4e8be9a5..9e710e48 100644 --- a/src/services/directory-services/gsuite-directory.service.ts +++ b/src/services/directory-services/gsuite-directory.service.ts @@ -4,6 +4,8 @@ import { admin_directory_v1, google } from "googleapis"; import { I18nService } from "@/jslib/common/src/abstractions/i18n.service"; import { LogService } from "@/jslib/common/src/abstractions/log.service"; +import { StateServiceVNext } from "@/src/abstractions/state-vNext.service"; + import { StateService } from "../../abstractions/state.service"; import { DirectoryType } from "../../enums/directoryType"; import { GroupEntry } from "../../models/groupEntry"; @@ -25,25 +27,26 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir private logService: LogService, private i18nService: I18nService, private stateService: StateService, + private stateServiceVNext: StateServiceVNext, ) { super(); this.service = google.admin("directory_v1"); } async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> { - const type = await this.stateService.getDirectoryType(); + const type = await this.stateServiceVNext.getDirectoryType(); if (type !== DirectoryType.GSuite) { return; } - this.dirConfig = await this.stateService.getDirectory( + this.dirConfig = await this.stateServiceVNext.getDirectory( DirectoryType.GSuite, ); if (this.dirConfig == null) { return; } - this.syncConfig = await this.stateService.getSync(); + this.syncConfig = await this.stateServiceVNext.getSync(); if (this.syncConfig == null) { return; } diff --git a/src/services/directory-services/ldap-directory.service.integration.spec.ts b/src/services/directory-services/ldap-directory.service.integration.spec.ts index 67d1be55..c230b656 100644 --- a/src/services/directory-services/ldap-directory.service.integration.spec.ts +++ b/src/services/directory-services/ldap-directory.service.integration.spec.ts @@ -9,7 +9,7 @@ import { import { groupFixtures } from "../../../utils/openldap/group-fixtures"; import { userFixtures } from "../../../utils/openldap/user-fixtures"; import { DirectoryType } from "../../enums/directoryType"; -import { StateService } from "../state.service"; +import { StateService } from "../state-service/state.service"; import { LdapDirectoryService } from "./ldap-directory.service"; diff --git a/src/services/state-service/state-vNext.service.spec.ts b/src/services/state-service/state-vNext.service.spec.ts new file mode 100644 index 00000000..7f144737 --- /dev/null +++ b/src/services/state-service/state-vNext.service.spec.ts @@ -0,0 +1,488 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { LogService } from "@/jslib/common/src/abstractions/log.service"; +import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service"; +import { StorageService } from "@/jslib/common/src/abstractions/storage.service"; + +import { DirectoryType } from "@/src/enums/directoryType"; +import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration"; +import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; +import { LdapConfiguration } from "@/src/models/ldapConfiguration"; +import { OktaConfiguration } from "@/src/models/oktaConfiguration"; +import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; +import { StorageKeysVNext as StorageKeys, StoredSecurely } from "@/src/models/state.model"; +import { SyncConfiguration } from "@/src/models/syncConfiguration"; + +import { StateServiceVNextImplementation } from "./state-vNext.service"; + +describe("StateServiceVNextImplementation", () => { + let storageService: MockProxy; + let secureStorageService: MockProxy; + let logService: MockProxy; + let stateMigrationService: MockProxy; + let stateService: StateServiceVNextImplementation; + + beforeEach(() => { + storageService = mock(); + secureStorageService = mock(); + logService = mock(); + stateMigrationService = mock(); + + stateService = new StateServiceVNextImplementation( + storageService, + secureStorageService, + logService, + stateMigrationService, + true, // useSecureStorageForSecrets + ); + }); + + describe("init", () => { + it("should run migration if needed", async () => { + stateMigrationService.needsMigration.mockResolvedValue(true); + + await stateService.init(); + + expect(stateMigrationService.needsMigration).toHaveBeenCalled(); + expect(stateMigrationService.migrate).toHaveBeenCalled(); + }); + + it("should not run migration if not needed", async () => { + stateMigrationService.needsMigration.mockResolvedValue(false); + + await stateService.init(); + + expect(stateMigrationService.needsMigration).toHaveBeenCalled(); + expect(stateMigrationService.migrate).not.toHaveBeenCalled(); + }); + }); + + describe("clean", () => { + it("should clear all directory settings and configurations", async () => { + await stateService.clean(); + + // Verify all directory types are cleared + expect(storageService.save).toHaveBeenCalledWith(StorageKeys.directoryType, null); + expect(storageService.save).toHaveBeenCalledWith(StorageKeys.organizationId, null); + expect(storageService.save).toHaveBeenCalledWith(StorageKeys.sync, null); + }); + }); + + describe("Directory Type", () => { + it("should store and retrieve directory type", async () => { + storageService.get.mockResolvedValue(DirectoryType.Ldap); + + await stateService.setDirectoryType(DirectoryType.Ldap); + const result = await stateService.getDirectoryType(); + + expect(storageService.save).toHaveBeenCalledWith( + StorageKeys.directoryType, + DirectoryType.Ldap, + ); + expect(result).toBe(DirectoryType.Ldap); + }); + + it("should return null when directory type is not set", async () => { + storageService.get.mockResolvedValue(null); + + const result = await stateService.getDirectoryType(); + + expect(result).toBeNull(); + }); + }); + + describe("Organization Id", () => { + it("should store and retrieve organization ID", async () => { + const orgId = "test-org-123"; + + storageService.get.mockResolvedValue(orgId); + + await stateService.setOrganizationId(orgId); + const result = await stateService.getOrganizationId(); + + expect(storageService.save).toHaveBeenCalledWith(StorageKeys.organizationId, orgId); + expect(result).toBe(orgId); + }); + }); + + describe("LDAP Configuration", () => { + it("should store and retrieve LDAP configuration with secrets in secure storage", async () => { + const config: LdapConfiguration = { + ssl: true, + startTls: false, + tlsCaPath: null, + sslAllowUnauthorized: false, + sslCertPath: null, + sslKeyPath: null, + sslCaPath: null, + hostname: "ldap.example.com", + port: 636, + domain: null, + rootPath: null, + ad: true, + username: "admin", + password: "secret-password", + currentUser: false, + pagedSearch: true, + }; + + secureStorageService.get.mockResolvedValue("secret-password"); + storageService.get.mockResolvedValue({ + ...config, + password: StoredSecurely, + }); + + await stateService.setDirectory(DirectoryType.Ldap, config); + const result = await stateService.getDirectory(DirectoryType.Ldap); + + // Verify password is stored in secure storage + expect(secureStorageService.save).toHaveBeenCalled(); + + // Verify configuration is stored + expect(storageService.save).toHaveBeenCalled(); + + // Verify retrieved config has real password from secure storage + expect(result?.password).toBe("secret-password"); + }); + + it("should return null when LDAP configuration is not set", async () => { + storageService.get.mockResolvedValue(null); + + const result = await stateService.getLdapConfiguration(); + + expect(result).toBeNull(); + }); + + it("should handle null password in LDAP configuration", async () => { + const config: LdapConfiguration = { + ssl: true, + startTls: false, + tlsCaPath: null, + sslAllowUnauthorized: false, + sslCertPath: null, + sslKeyPath: null, + sslCaPath: null, + hostname: "ldap.example.com", + port: 636, + domain: null, + rootPath: null, + ad: true, + username: "admin", + password: null, + currentUser: false, + pagedSearch: true, + }; + + await stateService.setDirectory(DirectoryType.Ldap, config); + + // Null passwords should call remove on the secure storage secret key + expect(secureStorageService.remove).toHaveBeenCalled(); + }); + }); + + describe("GSuite Configuration", () => { + it("should store and retrieve GSuite configuration with privateKey in secure storage", async () => { + const config: GSuiteConfiguration = { + domain: "example.com", + clientEmail: "service@example.com", + adminUser: "admin@example.com", + privateKey: "private-key-content", + customer: "customer-id", + }; + + secureStorageService.get.mockResolvedValue("private-key-content"); + storageService.get.mockResolvedValue({ + ...config, + privateKey: StoredSecurely, + }); + + await stateService.setDirectory(DirectoryType.GSuite, config); + const result = await stateService.getDirectory(DirectoryType.GSuite); + + expect(secureStorageService.save).toHaveBeenCalled(); + expect(result?.privateKey).toBe("private-key-content"); + }); + + it("should handle null privateKey in GSuite configuration", async () => { + const config: GSuiteConfiguration = { + domain: "example.com", + clientEmail: "service@example.com", + adminUser: "admin@example.com", + privateKey: null, + customer: "customer-id", + }; + + await stateService.setDirectory(DirectoryType.GSuite, config); + + // Null privateKey should call remove on the secure storage secret key + expect(secureStorageService.remove).toHaveBeenCalled(); + }); + }); + + describe("Entra ID Configuration", () => { + it("should store and retrieve Entra ID configuration with key in secure storage", async () => { + const config: EntraIdConfiguration = { + identityAuthority: "https://login.microsoftonline.com", + tenant: "tenant-id", + applicationId: "app-id", + key: "secret-key", + }; + + secureStorageService.get.mockResolvedValue("secret-key"); + storageService.get.mockResolvedValue({ + ...config, + key: StoredSecurely, + }); + + await stateService.setDirectory(DirectoryType.EntraID, config); + const result = await stateService.getDirectory(DirectoryType.EntraID); + + expect(secureStorageService.save).toHaveBeenCalled(); + expect(result?.key).toBe("secret-key"); + }); + + it("should maintain backwards compatibility with Azure key storage", async () => { + const config: EntraIdConfiguration = { + identityAuthority: "https://login.microsoftonline.com", + tenant: "tenant-id", + applicationId: "app-id", + key: StoredSecurely, + }; + + storageService.get.mockResolvedValue(config); + secureStorageService.get.mockResolvedValueOnce(null); // entra key not found + secureStorageService.get.mockResolvedValueOnce("azure-secret-key"); // fallback to azure key + + const result = await stateService.getDirectory(DirectoryType.EntraID); + + expect(secureStorageService.get).toHaveBeenCalled(); + expect(result?.key).toBe("azure-secret-key"); + }); + }); + + describe("Okta Configuration", () => { + it("should store and retrieve Okta configuration with token in secure storage", async () => { + const config: OktaConfiguration = { + orgUrl: "https://example.okta.com", + token: "okta-token", + }; + + secureStorageService.get.mockResolvedValue("okta-token"); + storageService.get.mockResolvedValue({ + ...config, + token: StoredSecurely, + }); + + await stateService.setDirectory(DirectoryType.Okta, config); + const result = await stateService.getDirectory(DirectoryType.Okta); + + expect(secureStorageService.save).toHaveBeenCalled(); + expect(result?.token).toBe("okta-token"); + }); + }); + + describe("OneLogin Configuration", () => { + it("should store and retrieve OneLogin configuration with clientSecret in secure storage", async () => { + const config: OneLoginConfiguration = { + region: "us", + clientId: "client-id", + clientSecret: "client-secret", + }; + + secureStorageService.get.mockResolvedValue("client-secret"); + storageService.get.mockResolvedValue({ + ...config, + clientSecret: StoredSecurely, + }); + + await stateService.setDirectory(DirectoryType.OneLogin, config); + const result = await stateService.getDirectory(DirectoryType.OneLogin); + + expect(secureStorageService.save).toHaveBeenCalled(); + expect(result?.clientSecret).toBe("client-secret"); + }); + }); + + describe("Sync Configuration", () => { + it("should store and retrieve sync configuration", async () => { + const syncConfig: SyncConfiguration = { + users: true, + groups: true, + interval: 5, + userFilter: null, + groupFilter: null, + removeDisabled: true, + overwriteExisting: false, + largeImport: false, + groupObjectClass: null, + userObjectClass: null, + groupPath: null, + userPath: null, + groupNameAttribute: null, + userEmailAttribute: null, + memberAttribute: "member", + creationDateAttribute: "whenCreated", + revisionDateAttribute: "whenChanged", + useEmailPrefixSuffix: false, + emailPrefixAttribute: null, + emailSuffix: null, + }; + + storageService.get.mockResolvedValue(syncConfig); + + await stateService.setSync(syncConfig); + const result = await stateService.getSync(); + + expect(storageService.save).toHaveBeenCalledWith(StorageKeys.sync, syncConfig); + expect(result).toEqual(syncConfig); + }); + }); + + describe("Sync Settings", () => { + it("should clear sync settings when clearSyncSettings is called", async () => { + await stateService.clearSyncSettings(false); + + // Should set delta and sync values to null + expect(storageService.save).toHaveBeenCalled(); + }); + + it("should clear lastSyncHash when hashToo is true", async () => { + await stateService.clearSyncSettings(true); + + // Should set all values including lastSyncHash to null + expect(storageService.save).toHaveBeenCalled(); + }); + + it("should not clear lastSyncHash when hashToo is false", async () => { + await stateService.clearSyncSettings(false); + + // Should set delta and sync values but not lastSyncHash + expect(storageService.save).toHaveBeenCalled(); + }); + }); + + describe("Last Sync Hash", () => { + it("should store and retrieve last sync hash", async () => { + const hash = "hash"; + + storageService.get.mockResolvedValue(hash); + + await stateService.setLastSyncHash(hash); + const result = await stateService.getLastSyncHash(); + + expect(storageService.save).toHaveBeenCalled(); + expect(result).toBe(hash); + }); + }); + + describe("Delta Tokens", () => { + it("should store and retrieve user delta token", async () => { + const token = "user-delta-token"; + + storageService.get.mockResolvedValue(token); + + await stateService.setUserDelta(token); + const result = await stateService.getUserDelta(); + + expect(storageService.save).toHaveBeenCalled(); + expect(result).toBe(token); + }); + + it("should store and retrieve group delta token", async () => { + const token = "group-delta-token"; + + storageService.get.mockResolvedValue(token); + + await stateService.setGroupDelta(token); + const result = await stateService.getGroupDelta(); + + expect(storageService.save).toHaveBeenCalled(); + expect(result).toBe(token); + }); + }); + + describe("Last Sync Timestamps", () => { + it("should store and retrieve last user sync timestamp", async () => { + const timestamp = new Date("2024-01-01T00:00:00Z"); + + storageService.get.mockResolvedValue(timestamp.toISOString()); + + await stateService.setLastUserSync(timestamp); + const result = await stateService.getLastUserSync(); + + expect(storageService.save).toHaveBeenCalled(); + expect(result?.toISOString()).toBe(timestamp.toISOString()); + }); + + it("should store and retrieve last group sync timestamp", async () => { + const timestamp = new Date("2024-01-01T00:00:00Z"); + + storageService.get.mockResolvedValue(timestamp.toISOString()); + + await stateService.setLastGroupSync(timestamp); + const result = await stateService.getLastGroupSync(); + + expect(storageService.save).toHaveBeenCalled(); + expect(result?.toISOString()).toBe(timestamp.toISOString()); + }); + + it("should return null when last user sync timestamp is not set", async () => { + storageService.get.mockResolvedValue(null); + + const result = await stateService.getLastUserSync(); + + expect(result).toBeNull(); + }); + + it("should return null when last group sync timestamp is not set", async () => { + storageService.get.mockResolvedValue(null); + + const result = await stateService.getLastGroupSync(); + + expect(result).toBeNull(); + }); + }); + + describe("Secure Storage Flag", () => { + it("should not separate secrets when useSecureStorageForSecrets is false", async () => { + const insecureStateService = new StateServiceVNextImplementation( + storageService, + secureStorageService, + logService, + stateMigrationService, + false, // useSecureStorageForSecrets = false + ); + + const config: LdapConfiguration = { + ssl: true, + startTls: false, + tlsCaPath: null, + sslAllowUnauthorized: false, + sslCertPath: null, + sslKeyPath: null, + sslCaPath: null, + hostname: "ldap.example.com", + port: 636, + domain: null, + rootPath: null, + ad: true, + username: "admin", + password: "secret-password", + currentUser: false, + pagedSearch: true, + }; + + storageService.get.mockResolvedValue(config); + + // When useSecureStorageForSecrets is false, setDirectory doesn't process secrets + await insecureStateService.setDirectory(DirectoryType.Ldap, config); + + // Retrieve config - should return password as-is from storage (not from secure storage) + const result = await insecureStateService.getDirectory(DirectoryType.Ldap); + + // Password should be retrieved directly from storage, not secure storage + expect(result?.password).toBe("secret-password"); + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/state-service/state-vNext.service.ts b/src/services/state-service/state-vNext.service.ts new file mode 100644 index 00000000..71a9156a --- /dev/null +++ b/src/services/state-service/state-vNext.service.ts @@ -0,0 +1,409 @@ +import { LogService } from "@/jslib/common/src/abstractions/log.service"; +import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service"; +import { StorageService } from "@/jslib/common/src/abstractions/storage.service"; +import { EnvironmentUrls } from "@/jslib/common/src/models/domain/environmentUrls"; +import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions"; + +import { StateServiceVNext as StateServiceVNextAbstraction } from "@/src/abstractions/state-vNext.service"; +import { DirectoryType } from "@/src/enums/directoryType"; +import { IConfiguration } from "@/src/models/IConfiguration"; +import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration"; +import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; +import { LdapConfiguration } from "@/src/models/ldapConfiguration"; +import { OktaConfiguration } from "@/src/models/oktaConfiguration"; +import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; +import { + SecureStorageKeysVNext as SecureStorageKeys, + StorageKeysVNext as StorageKeys, + StoredSecurely, +} from "@/src/models/state.model"; +import { SyncConfiguration } from "@/src/models/syncConfiguration"; + +export class StateServiceVNextImplementation implements StateServiceVNextAbstraction { + constructor( + protected storageService: StorageService, + protected secureStorageService: StorageService, + protected logService: LogService, + protected stateMigrationService: StateMigrationService, + private useSecureStorageForSecrets = true, + ) {} + + async init(): Promise { + if (await this.stateMigrationService.needsMigration()) { + await this.stateMigrationService.migrate(); + } + } + + async clean(options?: StorageOptions): Promise { + // Clear all directory settings and configurations + // but preserve version and environment settings + await this.setDirectoryType(null); + await this.setOrganizationId(null); + await this.setSync(null); + await this.setLdapConfiguration(null); + await this.setGsuiteConfiguration(null); + await this.setEntraConfiguration(null); + await this.setOktaConfiguration(null); + await this.setOneLoginConfiguration(null); + await this.clearSyncSettings(true); + } + + // =================================================================== + // Directory Configuration Methods + // =================================================================== + + async getDirectory(type: DirectoryType): Promise { + const config = await this.getConfiguration(type); + if (config == null) { + return config as T; + } + + if (this.useSecureStorageForSecrets) { + // Create a copy to avoid modifying the cached config + const configWithSecrets = Object.assign({}, config); + + switch (type) { + case DirectoryType.Ldap: + (configWithSecrets as any).password = await this.getLdapSecret(); + break; + case DirectoryType.EntraID: + (configWithSecrets as any).key = await this.getEntraSecret(); + break; + case DirectoryType.Okta: + (configWithSecrets as any).token = await this.getOktaSecret(); + break; + case DirectoryType.GSuite: + (configWithSecrets as any).privateKey = await this.getGsuiteSecret(); + break; + case DirectoryType.OneLogin: + (configWithSecrets as any).clientSecret = await this.getOneLoginSecret(); + break; + } + + return configWithSecrets as T; + } + + return config as T; + } + + async setDirectory( + type: DirectoryType, + config: + | LdapConfiguration + | GSuiteConfiguration + | EntraIdConfiguration + | OktaConfiguration + | OneLoginConfiguration, + ): Promise { + if (this.useSecureStorageForSecrets) { + switch (type) { + case DirectoryType.Ldap: { + const ldapConfig = config as LdapConfiguration; + await this.setLdapSecret(ldapConfig.password); + ldapConfig.password = StoredSecurely; + await this.setLdapConfiguration(ldapConfig); + break; + } + case DirectoryType.EntraID: { + const entraConfig = config as EntraIdConfiguration; + await this.setEntraSecret(entraConfig.key); + entraConfig.key = StoredSecurely; + await this.setEntraConfiguration(entraConfig); + break; + } + case DirectoryType.Okta: { + const oktaConfig = config as OktaConfiguration; + await this.setOktaSecret(oktaConfig.token); + oktaConfig.token = StoredSecurely; + await this.setOktaConfiguration(oktaConfig); + break; + } + case DirectoryType.GSuite: { + const gsuiteConfig = config as GSuiteConfiguration; + if (gsuiteConfig.privateKey == null) { + await this.setGsuiteSecret(null); + } else { + const normalizedPrivateKey = gsuiteConfig.privateKey.replace(/\\n/g, "\n"); + await this.setGsuiteSecret(normalizedPrivateKey); + gsuiteConfig.privateKey = StoredSecurely; + } + await this.setGsuiteConfiguration(gsuiteConfig); + break; + } + case DirectoryType.OneLogin: { + const oneLoginConfig = config as OneLoginConfiguration; + await this.setOneLoginSecret(oneLoginConfig.clientSecret); + oneLoginConfig.clientSecret = StoredSecurely; + await this.setOneLoginConfiguration(oneLoginConfig); + break; + } + } + } + } + + async getConfiguration(type: DirectoryType): Promise { + switch (type) { + case DirectoryType.Ldap: + return await this.getLdapConfiguration(); + case DirectoryType.GSuite: + return await this.getGsuiteConfiguration(); + case DirectoryType.EntraID: + return await this.getEntraConfiguration(); + case DirectoryType.Okta: + return await this.getOktaConfiguration(); + case DirectoryType.OneLogin: + return await this.getOneLoginConfiguration(); + } + } + + // =================================================================== + // Secret Storage Methods (Secure Storage) + // =================================================================== + + private async getLdapSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.ldap); + } + + private async setLdapSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.ldap); + } else { + await this.secureStorageService.save(SecureStorageKeys.ldap, value); + } + } + + private async getGsuiteSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.gsuite); + } + + private async setGsuiteSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.gsuite); + } else { + await this.secureStorageService.save(SecureStorageKeys.gsuite, value); + } + } + + private async getEntraSecret(): Promise { + // Try new key first, fall back to old azure key for backwards compatibility + const entraKey = await this.secureStorageService.get(SecureStorageKeys.entra); + if (entraKey != null) { + return entraKey; + } + return await this.secureStorageService.get(SecureStorageKeys.azure); + } + + private async setEntraSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.entra); + await this.secureStorageService.remove(SecureStorageKeys.azure); + } else { + await this.secureStorageService.save(SecureStorageKeys.entra, value); + } + } + + private async getOktaSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.okta); + } + + private async setOktaSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.okta); + } else { + await this.secureStorageService.save(SecureStorageKeys.okta, value); + } + } + + private async getOneLoginSecret(): Promise { + return await this.secureStorageService.get(SecureStorageKeys.oneLogin); + } + + private async setOneLoginSecret(value: string): Promise { + if (value == null) { + await this.secureStorageService.remove(SecureStorageKeys.oneLogin); + } else { + await this.secureStorageService.save(SecureStorageKeys.oneLogin, value); + } + } + + // =================================================================== + // Directory-Specific Configuration Methods + // =================================================================== + + async getLdapConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_ldap); + } + + async setLdapConfiguration(value: LdapConfiguration, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.directory_ldap, value); + } + + async getGsuiteConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_gsuite); + } + + async setGsuiteConfiguration( + value: GSuiteConfiguration, + options?: StorageOptions, + ): Promise { + await this.storageService.save(StorageKeys.directory_gsuite, value); + } + + async getEntraConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_entra); + } + + async setEntraConfiguration( + value: EntraIdConfiguration, + options?: StorageOptions, + ): Promise { + await this.storageService.save(StorageKeys.directory_entra, value); + } + + async getOktaConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_okta); + } + + async setOktaConfiguration(value: OktaConfiguration, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.directory_okta, value); + } + + async getOneLoginConfiguration(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directory_onelogin); + } + + async setOneLoginConfiguration( + value: OneLoginConfiguration, + options?: StorageOptions, + ): Promise { + await this.storageService.save(StorageKeys.directory_onelogin, value); + } + + // =================================================================== + // Directory Settings Methods + // =================================================================== + + async getOrganizationId(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.organizationId); + } + + async setOrganizationId(value: string, options?: StorageOptions): Promise { + const currentId = await this.getOrganizationId(); + if (currentId !== value) { + await this.clearSyncSettings(); + } + await this.storageService.save(StorageKeys.organizationId, value); + } + + async getSync(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.sync); + } + + async setSync(value: SyncConfiguration, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.sync, value); + } + + async getDirectoryType(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.directoryType); + } + + async setDirectoryType(value: DirectoryType, options?: StorageOptions): Promise { + const currentType = await this.getDirectoryType(); + if (value !== currentType) { + await this.clearSyncSettings(); + } + await this.storageService.save(StorageKeys.directoryType, value); + } + + async getLastUserSync(options?: StorageOptions): Promise { + const dateString = await this.storageService.get(SecureStorageKeys.lastUserSync); + return dateString ? new Date(dateString) : null; + } + + async setLastUserSync(value: Date, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.lastUserSync, value); + } + + async getLastGroupSync(options?: StorageOptions): Promise { + const dateString = await this.storageService.get(SecureStorageKeys.lastGroupSync); + return dateString ? new Date(dateString) : null; + } + + async setLastGroupSync(value: Date, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.lastGroupSync, value); + } + + async getLastSyncHash(options?: StorageOptions): Promise { + return await this.storageService.get(SecureStorageKeys.lastSyncHash); + } + + async setLastSyncHash(value: string, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.lastSyncHash, value); + } + + async getSyncingDir(options?: StorageOptions): Promise { + return await this.storageService.get(StorageKeys.syncingDir); + } + + async setSyncingDir(value: boolean, options?: StorageOptions): Promise { + await this.storageService.save(StorageKeys.syncingDir, value); + } + + async getUserDelta(options?: StorageOptions): Promise { + return await this.storageService.get(SecureStorageKeys.userDelta); + } + + async setUserDelta(value: string, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.userDelta, value); + } + + async getGroupDelta(options?: StorageOptions): Promise { + return await this.storageService.get(SecureStorageKeys.groupDelta); + } + + async setGroupDelta(value: string, options?: StorageOptions): Promise { + await this.storageService.save(SecureStorageKeys.groupDelta, value); + } + + async clearSyncSettings(hashToo = false): Promise { + await this.setUserDelta(null); + await this.setGroupDelta(null); + await this.setLastGroupSync(null); + await this.setLastUserSync(null); + if (hashToo) { + await this.setLastSyncHash(null); + } + } + + // =================================================================== + // Environment URLs (inherited from base, simplified implementation) + // =================================================================== + + async getEnvironmentUrls(options?: StorageOptions): Promise { + return await this.storageService.get("environmentUrls"); + } + + async setEnvironmentUrls(value: EnvironmentUrls): Promise { + await this.storageService.save("environmentUrls", value); + } + + // =================================================================== + // Additional State Methods + // =================================================================== + + async getLocale(options?: StorageOptions): Promise { + return await this.storageService.get("locale"); + } + + async setLocale(value: string, options?: StorageOptions): Promise { + await this.storageService.save("locale", value); + } + + async getInstalledVersion(options?: StorageOptions): Promise { + return await this.storageService.get("installedVersion"); + } + + async setInstalledVersion(value: string, options?: StorageOptions): Promise { + await this.storageService.save("installedVersion", value); + } +} diff --git a/src/services/state.service.ts b/src/services/state-service/state.service.ts similarity index 96% rename from src/services/state.service.ts rename to src/services/state-service/state.service.ts index 25111800..f3d4a8b9 100644 --- a/src/services/state.service.ts +++ b/src/services/state-service/state.service.ts @@ -16,32 +16,13 @@ import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; import { LdapConfiguration } from "@/src/models/ldapConfiguration"; import { OktaConfiguration } from "@/src/models/oktaConfiguration"; import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; +import { + SecureStorageKeysLegacy as SecureStorageKeys, + StoredSecurely, + TempKeys as keys, +} from "@/src/models/state.model"; import { SyncConfiguration } from "@/src/models/syncConfiguration"; -const SecureStorageKeys = { - ldap: "ldapPassword", - gsuite: "gsuitePrivateKey", - // Azure Active Directory was renamed to Entra ID, but we've kept the old property name - // to be backwards compatible with existing configurations. - azure: "azureKey", - entra: "entraKey", - okta: "oktaToken", - oneLogin: "oneLoginClientSecret", - userDelta: "userDeltaToken", - groupDelta: "groupDeltaToken", - lastUserSync: "lastUserSync", - lastGroupSync: "lastGroupSync", - lastSyncHash: "lastSyncHash", -}; - -const keys = { - tempAccountSettings: "tempAccountSettings", - tempDirectoryConfigs: "tempDirectoryConfigs", - tempDirectorySettings: "tempDirectorySettings", -}; - -const StoredSecurely = "[STORED SECURELY]"; - export class StateService extends BaseStateService implements StateServiceAbstraction diff --git a/src/services/stateMigration.service.ts b/src/services/state-service/stateMigration.service.ts similarity index 62% rename from src/services/stateMigration.service.ts rename to src/services/state-service/stateMigration.service.ts index b7ac37b3..96062093 100644 --- a/src/services/stateMigration.service.ts +++ b/src/services/state-service/stateMigration.service.ts @@ -8,48 +8,14 @@ import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration"; import { LdapConfiguration } from "@/src/models/ldapConfiguration"; import { OktaConfiguration } from "@/src/models/oktaConfiguration"; import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration"; +import { + MigrationClientKeys as ClientKeys, + MigrationKeys as Keys, + MigrationStateKeys as StateKeys, + SecureStorageKeysMigration as SecureStorageKeys, +} from "@/src/models/state.model"; import { SyncConfiguration } from "@/src/models/syncConfiguration"; -const SecureStorageKeys: { [key: string]: any } = { - ldap: "ldapPassword", - gsuite: "gsuitePrivateKey", - azure: "azureKey", - entra: "entraIdKey", - okta: "oktaToken", - oneLogin: "oneLoginClientSecret", - directoryConfigPrefix: "directoryConfig_", - sync: "syncConfig", - directoryType: "directoryType", - organizationId: "organizationId", -}; - -const Keys: { [key: string]: any } = { - entityId: "entityId", - directoryType: "directoryType", - organizationId: "organizationId", - lastUserSync: "lastUserSync", - lastGroupSync: "lastGroupSync", - lastSyncHash: "lastSyncHash", - syncingDir: "syncingDir", - syncConfig: "syncConfig", - userDelta: "userDeltaToken", - groupDelta: "groupDeltaToken", - tempDirectoryConfigs: "tempDirectoryConfigs", - tempDirectorySettings: "tempDirectorySettings", -}; - -const StateKeys = { - global: "global", - authenticatedAccounts: "authenticatedAccounts", -}; - -const ClientKeys: { [key: string]: any } = { - clientIdOld: "clientId", - clientId: "apikey_clientId", - clientSecretOld: "clientSecret", - clientSecret: "apikey_clientSecret", -}; - export class StateMigrationService extends BaseStateMigrationService { async migrate(): Promise { let currentStateVersion = await this.getCurrentStateVersion(); @@ -208,10 +174,26 @@ export class StateMigrationService extends BaseStateMigrationService { await this.set(StateKeys.global, globals); } - protected async migrateStateFrom4To5(): Promise { + /** + * Migrate from State v4 (Account-based hierarchy) to v5 (flat key-value structure) + * + * This is a clean break from the Account-based structure. Data is extracted from + * the account and saved into flat keys for simpler access. + * + * Old structure: authenticatedAccounts -> userId -> account.directorySettings/directoryConfigurations + * New structure: flat keys like "directoryType", "organizationId", "directory_ldap", etc. + * + * Secrets migrate from: {userId}_{secretKey} -> secret_{secretKey} + */ + protected async migrateStateFrom4To5(useSecureStorageForSecrets = true): Promise { + // Get the authenticated user IDs from v3 structure const authenticatedUserIds = await this.get(StateKeys.authenticatedAccounts); - if (!authenticatedUserIds || authenticatedUserIds.length === 0) { + if ( + !authenticatedUserIds || + !Array.isArray(authenticatedUserIds) || + authenticatedUserIds.length === 0 + ) { // No accounts to migrate, just update version const globals = await this.getGlobals(); globals.stateVersion = StateVersion.Five; @@ -219,36 +201,94 @@ export class StateMigrationService extends BaseStateMigrationService { return; } - await Promise.all( - authenticatedUserIds.map(async (userId) => { - const oldAccount = await this.get(userId); + // DC is single-user, so we take the first (and likely only) account + const userId = authenticatedUserIds[0]; + const account = await this.get(userId); - if (!oldAccount) { - return; + if (!account) { + // No account data found, just update version + const globals = await this.getGlobals(); + globals.stateVersion = StateVersion.Five; + await this.set(StateKeys.global, globals); + return; + } + + // Migrate directory configurations to flat structure + if (account.directoryConfigurations) { + if (account.directoryConfigurations.ldap) { + await this.set("directory_ldap", account.directoryConfigurations.ldap); + } + if (account.directoryConfigurations.gsuite) { + await this.set("directory_gsuite", account.directoryConfigurations.gsuite); + } + if (account.directoryConfigurations.entra) { + await this.set("directory_entra", account.directoryConfigurations.entra); + } else if (account.directoryConfigurations.azure) { + // Backwards compatibility: migrate azure to entra + await this.set("directory_entra", account.directoryConfigurations.azure); + } + if (account.directoryConfigurations.okta) { + await this.set("directory_okta", account.directoryConfigurations.okta); + } + if (account.directoryConfigurations.oneLogin) { + await this.set("directory_onelogin", account.directoryConfigurations.oneLogin); + } + } + + // Migrate directory settings to flat structure + if (account.directorySettings) { + if (account.directorySettings.organizationId) { + await this.set("organizationId", account.directorySettings.organizationId); + } + if (account.directorySettings.directoryType != null) { + await this.set("directoryType", account.directorySettings.directoryType); + } + if (account.directorySettings.sync) { + await this.set("sync", account.directorySettings.sync); + } + if (account.directorySettings.lastUserSync) { + await this.set("lastUserSync", account.directorySettings.lastUserSync); + } + if (account.directorySettings.lastGroupSync) { + await this.set("lastGroupSync", account.directorySettings.lastGroupSync); + } + if (account.directorySettings.lastSyncHash) { + await this.set("lastSyncHash", account.directorySettings.lastSyncHash); + } + if (account.directorySettings.userDelta) { + await this.set("userDelta", account.directorySettings.userDelta); + } + if (account.directorySettings.groupDelta) { + await this.set("groupDelta", account.directorySettings.groupDelta); + } + if (account.directorySettings.syncingDir != null) { + await this.set("syncingDir", account.directorySettings.syncingDir); + } + } + + // Migrate secrets from {userId}_* to secret_* pattern + if (useSecureStorageForSecrets) { + const oldSecretKeys = [ + { old: `${userId}_${SecureStorageKeys.ldap}`, new: "secret_ldap" }, + { old: `${userId}_${SecureStorageKeys.gsuite}`, new: "secret_gsuite" }, + { old: `${userId}_${SecureStorageKeys.azure}`, new: "secret_azure" }, + { old: `${userId}_${SecureStorageKeys.entra}`, new: "secret_entra" }, + { old: `${userId}_${SecureStorageKeys.okta}`, new: "secret_okta" }, + { old: `${userId}_${SecureStorageKeys.oneLogin}`, new: "secret_onelogin" }, + ]; + + for (const { old: oldKey, new: newKey } of oldSecretKeys) { + if (await this.secureStorageService.has(oldKey)) { + const value = await this.secureStorageService.get(oldKey); + if (value) { + await this.secureStorageService.save(newKey, value); + } + // @TODO Keep old key for now - will remove in future release + // await this.secureStorageService.remove(oldKey); } + } + } - // Create new flattened account structure - const flattenedAccount = new Account({ - // Extract from nested structures - userId: oldAccount.profile?.userId ?? userId, - entityId: oldAccount.profile?.entityId ?? userId, - apiKeyClientId: oldAccount.profile?.apiKeyClientId ?? null, - accessToken: oldAccount.tokens?.accessToken ?? null, - refreshToken: oldAccount.tokens?.refreshToken ?? null, - apiKeyClientSecret: oldAccount.keys?.apiKeyClientSecret ?? null, - - // Preserve existing DC-specific data - directoryConfigurations: - oldAccount.directoryConfigurations ?? new DirectoryConfigurations(), - directorySettings: oldAccount.directorySettings ?? new DirectorySettings(), - }); - - // Save flattened account back to storage - await this.set(userId, flattenedAccount); - }), - ); - - // Update global state version const globals = await this.getGlobals(); globals.stateVersion = StateVersion.Five; await this.set(StateKeys.global, globals); diff --git a/src/services/sync.service.integration.spec.ts b/src/services/sync.service.integration.spec.ts index fa7f0f97..d844a6cb 100644 --- a/src/services/sync.service.integration.spec.ts +++ b/src/services/sync.service.integration.spec.ts @@ -14,7 +14,7 @@ import { DirectoryType } from "../enums/directoryType"; import { BatchRequestBuilder } from "./batch-request-builder"; import { LdapDirectoryService } from "./directory-services/ldap-directory.service"; import { SingleRequestBuilder } from "./single-request-builder"; -import { StateService } from "./state.service"; +import { StateService } from "./state-service/state.service"; import { SyncService } from "./sync.service"; import * as constants from "./sync.service"; diff --git a/src/services/sync.service.spec.ts b/src/services/sync.service.spec.ts index 3cff089c..be39175d 100644 --- a/src/services/sync.service.spec.ts +++ b/src/services/sync.service.spec.ts @@ -14,7 +14,7 @@ import { BatchRequestBuilder } from "./batch-request-builder"; import { LdapDirectoryService } from "./directory-services/ldap-directory.service"; import { I18nService } from "./i18n.service"; import { SingleRequestBuilder } from "./single-request-builder"; -import { StateService } from "./state.service"; +import { StateService } from "./state-service/state.service"; import { SyncService } from "./sync.service"; import * as constants from "./sync.service";