diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 2d229092787..a90fbcbf332 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1475,6 +1475,9 @@ "selectFile": { "message": "Select a file" }, + "itemsTransferred": { + "message": "Items transferred" + }, "maxFileSize": { "message": "Maximum file size is 500 MB." }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f6a679d9c7c..7a3abe528e8 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -708,6 +708,9 @@ "addAttachment": { "message": "Add attachment" }, + "itemsTransferred": { + "message": "Items transferred" + }, "fixEncryption": { "message": "Fix encryption" }, diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index b0685a028df..78a8889bc8f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -84,7 +84,7 @@ import { CipherViewLikeUtils, } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; -import { DialogRef, DialogService, ToastService, BannerComponent } from "@bitwarden/components"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { CipherListView } from "@bitwarden/sdk-internal"; import { AddEditFolderDialogComponent, @@ -97,6 +97,8 @@ import { DecryptionFailureDialogComponent, DefaultCipherFormConfigService, PasswordRepromptService, + VaultItemsTransferService, + DefaultVaultItemsTransferService, } from "@bitwarden/vault"; import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; @@ -177,12 +179,12 @@ type EmptyStateMap = Record; VaultItemsModule, SharedModule, OrganizationWarningsModule, - BannerComponent, ], providers: [ RoutedVaultFilterService, RoutedVaultFilterBridgeService, DefaultCipherFormConfigService, + { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }, ], }) export class VaultComponent implements OnInit, OnDestroy { @@ -349,6 +351,7 @@ export class VaultComponent implements OnInit, OnDestr private premiumUpgradePromptService: PremiumUpgradePromptService, private autoConfirmService: AutomaticUserConfirmationService, private configService: ConfigService, + private vaultItemTransferService: VaultItemsTransferService, ) {} async ngOnInit() { @@ -644,6 +647,8 @@ export class VaultComponent implements OnInit, OnDestr void this.unifiedUpgradePromptService.displayUpgradePromptConditionally(); this.setupAutoConfirm(); + + void this.vaultItemTransferService.enforceOrganizationDataOwnership(activeUserId); } ngOnDestroy() { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index bbbf548b8e1..a755e4de556 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5185,6 +5185,9 @@ "oldAttachmentsNeedFixDesc": { "message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key." }, + "itemsTransferred": { + "message": "Items transferred" + }, "yourAccountsFingerprint": { "message": "Your account's fingerprint phrase", "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." diff --git a/libs/common/src/admin-console/models/data/policy.data.ts b/libs/common/src/admin-console/models/data/policy.data.ts index a8628e2f1ab..639fda1fa92 100644 --- a/libs/common/src/admin-console/models/data/policy.data.ts +++ b/libs/common/src/admin-console/models/data/policy.data.ts @@ -11,6 +11,7 @@ export class PolicyData { type: PolicyType; data: Record; enabled: boolean; + revisionDate: string; constructor(response?: PolicyResponse) { if (response == null) { @@ -22,6 +23,7 @@ export class PolicyData { this.type = response.type; this.data = response.data; this.enabled = response.enabled; + this.revisionDate = response.revisionDate; } static fromPolicy(policy: Policy): PolicyData { diff --git a/libs/common/src/admin-console/models/domain/policy.ts b/libs/common/src/admin-console/models/domain/policy.ts index eb67c4412e9..6b2d587a262 100644 --- a/libs/common/src/admin-console/models/domain/policy.ts +++ b/libs/common/src/admin-console/models/domain/policy.ts @@ -19,6 +19,8 @@ export class Policy extends Domain { */ enabled: boolean; + revisionDate: Date; + constructor(obj?: PolicyData) { super(); if (obj == null) { @@ -30,6 +32,7 @@ export class Policy extends Domain { this.type = obj.type; this.data = obj.data; this.enabled = obj.enabled; + this.revisionDate = new Date(obj.revisionDate); } static fromResponse(response: PolicyResponse): Policy { diff --git a/libs/common/src/admin-console/models/response/policy.response.ts b/libs/common/src/admin-console/models/response/policy.response.ts index 0544cd996f4..7cca63a19d3 100644 --- a/libs/common/src/admin-console/models/response/policy.response.ts +++ b/libs/common/src/admin-console/models/response/policy.response.ts @@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse { data: any; enabled: boolean; canToggleState: boolean; + revisionDate: string; constructor(response: any) { super(response); @@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse { this.data = this.getResponseProperty("Data"); this.enabled = this.getResponseProperty("Enabled"); this.canToggleState = this.getResponseProperty("CanToggleState") ?? true; + this.revisionDate = this.getResponseProperty("RevisionDate"); } } diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts index 4b59683ec0a..2ff649e6533 100644 --- a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts @@ -83,12 +83,15 @@ describe("PolicyService", () => { type: PolicyType.MaximumVaultTimeout, enabled: true, data: { minutes: 14 }, + revisionDate: expect.any(Date), }, { id: "99", organizationId: "test-organization", type: PolicyType.DisableSend, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -113,6 +116,8 @@ describe("PolicyService", () => { organizationId: "test-organization", type: PolicyType.DisableSend, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -242,6 +247,8 @@ describe("PolicyService", () => { organizationId: "org1", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }); }); @@ -331,24 +338,32 @@ describe("PolicyService", () => { organizationId: "org4", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy2", organizationId: "org1", type: PolicyType.ActivateAutofill, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy3", organizationId: "org5", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy4", organizationId: "org1", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -371,24 +386,32 @@ describe("PolicyService", () => { organizationId: "org4", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy2", organizationId: "org1", type: PolicyType.ActivateAutofill, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy3", organizationId: "org5", type: PolicyType.DisablePersonalVaultExport, enabled: false, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy4", organizationId: "org1", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -411,24 +434,32 @@ describe("PolicyService", () => { organizationId: "org4", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy2", organizationId: "org1", type: PolicyType.ActivateAutofill, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy3", organizationId: "org5", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy4", organizationId: "org2", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -451,24 +482,32 @@ describe("PolicyService", () => { organizationId: "org4", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy2", organizationId: "org1", type: PolicyType.ActivateAutofill, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy3", organizationId: "org3", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy4", organizationId: "org1", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -788,6 +827,7 @@ describe("PolicyService", () => { policyData.type = type; policyData.enabled = enabled; policyData.data = data; + policyData.revisionDate = new Date().toISOString(); return policyData; } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 371081a89d9..1727d3da712 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -64,6 +64,7 @@ export enum FeatureFlag { RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", + MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -123,6 +124,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.RiskInsightsForPremium]: FALSE, [FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.BrowserPremiumSpotlight]: FALSE, + [FeatureFlag.MigrateMyVaultToMyItems]: FALSE, /* Auth */ [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts index 5f699974fba..7de8c708dcf 100644 --- a/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts +++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts @@ -24,6 +24,7 @@ describe("availableAlgorithms_vNextPolicy", () => { overridePasswordType: override, }, enabled: true, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([policy]); @@ -44,6 +45,7 @@ describe("availableAlgorithms_vNextPolicy", () => { overridePasswordType: override, }, enabled: true, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([policy, policy]); @@ -64,6 +66,7 @@ describe("availableAlgorithms_vNextPolicy", () => { overridePasswordType: "password", }, enabled: true, + revisionDate: new Date().toISOString(), }); const passphrase = new Policy({ id: "" as PolicyId, @@ -73,6 +76,7 @@ describe("availableAlgorithms_vNextPolicy", () => { overridePasswordType: "passphrase", }, enabled: true, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([password, passphrase]); @@ -93,6 +97,7 @@ describe("availableAlgorithms_vNextPolicy", () => { some: "policy", }, enabled: true, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([policy]); @@ -111,6 +116,7 @@ describe("availableAlgorithms_vNextPolicy", () => { some: "policy", }, enabled: false, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([policy]); @@ -129,6 +135,7 @@ describe("availableAlgorithms_vNextPolicy", () => { some: "policy", }, enabled: true, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([policy]); diff --git a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts index 0fbc1796e9e..c6ce189f620 100644 --- a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts +++ b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts @@ -17,6 +17,7 @@ function createPolicy( data, enabled, type, + revisionDate: new Date().toISOString(), }); } diff --git a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts index 7f8dce19b15..7885641c8e5 100644 --- a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts +++ b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts @@ -17,6 +17,7 @@ function createPolicy( data, enabled, type, + revisionDate: new Date().toISOString(), }); } diff --git a/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts index 32d99aa8a1f..924849b1c22 100644 --- a/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts +++ b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts @@ -57,6 +57,7 @@ const somePolicy = new Policy({ id: "" as PolicyId, organizationId: "" as OrganizationId, enabled: true, + revisionDate: new Date().toISOString(), }); const stateProvider = new FakeStateProvider(accountService); diff --git a/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts b/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts index 65f1669ebd1..37e8ec6e379 100644 --- a/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts +++ b/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts @@ -70,6 +70,7 @@ describe("DefaultGeneratorNavigationService", () => { enabled: true, type: PolicyType.PasswordGenerator, data: { overridePasswordType: "password" }, + revisionDate: new Date().toISOString(), }), ]); }, diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts index e4f0b08a3d5..69a4e75d47d 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts @@ -17,6 +17,7 @@ function createPolicy( data, enabled, type, + revisionDate: new Date().toISOString(), }); } diff --git a/libs/vault/src/abstractions/vault-items-transfer.service.ts b/libs/vault/src/abstractions/vault-items-transfer.service.ts new file mode 100644 index 00000000000..ced9f71eb83 --- /dev/null +++ b/libs/vault/src/abstractions/vault-items-transfer.service.ts @@ -0,0 +1,59 @@ +import { Observable } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; +import { UserId } from "@bitwarden/user-core"; + +export type UserMigrationInfo = + | { + /** + * Whether the user requires migration of their vault items from My Vault to a My Items collection due to an + * organizational policy change. (Enforce organization data ownership policy enabled) + */ + requiresMigration: false; + } + | { + /** + * Whether the user requires migration of their vault items from My Vault to a My Items collection due to an + * organizational policy change. (Enforce organization data ownership policy enabled) + */ + requiresMigration: true; + + /** + * The organization that is enforcing data ownership policies for the given user. + */ + enforcingOrganization: Organization; + + /** + * The default collection ID for the user in the enforcing organization, if available. + */ + defaultCollectionId?: CollectionId; + }; + +export abstract class VaultItemsTransferService { + /** + * Gets information about whether the given user requires migration of their vault items + * from My Vault to a My Items collection, and whether they are capable of performing that migration. + * @param userId + */ + abstract userMigrationInfo$(userId: UserId): Observable; + + /** + * Enforces organization data ownership for the given user by transferring vault items. + * Checks if any organization policies require the transfer, and if so, prompts the user to confirm before proceeding. + * + * Rejecting the transfer will result in the user being revoked from the organization. + * + * @param userId + */ + abstract enforceOrganizationDataOwnership(userId: UserId): Promise; + + /** + * Begins transfer of vault items from My Vault to the specified default collection for the given user. + */ + abstract transferPersonalItems( + userId: UserId, + organizationId: OrganizationId, + defaultCollectionId: CollectionId, + ): Promise; +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index be0daad3637..391957d26d8 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -35,5 +35,7 @@ export { DefaultSshImportPromptService } from "./services/default-ssh-import-pro export { SshImportPromptService } from "./services/ssh-import-prompt.service"; export * from "./abstractions/change-login-password.service"; +export * from "./abstractions/vault-items-transfer.service"; +export * from "./services/default-vault-items-transfer.service"; export * from "./services/default-change-login-password.service"; export * from "./services/archive-cipher-utilities.service"; diff --git a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts new file mode 100644 index 00000000000..d85fe2ffd43 --- /dev/null +++ b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts @@ -0,0 +1,721 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { DefaultVaultItemsTransferService } from "./default-vault-items-transfer.service"; + +describe("DefaultVaultItemsTransferService", () => { + let service: DefaultVaultItemsTransferService; + + let mockCipherService: MockProxy; + let mockPolicyService: MockProxy; + let mockOrganizationService: MockProxy; + let mockCollectionService: MockProxy; + let mockLogService: MockProxy; + let mockI18nService: MockProxy; + let mockDialogService: MockProxy; + let mockToastService: MockProxy; + let mockConfigService: MockProxy; + + const userId = "user-id" as UserId; + const organizationId = "org-id" as OrganizationId; + const collectionId = "collection-id" as CollectionId; + + beforeEach(() => { + mockCipherService = mock(); + mockPolicyService = mock(); + mockOrganizationService = mock(); + mockCollectionService = mock(); + mockLogService = mock(); + mockI18nService = mock(); + mockDialogService = mock(); + mockToastService = mock(); + mockConfigService = mock(); + + mockI18nService.t.mockImplementation((key) => key); + + service = new DefaultVaultItemsTransferService( + mockCipherService, + mockPolicyService, + mockOrganizationService, + mockCollectionService, + mockLogService, + mockI18nService, + mockDialogService, + mockToastService, + mockConfigService, + ); + }); + + describe("userMigrationInfo$", () => { + // Helper to setup common mock scenario + function setupMocksForMigrationScenario(options: { + policies?: Policy[]; + organizations?: Organization[]; + ciphers?: CipherView[]; + collections?: CollectionView[]; + }): void { + mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? [])); + mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? [])); + mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? [])); + mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? [])); + } + + it("calls policiesByType$ with correct PolicyType", async () => { + setupMocksForMigrationScenario({ policies: [] }); + + await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(mockPolicyService.policiesByType$).toHaveBeenCalledWith( + PolicyType.OrganizationDataOwnership, + userId, + ); + }); + + describe("when no policy exists", () => { + beforeEach(() => { + setupMocksForMigrationScenario({ policies: [] }); + }); + + it("returns requiresMigration: false", async () => { + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: false, + }); + }); + }); + + describe("when policy exists", () => { + const policy = { + organizationId: organizationId, + revisionDate: new Date("2024-01-01"), + } as Policy; + const organization = { + id: organizationId, + name: "Test Org", + } as Organization; + + beforeEach(() => { + setupMocksForMigrationScenario({ + policies: [policy], + organizations: [organization], + }); + }); + + describe("and user has no personal ciphers", () => { + beforeEach(() => { + mockCipherService.cipherViews$.mockReturnValue(of([])); + }); + + it("returns requiresMigration: false", async () => { + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: false, + enforcingOrganization: organization, + defaultCollectionId: undefined, + }); + }); + }); + + describe("and user has personal ciphers", () => { + beforeEach(() => { + mockCipherService.cipherViews$.mockReturnValue(of([{ id: "cipher-1" } as CipherView])); + }); + + it("returns requiresMigration: true", async () => { + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: true, + enforcingOrganization: organization, + defaultCollectionId: undefined, + }); + }); + + it("includes defaultCollectionId when a default collection exists", async () => { + mockCollectionService.decryptedCollections$.mockReturnValue( + of([ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ]), + ); + + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: true, + enforcingOrganization: organization, + defaultCollectionId: collectionId, + }); + }); + + it("returns default collection only for the enforcing organization", async () => { + mockCollectionService.decryptedCollections$.mockReturnValue( + of([ + { + id: "wrong-collection-id" as CollectionId, + organizationId: "wrong-org-id" as OrganizationId, + isDefaultCollection: true, + } as CollectionView, + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ]), + ); + + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: true, + enforcingOrganization: organization, + defaultCollectionId: collectionId, + }); + }); + }); + + it("filters out organization ciphers when checking for personal ciphers", async () => { + mockCipherService.cipherViews$.mockReturnValue( + of([ + { + id: "cipher-1", + organizationId: organizationId as string, + } as CipherView, + ]), + ); + + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: false, + enforcingOrganization: organization, + defaultCollectionId: undefined, + }); + }); + }); + + describe("when multiple policies exist", () => { + const olderPolicy = { + organizationId: "older-org-id" as OrganizationId, + revisionDate: new Date("2024-01-01"), + } as Policy; + const newerPolicy = { + organizationId: organizationId, + revisionDate: new Date("2024-06-01"), + } as Policy; + const olderOrganization = { + id: "older-org-id" as OrganizationId, + name: "Older Org", + } as Organization; + const newerOrganization = { + id: organizationId, + name: "Newer Org", + } as Organization; + + beforeEach(() => { + setupMocksForMigrationScenario({ + policies: [newerPolicy, olderPolicy], + organizations: [olderOrganization, newerOrganization], + ciphers: [{ id: "cipher-1" } as CipherView], + }); + }); + + it("uses the oldest policy when selecting enforcing organization", async () => { + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: true, + enforcingOrganization: olderOrganization, + defaultCollectionId: undefined, + }); + }); + }); + }); + + describe("transferPersonalItems", () => { + it("does nothing when user has no personal ciphers", async () => { + mockCipherService.cipherViews$.mockReturnValue(of([])); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + expect(mockLogService.info).not.toHaveBeenCalled(); + }); + + it("calls shareManyWithServer with correct parameters", async () => { + const personalCiphers = [{ id: "cipher-1" }, { id: "cipher-2" }] as CipherView[]; + + mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers)); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith( + personalCiphers, + organizationId, + [collectionId], + userId, + ); + }); + + it("transfers only personal ciphers, not organization ciphers", async () => { + const allCiphers = [ + { id: "cipher-1" }, + { id: "cipher-2", organizationId: "other-org-id" }, + { id: "cipher-3" }, + ] as CipherView[]; + + const expectedPersonalCiphers = [allCiphers[0], allCiphers[2]]; + + mockCipherService.cipherViews$.mockReturnValue(of(allCiphers)); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith( + expectedPersonalCiphers, + organizationId, + [collectionId], + userId, + ); + }); + + it("propagates errors from shareManyWithServer", async () => { + const personalCiphers = [{ id: "cipher-1" }] as CipherView[]; + + const error = new Error("Transfer failed"); + + mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers)); + mockCipherService.shareManyWithServer.mockRejectedValue(error); + + await expect( + service.transferPersonalItems(userId, organizationId, collectionId), + ).rejects.toThrow("Transfer failed"); + }); + }); + + describe("upgradeOldAttachments", () => { + it("upgrades old attachments before transferring", async () => { + const cipherWithOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + const upgradedCipher = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: false, + attachments: [{ key: "new-key" }], + } as unknown as CipherView; + + mockCipherService.cipherViews$ + .mockReturnValueOnce(of([cipherWithOldAttachment])) + .mockReturnValueOnce(of([upgradedCipher])); + mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith( + cipherWithOldAttachment, + userId, + ); + expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith( + [upgradedCipher], + organizationId, + [collectionId], + userId, + ); + }); + + it("upgrades multiple ciphers with old attachments", async () => { + const cipher1 = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + const cipher2 = { + id: "cipher-2", + name: "Cipher 2", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + const upgradedCipher1 = { ...cipher1, hasOldAttachments: false } as CipherView; + const upgradedCipher2 = { ...cipher2, hasOldAttachments: false } as CipherView; + + mockCipherService.cipherViews$ + .mockReturnValueOnce(of([cipher1, cipher2])) + .mockReturnValueOnce(of([upgradedCipher1, upgradedCipher2])); + mockCipherService.upgradeOldCipherAttachments + .mockResolvedValueOnce(upgradedCipher1) + .mockResolvedValueOnce(upgradedCipher2); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(2); + expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher1, userId); + expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher2, userId); + }); + + it("skips attachments that already have keys", async () => { + const cipherWithMixedAttachments = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: "existing-key" }, { key: null }], + } as unknown as CipherView; + + const upgradedCipher = { + ...cipherWithMixedAttachments, + hasOldAttachments: false, + } as unknown as CipherView; + + mockCipherService.cipherViews$ + .mockReturnValueOnce(of([cipherWithMixedAttachments])) + .mockReturnValueOnce(of([upgradedCipher])); + mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + // Should only be called once for the attachment without a key + expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(1); + }); + + it("throws error when upgradeOldCipherAttachments fails", async () => { + const cipherWithOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment])); + mockCipherService.upgradeOldCipherAttachments.mockRejectedValue(new Error("Upgrade failed")); + + await expect( + service.transferPersonalItems(userId, organizationId, collectionId), + ).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1"); + + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("throws error when upgrade returns cipher still having old attachments", async () => { + const cipherWithOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + // Upgrade returns but cipher still has old attachments + const stillOldCipher = { + ...cipherWithOldAttachment, + hasOldAttachments: true, + } as unknown as CipherView; + + mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment])); + mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(stillOldCipher); + + await expect( + service.transferPersonalItems(userId, organizationId, collectionId), + ).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1"); + + expect(mockLogService.error).toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("throws error when sanity check finds remaining old attachments after upgrade", async () => { + const cipherWithOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + const upgradedCipher = { + ...cipherWithOldAttachment, + hasOldAttachments: false, + } as unknown as CipherView; + + // First call returns cipher with old attachment, second call (after upgrade) still returns old attachment + mockCipherService.cipherViews$ + .mockReturnValueOnce(of([cipherWithOldAttachment])) + .mockReturnValueOnce(of([cipherWithOldAttachment])); // Still has old attachments after re-fetch + mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher); + + await expect( + service.transferPersonalItems(userId, organizationId, collectionId), + ).rejects.toThrow( + "Failed to upgrade all old attachments. 1 ciphers still have old attachments.", + ); + + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("logs info when upgrading old attachments", async () => { + const cipherWithOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + const upgradedCipher = { + ...cipherWithOldAttachment, + hasOldAttachments: false, + } as unknown as CipherView; + + mockCipherService.cipherViews$ + .mockReturnValueOnce(of([cipherWithOldAttachment])) + .mockReturnValueOnce(of([upgradedCipher])); + mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockLogService.info).toHaveBeenCalledWith( + expect.stringContaining("Found 1 ciphers with old attachments needing upgrade"), + ); + expect(mockLogService.info).toHaveBeenCalledWith( + expect.stringContaining("Successfully upgraded 1 ciphers with old attachments"), + ); + }); + + it("does not upgrade when ciphers have no old attachments", async () => { + const cipherWithoutOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: false, + } as unknown as CipherView; + + mockCipherService.cipherViews$.mockReturnValue(of([cipherWithoutOldAttachment])); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.upgradeOldCipherAttachments).not.toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).toHaveBeenCalled(); + }); + }); + + describe("enforceOrganizationDataOwnership", () => { + const policy = { + organizationId: organizationId, + revisionDate: new Date("2024-01-01"), + } as Policy; + const organization = { + id: organizationId, + name: "Test Org", + } as Organization; + + function setupMocksForEnforcementScenario(options: { + featureEnabled?: boolean; + policies?: Policy[]; + organizations?: Organization[]; + ciphers?: CipherView[]; + collections?: CollectionView[]; + }): void { + mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true); + mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? [])); + mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? [])); + mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? [])); + mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? [])); + } + + it("does nothing when feature flag is disabled", async () => { + setupMocksForEnforcementScenario({ + featureEnabled: false, + policies: [policy], + organizations: [organization], + ciphers: [{ id: "cipher-1" } as CipherView], + collections: [ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ], + }); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.MigrateMyVaultToMyItems, + ); + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("does nothing when no migration is required", async () => { + setupMocksForEnforcementScenario({ policies: [] }); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("does nothing when user has no personal ciphers", async () => { + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: [], + }); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("logs warning and returns when default collection is missing", async () => { + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: [{ id: "cipher-1" } as CipherView], + collections: [], + }); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockLogService.warning).toHaveBeenCalledWith( + "Default collection is missing for user during organization data ownership enforcement", + ); + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("shows confirmation dialog when migration is required", async () => { + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: [{ id: "cipher-1" } as CipherView], + collections: [ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ], + }); + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Requires migration", + content: "Your vault requires migration of personal items to your organization.", + type: "warning", + }); + }); + + it("does not transfer items when user declines confirmation", async () => { + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: [{ id: "cipher-1" } as CipherView], + collections: [ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ], + }); + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("transfers items and shows success toast when user confirms", async () => { + const personalCiphers = [{ id: "cipher-1" } as CipherView]; + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: personalCiphers, + collections: [ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ], + }); + mockDialogService.openSimpleDialog.mockResolvedValue(true); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith( + personalCiphers, + organizationId, + [collectionId], + userId, + ); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "itemsTransferred", + }); + }); + + it("shows error toast when transfer fails", async () => { + const personalCiphers = [{ id: "cipher-1" } as CipherView]; + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: personalCiphers, + collections: [ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ], + }); + mockDialogService.openSimpleDialog.mockResolvedValue(true); + mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed")); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockLogService.error).toHaveBeenCalledWith( + "Error transferring personal items to organization", + expect.any(Error), + ); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "errorOccurred", + }); + }); + }); +}); diff --git a/libs/vault/src/services/default-vault-items-transfer.service.ts b/libs/vault/src/services/default-vault-items-transfer.service.ts new file mode 100644 index 00000000000..d9c490f870e --- /dev/null +++ b/libs/vault/src/services/default-vault-items-transfer.service.ts @@ -0,0 +1,231 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom, switchMap, map, of, Observable, combineLatest } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { getById } from "@bitwarden/common/platform/misc"; +import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { + VaultItemsTransferService, + UserMigrationInfo, +} from "../abstractions/vault-items-transfer.service"; + +@Injectable() +export class DefaultVaultItemsTransferService implements VaultItemsTransferService { + constructor( + private cipherService: CipherService, + private policyService: PolicyService, + private organizationService: OrganizationService, + private collectionService: CollectionService, + private logService: LogService, + private i18nService: I18nService, + private dialogService: DialogService, + private toastService: ToastService, + private configService: ConfigService, + ) {} + + private enforcingOrganization$(userId: UserId): Observable { + return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe( + map( + (policies) => + policies.sort((a, b) => a.revisionDate.getTime() - b.revisionDate.getTime())?.[0], + ), + switchMap((policy) => { + if (policy == null) { + return of(undefined); + } + return this.organizationService.organizations$(userId).pipe(getById(policy.organizationId)); + }), + ); + } + + private personalCiphers$(userId: UserId): Observable { + return this.cipherService.cipherViews$(userId).pipe( + filterOutNullish(), + map((ciphers) => ciphers.filter((c) => c.organizationId == null)), + ); + } + + private defaultUserCollection$( + userId: UserId, + organizationId: OrganizationId, + ): Observable { + return this.collectionService.decryptedCollections$(userId).pipe( + map((collections) => { + return collections.find((c) => c.isDefaultCollection && c.organizationId === organizationId) + ?.id; + }), + ); + } + + userMigrationInfo$(userId: UserId): Observable { + return this.enforcingOrganization$(userId).pipe( + switchMap((enforcingOrganization) => { + if (enforcingOrganization == null) { + return of({ + requiresMigration: false, + }); + } + return combineLatest([ + this.personalCiphers$(userId), + this.defaultUserCollection$(userId, enforcingOrganization.id), + ]).pipe( + map(([personalCiphers, defaultCollectionId]): UserMigrationInfo => { + return { + requiresMigration: personalCiphers.length > 0, + enforcingOrganization, + defaultCollectionId, + }; + }), + ); + }), + ); + } + + async enforceOrganizationDataOwnership(userId: UserId): Promise { + const featureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.MigrateMyVaultToMyItems, + ); + + if (!featureEnabled) { + return; + } + + const migrationInfo = await firstValueFrom(this.userMigrationInfo$(userId)); + + if (!migrationInfo.requiresMigration) { + return; + } + + if (migrationInfo.defaultCollectionId == null) { + // TODO: Handle creating the default collection if missing (to be handled by AC in future work) + this.logService.warning( + "Default collection is missing for user during organization data ownership enforcement", + ); + return; + } + + // Temporary confirmation dialog. Full implementation in PM-27663 + const confirmMigration = await this.dialogService.openSimpleDialog({ + title: "Requires migration", + content: "Your vault requires migration of personal items to your organization.", + type: "warning", + }); + + if (!confirmMigration) { + // TODO: Show secondary confirmation dialog in PM-27663, for now we just exit + // TODO: Revoke user from organization if they decline migration PM-29465 + return; + } + + try { + await this.transferPersonalItems( + userId, + migrationInfo.enforcingOrganization.id, + migrationInfo.defaultCollectionId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemsTransferred"), + }); + } catch (error) { + this.logService.error("Error transferring personal items to organization", error); + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + } + } + + async transferPersonalItems( + userId: UserId, + organizationId: OrganizationId, + defaultCollectionId: CollectionId, + ): Promise { + let personalCiphers = await firstValueFrom(this.personalCiphers$(userId)); + + if (personalCiphers.length === 0) { + return; + } + + const oldAttachmentCiphers = personalCiphers.filter((c) => c.hasOldAttachments); + + if (oldAttachmentCiphers.length > 0) { + await this.upgradeOldAttachments(oldAttachmentCiphers, userId, organizationId); + personalCiphers = await firstValueFrom(this.personalCiphers$(userId)); + + // Sanity check to ensure all old attachments were upgraded, though upgradeOldAttachments should throw if any fail + const remainingOldAttachments = personalCiphers.filter((c) => c.hasOldAttachments); + if (remainingOldAttachments.length > 0) { + throw new Error( + `Failed to upgrade all old attachments. ${remainingOldAttachments.length} ciphers still have old attachments.`, + ); + } + } + + this.logService.info( + `Starting transfer of ${personalCiphers.length} personal ciphers to organization ${organizationId} for user ${userId}`, + ); + + await this.cipherService.shareManyWithServer( + personalCiphers, + organizationId, + [defaultCollectionId], + userId, + ); + } + + /** + * Upgrades old attachments that don't have attachment keys. + * Throws an error if any attachment fails to upgrade as it is not possible to share with an organization without a key. + */ + private async upgradeOldAttachments( + ciphers: CipherView[], + userId: UserId, + organizationId: OrganizationId, + ): Promise { + this.logService.info( + `Found ${ciphers.length} ciphers with old attachments needing upgrade during transfer to organization ${organizationId} for user ${userId}`, + ); + + for (const cipher of ciphers) { + try { + if (!cipher.hasOldAttachments) { + continue; + } + + const upgraded = await this.cipherService.upgradeOldCipherAttachments(cipher, userId); + + if (upgraded.hasOldAttachments) { + this.logService.error( + `Attachment upgrade did not complete successfully for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}`, + ); + throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`); + } + } catch (e) { + this.logService.error( + `Failed to upgrade old attachments for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}: ${e}`, + ); + throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`); + } + } + + this.logService.info( + `Successfully upgraded ${ciphers.length} ciphers with old attachments during transfer to organization ${organizationId} for user ${userId}`, + ); + } +}