mirror of
https://github.com/bitwarden/directory-connector
synced 2026-03-02 03:01:09 +00:00
flatten account structure using claude
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
import { Account as BaseAccount } from "@/jslib/common/src/models/domain/account";
|
||||
|
||||
import { DirectoryType } from "@/src/enums/directoryType";
|
||||
|
||||
import { EntraIdConfiguration } from "./entraIdConfiguration";
|
||||
@@ -9,23 +7,39 @@ import { OktaConfiguration } from "./oktaConfiguration";
|
||||
import { OneLoginConfiguration } from "./oneLoginConfiguration";
|
||||
import { SyncConfiguration } from "./syncConfiguration";
|
||||
|
||||
export class Account extends BaseAccount {
|
||||
directoryConfigurations?: DirectoryConfigurations = new DirectoryConfigurations();
|
||||
export class Account {
|
||||
// Authentication fields (flattened from nested profile/tokens/keys structure)
|
||||
userId: string;
|
||||
entityId: string;
|
||||
apiKeyClientId: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
apiKeyClientSecret: string;
|
||||
|
||||
// Directory Connector specific fields
|
||||
directoryConfigurations: DirectoryConfigurations = new DirectoryConfigurations();
|
||||
directorySettings: DirectorySettings = new DirectorySettings();
|
||||
clientKeys: ClientKeys = new ClientKeys();
|
||||
|
||||
// FIXME: Remove these compatibility fields after StateServiceVNext migration (PR #990) is merged
|
||||
// These fields are unused but required for type compatibility with jslib's StateService infrastructure
|
||||
data?: any;
|
||||
keys?: any;
|
||||
profile?: any;
|
||||
settings?: any;
|
||||
tokens?: any;
|
||||
|
||||
constructor(init: Partial<Account>) {
|
||||
super(init);
|
||||
this.userId = init?.userId;
|
||||
this.entityId = init?.entityId;
|
||||
this.apiKeyClientId = init?.apiKeyClientId;
|
||||
this.accessToken = init?.accessToken;
|
||||
this.refreshToken = init?.refreshToken;
|
||||
this.apiKeyClientSecret = init?.apiKeyClientSecret;
|
||||
this.directoryConfigurations = init?.directoryConfigurations ?? new DirectoryConfigurations();
|
||||
this.directorySettings = init?.directorySettings ?? new DirectorySettings();
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientKeys {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export class DirectoryConfigurations {
|
||||
ldap: LdapConfiguration;
|
||||
gsuite: GSuiteConfiguration;
|
||||
|
||||
@@ -2,11 +2,6 @@ import { ApiService } from "@/jslib/common/src/abstractions/api.service";
|
||||
import { AppIdService } from "@/jslib/common/src/abstractions/appId.service";
|
||||
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
|
||||
import {
|
||||
AccountKeys,
|
||||
AccountProfile,
|
||||
AccountTokens,
|
||||
} from "@/jslib/common/src/models/domain/account";
|
||||
import { DeviceRequest } from "@/jslib/common/src/models/request/deviceRequest";
|
||||
import { ApiTokenRequest } from "@/jslib/common/src/models/request/identityToken/apiTokenRequest";
|
||||
import { TokenRequestTwoFactor } from "@/jslib/common/src/models/request/identityToken/tokenRequestTwoFactor";
|
||||
@@ -62,27 +57,12 @@ export class AuthService {
|
||||
|
||||
await this.stateService.addAccount(
|
||||
new Account({
|
||||
profile: {
|
||||
...new AccountProfile(),
|
||||
...{
|
||||
userId: entityId,
|
||||
apiKeyClientId: clientId,
|
||||
entityId: entityId,
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
...new AccountTokens(),
|
||||
...{
|
||||
accessToken: tokenResponse.accessToken,
|
||||
refreshToken: tokenResponse.refreshToken,
|
||||
},
|
||||
},
|
||||
keys: {
|
||||
...new AccountKeys(),
|
||||
...{
|
||||
apiKeyClientSecret: clientSecret,
|
||||
},
|
||||
},
|
||||
userId: entityId,
|
||||
entityId: entityId,
|
||||
apiKeyClientId: clientId,
|
||||
accessToken: tokenResponse.accessToken,
|
||||
refreshToken: tokenResponse.refreshToken,
|
||||
apiKeyClientSecret: clientSecret,
|
||||
directorySettings: new DirectorySettings(),
|
||||
directoryConfigurations: new DirectoryConfigurations(),
|
||||
}),
|
||||
|
||||
@@ -5,11 +5,6 @@ import { AppIdService } from "@/jslib/common/src/abstractions/appId.service";
|
||||
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
|
||||
import { Utils } from "@/jslib/common/src/misc/utils";
|
||||
import {
|
||||
AccountKeys,
|
||||
AccountProfile,
|
||||
AccountTokens,
|
||||
} from "@/jslib/common/src/models/domain/account";
|
||||
import { IdentityTokenResponse } from "@/jslib/common/src/models/response/identityTokenResponse";
|
||||
|
||||
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
|
||||
@@ -69,27 +64,12 @@ describe("AuthService", () => {
|
||||
expect(stateService.addAccount).toHaveBeenCalledTimes(1);
|
||||
expect(stateService.addAccount).toHaveBeenCalledWith(
|
||||
new Account({
|
||||
profile: {
|
||||
...new AccountProfile(),
|
||||
...{
|
||||
userId: "CLIENT_ID",
|
||||
apiKeyClientId: clientId, // with the "organization." prefix
|
||||
entityId: "CLIENT_ID",
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
...new AccountTokens(),
|
||||
...{
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
},
|
||||
},
|
||||
keys: {
|
||||
...new AccountKeys(),
|
||||
...{
|
||||
apiKeyClientSecret: clientSecret,
|
||||
},
|
||||
},
|
||||
userId: "CLIENT_ID",
|
||||
entityId: "CLIENT_ID",
|
||||
apiKeyClientId: clientId, // with the "organization." prefix
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
apiKeyClientSecret: clientSecret,
|
||||
directorySettings: new DirectorySettings(),
|
||||
directoryConfigurations: new DirectoryConfigurations(),
|
||||
}),
|
||||
|
||||
@@ -558,18 +558,16 @@ export class StateService
|
||||
|
||||
protected async scaffoldNewAccountDiskStorage(account: Account): Promise<void> {
|
||||
const storageOptions = this.reconcileOptions(
|
||||
{ userId: account.profile.userId },
|
||||
{ userId: account.userId },
|
||||
await this.defaultOnDiskLocalOptions(),
|
||||
);
|
||||
|
||||
const storedAccount = await this.getAccount(storageOptions);
|
||||
if (storedAccount != null) {
|
||||
account.settings = storedAccount.settings;
|
||||
account.directorySettings = storedAccount.directorySettings;
|
||||
account.directoryConfigurations = storedAccount.directoryConfigurations;
|
||||
} else if (await this.hasTemporaryStorage()) {
|
||||
// If migrating to state V2 with an no actively authed account we store temporary data to be copied on auth - this will only be run once.
|
||||
account.settings = await this.storageService.get<any>(keys.tempAccountSettings);
|
||||
account.directorySettings = await this.storageService.get<any>(keys.tempDirectorySettings);
|
||||
account.directoryConfigurations = await this.storageService.get<any>(
|
||||
keys.tempDirectoryConfigs,
|
||||
@@ -600,7 +598,7 @@ export class StateService
|
||||
|
||||
protected resetAccount(account: Account) {
|
||||
const persistentAccountInformation = {
|
||||
settings: account.settings,
|
||||
settings: account.settings, // Required by base class (unused by DC)
|
||||
directorySettings: account.directorySettings,
|
||||
directoryConfigurations: account.directoryConfigurations,
|
||||
};
|
||||
|
||||
209
src/services/stateMigration.service.spec.ts
Normal file
209
src/services/stateMigration.service.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
|
||||
import { StateVersion } from "@/jslib/common/src/enums/stateVersion";
|
||||
import { StateFactory } from "@/jslib/common/src/factories/stateFactory";
|
||||
|
||||
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
|
||||
|
||||
import { StateMigrationService } from "./stateMigration.service";
|
||||
|
||||
describe("StateMigrationService - v4 to v5 migration", () => {
|
||||
let storageService: jest.Mocked<StorageService>;
|
||||
let secureStorageService: jest.Mocked<StorageService>;
|
||||
let stateFactory: jest.Mocked<StateFactory<any, Account>>;
|
||||
let migrationService: StateMigrationService;
|
||||
|
||||
beforeEach(() => {
|
||||
storageService = mock<StorageService>();
|
||||
secureStorageService = mock<StorageService>();
|
||||
stateFactory = mock<StateFactory<any, Account>>();
|
||||
|
||||
migrationService = new StateMigrationService(
|
||||
storageService,
|
||||
secureStorageService,
|
||||
stateFactory,
|
||||
);
|
||||
});
|
||||
|
||||
it("should flatten nested account structure", async () => {
|
||||
const userId = "test-user-id";
|
||||
const oldAccount = {
|
||||
profile: {
|
||||
userId: userId,
|
||||
entityId: userId,
|
||||
apiKeyClientId: "organization.CLIENT_ID",
|
||||
},
|
||||
tokens: {
|
||||
accessToken: "test-access-token",
|
||||
refreshToken: "test-refresh-token",
|
||||
},
|
||||
keys: {
|
||||
apiKeyClientSecret: "test-secret",
|
||||
},
|
||||
directoryConfigurations: new DirectoryConfigurations(),
|
||||
directorySettings: new DirectorySettings(),
|
||||
};
|
||||
|
||||
storageService.get.mockImplementation((key: string) => {
|
||||
if (key === "authenticatedAccounts") {
|
||||
return Promise.resolve([userId]);
|
||||
}
|
||||
if (key === userId) {
|
||||
return Promise.resolve(oldAccount);
|
||||
}
|
||||
if (key === "global") {
|
||||
return Promise.resolve({ stateVersion: StateVersion.Four });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await migrationService["migrateStateFrom4To5"]();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
userId,
|
||||
expect.objectContaining({
|
||||
userId: userId,
|
||||
entityId: userId,
|
||||
apiKeyClientId: "organization.CLIENT_ID",
|
||||
accessToken: "test-access-token",
|
||||
refreshToken: "test-refresh-token",
|
||||
apiKeyClientSecret: "test-secret",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing nested objects gracefully", async () => {
|
||||
const userId = "test-user-id";
|
||||
const partialAccount = {
|
||||
directoryConfigurations: new DirectoryConfigurations(),
|
||||
directorySettings: new DirectorySettings(),
|
||||
};
|
||||
|
||||
storageService.get.mockImplementation((key: string) => {
|
||||
if (key === "authenticatedAccounts") {
|
||||
return Promise.resolve([userId]);
|
||||
}
|
||||
if (key === userId) {
|
||||
return Promise.resolve(partialAccount);
|
||||
}
|
||||
if (key === "global") {
|
||||
return Promise.resolve({ stateVersion: StateVersion.Four });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await migrationService["migrateStateFrom4To5"]();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
userId,
|
||||
expect.objectContaining({
|
||||
userId: userId,
|
||||
apiKeyClientId: null,
|
||||
accessToken: null,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty account list", async () => {
|
||||
storageService.get.mockImplementation((key: string) => {
|
||||
if (key === "authenticatedAccounts") {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (key === "global") {
|
||||
return Promise.resolve({ stateVersion: StateVersion.Four });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await migrationService["migrateStateFrom4To5"]();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
"global",
|
||||
expect.objectContaining({ stateVersion: StateVersion.Five }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(storageService.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should preserve directory configurations and settings", async () => {
|
||||
const userId = "test-user-id";
|
||||
const directoryConfigs = new DirectoryConfigurations();
|
||||
directoryConfigs.ldap = { host: "ldap.example.com" } as any;
|
||||
|
||||
const directorySettings = new DirectorySettings();
|
||||
directorySettings.organizationId = "org-123";
|
||||
directorySettings.lastSyncHash = "hash-abc";
|
||||
|
||||
const oldAccount = {
|
||||
profile: { userId: userId },
|
||||
tokens: {},
|
||||
keys: {},
|
||||
directoryConfigurations: directoryConfigs,
|
||||
directorySettings: directorySettings,
|
||||
};
|
||||
|
||||
storageService.get.mockImplementation((key: string) => {
|
||||
if (key === "authenticatedAccounts") {
|
||||
return Promise.resolve([userId]);
|
||||
}
|
||||
if (key === userId) {
|
||||
return Promise.resolve(oldAccount);
|
||||
}
|
||||
if (key === "global") {
|
||||
return Promise.resolve({ stateVersion: StateVersion.Four });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await migrationService["migrateStateFrom4To5"]();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
userId,
|
||||
expect.objectContaining({
|
||||
directoryConfigurations: expect.objectContaining({
|
||||
ldap: { host: "ldap.example.com" },
|
||||
}),
|
||||
directorySettings: expect.objectContaining({
|
||||
organizationId: "org-123",
|
||||
lastSyncHash: "hash-abc",
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should update state version after successful migration", async () => {
|
||||
const userId = "test-user-id";
|
||||
const oldAccount = {
|
||||
profile: { userId: userId },
|
||||
tokens: {},
|
||||
keys: {},
|
||||
directoryConfigurations: new DirectoryConfigurations(),
|
||||
directorySettings: new DirectorySettings(),
|
||||
};
|
||||
|
||||
storageService.get.mockImplementation((key: string) => {
|
||||
if (key === "authenticatedAccounts") {
|
||||
return Promise.resolve([userId]);
|
||||
}
|
||||
if (key === userId) {
|
||||
return Promise.resolve(oldAccount);
|
||||
}
|
||||
if (key === "global") {
|
||||
return Promise.resolve({ stateVersion: StateVersion.Four });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await migrationService["migrateStateFrom4To5"]();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
"global",
|
||||
expect.objectContaining({ stateVersion: StateVersion.Five }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,13 @@ export class StateMigrationService extends BaseStateMigrationService {
|
||||
break;
|
||||
case StateVersion.Two:
|
||||
await this.migrateStateFrom2To3();
|
||||
break;
|
||||
case StateVersion.Three:
|
||||
await this.migrateStateFrom3To4();
|
||||
break;
|
||||
case StateVersion.Four:
|
||||
await this.migrateStateFrom4To5();
|
||||
break;
|
||||
}
|
||||
currentStateVersion += 1;
|
||||
}
|
||||
@@ -143,15 +150,10 @@ export class StateMigrationService extends BaseStateMigrationService {
|
||||
const account = await this.get<Account>(userId);
|
||||
account.directoryConfigurations = directoryConfigs;
|
||||
account.directorySettings = directorySettings;
|
||||
account.profile = {
|
||||
userId: userId,
|
||||
entityId: userId,
|
||||
apiKeyClientId: clientId,
|
||||
};
|
||||
account.clientKeys = {
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
};
|
||||
account.userId = userId;
|
||||
account.entityId = userId;
|
||||
account.apiKeyClientId = clientId;
|
||||
account.apiKeyClientSecret = clientSecret;
|
||||
|
||||
await this.set(userId, account);
|
||||
await clearDirectoryConnectorV1Keys();
|
||||
@@ -198,4 +200,57 @@ export class StateMigrationService extends BaseStateMigrationService {
|
||||
globals.stateVersion = StateVersion.Three;
|
||||
await this.set(StateKeys.global, globals);
|
||||
}
|
||||
|
||||
protected async migrateStateFrom3To4(): Promise<void> {
|
||||
// Placeholder migration for v3→v4 (no changes needed for DC)
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Four;
|
||||
await this.set(StateKeys.global, globals);
|
||||
}
|
||||
|
||||
protected async migrateStateFrom4To5(): Promise<void> {
|
||||
const authenticatedUserIds = await this.get<string[]>(StateKeys.authenticatedAccounts);
|
||||
|
||||
if (!authenticatedUserIds || authenticatedUserIds.length === 0) {
|
||||
// No accounts to migrate, just update version
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Five;
|
||||
await this.set(StateKeys.global, globals);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
authenticatedUserIds.map(async (userId) => {
|
||||
const oldAccount = await this.get<any>(userId);
|
||||
|
||||
if (!oldAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user