diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts index b60007ca91..09d4fc3e9e 100644 --- a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts +++ b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts @@ -2,8 +2,11 @@ // @ts-strict-ignore import { Component } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -29,13 +32,18 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent { protected authService: AuthService, private acceptOrganizationInviteService: AcceptOrganizationInviteService, private organizationInviteService: OrganizationInviteService, + private accountService: AccountService, ) { super(router, platformUtilsService, i18nService, route, authService); } async authedHandler(qParams: Params): Promise { const invite = this.fromParams(qParams); - const success = await this.acceptOrganizationInviteService.validateAndAcceptInvite(invite); + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + const success = await this.acceptOrganizationInviteService.validateAndAcceptInvite( + invite, + activeUserId, + ); if (!success) { return; diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts b/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts index 9e3bf1797a..05f424dd77 100644 --- a/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts +++ b/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts @@ -21,7 +21,9 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { OrgKey } from "@bitwarden/common/types/key"; +import { newGuid } from "@bitwarden/guid"; import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; import { I18nService } from "../../core/i18n.service"; @@ -73,10 +75,13 @@ describe("AcceptOrganizationInviteService", () => { }); describe("validateAndAcceptInvite", () => { + const activeUserId = newGuid() as UserId; + it("initializes an organization when given an invite where initOrganization is true", async () => { + const mockOrgKey = "orgPrivateKey" as unknown as OrgKey; keyService.makeOrgKey.mockResolvedValue([ { encryptedString: "string" } as EncString, - "orgPrivateKey" as unknown as OrgKey, + mockOrgKey, ]); keyService.makeKeyPair.mockResolvedValue([ "orgPublicKey", @@ -88,10 +93,12 @@ describe("AcceptOrganizationInviteService", () => { encryptService.encryptString.mockResolvedValue({ encryptedString: "string" } as EncString); const invite = createOrgInvite({ initOrganization: true }); - const result = await sut.validateAndAcceptInvite(invite); + const result = await sut.validateAndAcceptInvite(invite, activeUserId); expect(result).toBe(true); expect(organizationUserApiService.postOrganizationUserAcceptInit).toHaveBeenCalled(); + expect(keyService.makeOrgKey).toHaveBeenCalledWith(activeUserId); + expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockOrgKey); expect(apiService.refreshIdentityToken).toHaveBeenCalled(); expect(organizationUserApiService.postOrganizationUserAccept).not.toHaveBeenCalled(); expect(organizationInviteService.getOrganizationInvite).not.toHaveBeenCalled(); @@ -109,7 +116,7 @@ describe("AcceptOrganizationInviteService", () => { } as Policy, ]); - const result = await sut.validateAndAcceptInvite(invite); + const result = await sut.validateAndAcceptInvite(invite, activeUserId); expect(result).toBe(false); expect(authService.logOut).toHaveBeenCalled(); @@ -130,7 +137,7 @@ describe("AcceptOrganizationInviteService", () => { } as Policy, ]); - const result = await sut.validateAndAcceptInvite(providedInvite); + const result = await sut.validateAndAcceptInvite(providedInvite, activeUserId); expect(result).toBe(false); expect(authService.logOut).toHaveBeenCalled(); @@ -145,7 +152,7 @@ describe("AcceptOrganizationInviteService", () => { const invite = createOrgInvite(); policyApiService.getPoliciesByToken.mockResolvedValue([]); - const result = await sut.validateAndAcceptInvite(invite); + const result = await sut.validateAndAcceptInvite(invite, activeUserId); expect(result).toBe(true); expect(organizationUserApiService.postOrganizationUserAccept).toHaveBeenCalled(); @@ -175,7 +182,7 @@ describe("AcceptOrganizationInviteService", () => { false, ]); - const result = await sut.validateAndAcceptInvite(invite); + const result = await sut.validateAndAcceptInvite(invite, activeUserId); expect(result).toBe(true); expect(organizationUserApiService.postOrganizationUserAccept).toHaveBeenCalled(); @@ -214,7 +221,7 @@ describe("AcceptOrganizationInviteService", () => { true, ]); - const result = await sut.validateAndAcceptInvite(invite); + const result = await sut.validateAndAcceptInvite(invite, activeUserId); expect(result).toBe(true); expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith( diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.service.ts b/apps/web/src/app/auth/organization-invite/accept-organization.service.ts index a5f5eb828f..1a28973af9 100644 --- a/apps/web/src/app/auth/organization-invite/accept-organization.service.ts +++ b/apps/web/src/app/auth/organization-invite/accept-organization.service.ts @@ -25,6 +25,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { Utils } from "@bitwarden/common/platform/misc/utils"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; @Injectable() export class AcceptOrganizationInviteService { @@ -54,16 +55,20 @@ export class AcceptOrganizationInviteService { * Note: Users might need to pass a MP policy check before accepting an invite to an existing organization. If the user * has not passed this check, they will be logged out and the invite will be stored for later use. * @param invite an organization invite + * @param activeUserId the user ID of the active user accepting the invite * @returns a promise that resolves a boolean indicating if the invite was accepted. */ - async validateAndAcceptInvite(invite: OrganizationInvite): Promise { + async validateAndAcceptInvite( + invite: OrganizationInvite, + activeUserId: UserId, + ): Promise { if (invite == null) { throw new Error("Invite cannot be null."); } // Creation of a new org if (invite.initOrganization) { - await this.acceptAndInitOrganization(invite); + await this.acceptAndInitOrganization(invite, activeUserId); return true; } @@ -81,8 +86,11 @@ export class AcceptOrganizationInviteService { return true; } - private async acceptAndInitOrganization(invite: OrganizationInvite): Promise { - await this.prepareAcceptAndInitRequest(invite).then((request) => + private async acceptAndInitOrganization( + invite: OrganizationInvite, + activeUserId: UserId, + ): Promise { + await this.prepareAcceptAndInitRequest(invite, activeUserId).then((request) => this.organizationUserApiService.postOrganizationUserAcceptInit( invite.organizationId, invite.organizationUserId, @@ -95,11 +103,12 @@ export class AcceptOrganizationInviteService { private async prepareAcceptAndInitRequest( invite: OrganizationInvite, + activeUserId: UserId, ): Promise { const request = new OrganizationUserAcceptInitRequest(); request.token = invite.token; - const [encryptedOrgKey, orgKey] = await this.keyService.makeOrgKey(); + const [encryptedOrgKey, orgKey] = await this.keyService.makeOrgKey(activeUserId); const [orgPublicKey, encryptedOrgPrivateKey] = await this.keyService.makeKeyPair(orgKey); const collection = await this.encryptService.encryptString( this.i18nService.t("defaultCollection"), diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts index 20e69cf3bf..431f888250 100644 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts +++ b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts @@ -10,10 +10,12 @@ import { ViewChild, } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; -import { from, Subject, switchMap, takeUntil } from "rxjs"; +import { firstValueFrom, from, Subject, switchMap, takeUntil } from "rxjs"; import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingInformation, OrganizationBillingServiceAbstraction as OrganizationBillingService, @@ -107,6 +109,7 @@ export class TrialBillingStepComponent implements OnInit, OnDestroy { private organizationBillingService: OrganizationBillingService, private toastService: ToastService, private taxService: TaxServiceAbstraction, + private accountService: AccountService, ) {} async ngOnInit(): Promise { @@ -190,6 +193,7 @@ export class TrialBillingStepComponent implements OnInit, OnDestroy { } private async createOrganization(): Promise { + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); const planResponse = this.findPlanFor(this.formGroup.value.cadence); const { type, token } = await this.paymentComponent.tokenize(); @@ -221,11 +225,14 @@ export class TrialBillingStepComponent implements OnInit, OnDestroy { skipTrial: this.trialLength === 0, }; - const response = await this.organizationBillingService.purchaseSubscription({ - organization, - plan, - payment, - }); + const response = await this.organizationBillingService.purchaseSubscription( + { + organization, + plan, + payment, + }, + activeUserId, + ); return response.id; } diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 129d8e1de4..0dd0c0d26f 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -64,6 +64,7 @@ import { ToastService, } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; import { BillingNotificationService } from "../services/billing-notification.service"; import { BillingSharedModule } from "../shared/billing-shared.module"; @@ -769,6 +770,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } const doSubmit = async (): Promise => { + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); let orgId: string = null; const sub = this.sub?.subscription; const isCanceled = sub?.status === "canceled"; @@ -776,7 +778,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { sub?.cancelled && this.organization.productTierType === ProductTierType.Free; if (isCanceled || isCancelledDowngradedToFreeOrg) { - await this.restartSubscription(); + await this.restartSubscription(activeUserId); orgId = this.organizationId; } else { orgId = await this.updateOrganization(); @@ -816,7 +818,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.dialogRef.close(); }; - private async restartSubscription() { + private async restartSubscription(activeUserId: UserId) { const org = await this.organizationApiService.get(this.organizationId); const organization: OrganizationInformation = { name: org.name, @@ -848,11 +850,15 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { billing: this.getBillingInformationFromTaxInfoComponent(), }; - await this.organizationBillingService.restartSubscription(this.organization.id, { - organization, - plan, - payment, - }); + await this.organizationBillingService.restartSubscription( + this.organization.id, + { + organization, + plan, + payment, + }, + activeUserId, + ); } private async updateOrganization() { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 0d2c3a1d03..820bee950e 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -624,7 +624,8 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { const doSubmit = async (): Promise => { let orgId: string; if (this.createOrganization) { - const orgKey = await this.keyService.makeOrgKey(); + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + const orgKey = await this.keyService.makeOrgKey(activeUserId); const key = orgKey[0].encryptedString; const collection = await this.encryptService.encryptString( this.i18nService.t("defaultCollection"), diff --git a/apps/web/src/app/billing/shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component.ts b/apps/web/src/app/billing/shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component.ts index 1850b5e526..e2b43a6a56 100644 --- a/apps/web/src/app/billing/shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component.ts +++ b/apps/web/src/app/billing/shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component.ts @@ -2,11 +2,14 @@ // @ts-strict-ignore import { Component, EventEmitter, Output } from "@angular/core"; import { FormBuilder } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -43,14 +46,15 @@ export class OrganizationSelfHostingLicenseUploaderComponent extends AbstractSel private readonly keyService: KeyService, private readonly organizationApiService: OrganizationApiServiceAbstraction, private readonly syncService: SyncService, + private readonly accountService: AccountService, ) { super(formBuilder, i18nService, platformUtilsService, toastService, tokenService); } protected async submit(): Promise { await super.submit(); - - const orgKey = await this.keyService.makeOrgKey(); + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + const orgKey = await this.keyService.makeOrgKey(activeUserId); const key = orgKey[0].encryptedString; const collection = await this.encryptService.encryptString( this.i18nService.t("defaultCollection"), diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts index 6c02b9a8ff..0b1ddda0c1 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts @@ -29,6 +29,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ToastService } from "@bitwarden/components"; +import { UserId } from "@bitwarden/user-core"; import { OrganizationCreatedEvent, @@ -227,13 +228,14 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { } async orgNameEntrySubmit(): Promise { + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); const isTrialPaymentOptional = await firstValueFrom(this.trialPaymentOptional$); /** Only skip payment if the flag is on AND trialLength > 0 */ if (isTrialPaymentOptional && this.trialLength > 0) { - await this.createOrganizationOnTrial(); + await this.createOrganizationOnTrial(activeUserId); } else { - await this.conditionallyCreateOrganization(); + await this.conditionallyCreateOrganization(activeUserId); } } @@ -245,7 +247,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { } /** create an organization on trial without payment method */ - async createOrganizationOnTrial() { + async createOrganizationOnTrial(activeUserId: UserId) { this.loading = true; let trialInitiationPath: InitiationPath = "Password Manager trial from marketing website"; let plan: PlanInformation = { @@ -272,10 +274,13 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { initiationPath: trialInitiationPath, }; - const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({ - organization, - plan, - }); + const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod( + { + organization, + plan, + }, + activeUserId, + ); this.orgId = response?.id; this.billingSubLabel = response.name.toString(); @@ -351,27 +356,30 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { ); /** Create an organization unless the trial is for secrets manager */ - async conditionallyCreateOrganization(): Promise { + async conditionallyCreateOrganization(activeUserId: UserId): Promise { if (!this.isSecretsManagerFree) { this.verticalStepper.next(); return; } - const response = await this.organizationBillingService.startFree({ - organization: { - name: this.orgInfoFormGroup.value.name == null ? "" : this.orgInfoFormGroup.value.name, - billingEmail: - this.orgInfoFormGroup.value.billingEmail == null - ? "" - : this.orgInfoFormGroup.value.billingEmail, - initiationPath: "Password Manager trial from marketing website", + const response = await this.organizationBillingService.startFree( + { + organization: { + name: this.orgInfoFormGroup.value.name == null ? "" : this.orgInfoFormGroup.value.name, + billingEmail: + this.orgInfoFormGroup.value.billingEmail == null + ? "" + : this.orgInfoFormGroup.value.billingEmail, + initiationPath: "Password Manager trial from marketing website", + }, + plan: { + type: 0, + subscribeToSecretsManager: true, + isFromSecretsManagerTrial: true, + }, }, - plan: { - type: 0, - subscribeToSecretsManager: true, - isFromSecretsManagerTrial: true, - }, - }); + activeUserId, + ); this.orgId = response.id; this.verticalStepper.next(); diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.spec.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.spec.ts new file mode 100644 index 0000000000..2ddfbabf88 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.spec.ts @@ -0,0 +1,119 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; +import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; +import { PlanType } from "@bitwarden/common/billing/enums"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { OrgKey, ProviderKey } from "@bitwarden/common/types/key"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { WebProviderService } from "./web-provider.service"; + +describe("WebProviderService", () => { + let sut: WebProviderService; + let keyService: MockProxy; + let syncService: MockProxy; + let apiService: MockProxy; + let i18nService: MockProxy; + let encryptService: MockProxy; + let billingApiService: MockProxy; + let stateProvider: MockProxy; + let providerApiService: MockProxy; + let accountService: MockProxy; + + beforeEach(() => { + keyService = mock(); + syncService = mock(); + apiService = mock(); + i18nService = mock(); + encryptService = mock(); + billingApiService = mock(); + stateProvider = mock(); + providerApiService = mock(); + accountService = mock(); + + sut = new WebProviderService( + keyService, + syncService, + apiService, + i18nService, + encryptService, + billingApiService, + stateProvider, + providerApiService, + accountService, + ); + }); + + describe("createClientOrganization", () => { + const activeUserId = newGuid() as UserId; + const providerId = "provider-123"; + const name = "Test Org"; + const ownerEmail = "owner@example.com"; + const planType = PlanType.EnterpriseAnnually; + const seats = 10; + const publicKey = "public-key"; + const encryptedPrivateKey = new EncString("encrypted-private-key"); + const encryptedProviderKey = new EncString("encrypted-provider-key"); + const encryptedCollectionName = new EncString("encrypted-collection-name"); + const defaultCollectionTranslation = "Default Collection"; + const mockOrgKey = new SymmetricCryptoKey(new Uint8Array(64)) as OrgKey; + const mockProviderKey = new SymmetricCryptoKey(new Uint8Array(64)) as ProviderKey; + + beforeEach(() => { + keyService.makeOrgKey.mockResolvedValue([new EncString("mockEncryptedKey"), mockOrgKey]); + keyService.makeKeyPair.mockResolvedValue([publicKey, encryptedPrivateKey]); + i18nService.t.mockReturnValue(defaultCollectionTranslation); + encryptService.encryptString.mockResolvedValue(encryptedCollectionName); + keyService.getProviderKey.mockResolvedValue(mockProviderKey); + encryptService.wrapSymmetricKey.mockResolvedValue(encryptedProviderKey); + }); + + it("creates a client organization and calls all dependencies with correct arguments", async () => { + await sut.createClientOrganization( + providerId, + name, + ownerEmail, + planType, + seats, + activeUserId, + ); + + expect(keyService.makeOrgKey).toHaveBeenCalledWith(activeUserId); + expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockOrgKey); + expect(i18nService.t).toHaveBeenCalledWith("defaultCollection"); + expect(encryptService.encryptString).toHaveBeenCalledWith( + defaultCollectionTranslation, + mockOrgKey, + ); + expect(keyService.getProviderKey).toHaveBeenCalledWith(providerId); + expect(encryptService.wrapSymmetricKey).toHaveBeenCalledWith(mockOrgKey, mockProviderKey); + + expect(billingApiService.createProviderClientOrganization).toHaveBeenCalledWith( + providerId, + expect.objectContaining({ + name, + ownerEmail, + planType, + seats, + key: encryptedProviderKey.encryptedString, + keyPair: expect.any(OrganizationKeysRequest), + collectionName: encryptedCollectionName.encryptedString, + }), + ); + + expect(apiService.refreshIdentityToken).toHaveBeenCalled(); + expect(syncService.fullSync).toHaveBeenCalledWith(true); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts index 0f3f6f412a..16c2ab5cd3 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts @@ -16,7 +16,7 @@ import { CreateClientOrganizationRequest } from "@bitwarden/common/billing/model import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { KeyService } from "@bitwarden/key-management"; @@ -78,8 +78,9 @@ export class WebProviderService { ownerEmail: string, planType: PlanType, seats: number, + activeUserId: UserId, ): Promise { - const organizationKey = (await this.keyService.makeOrgKey())[1]; + const organizationKey = (await this.keyService.makeOrgKey(activeUserId))[1]; const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(organizationKey); diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts index edad6616fb..72ca0bc839 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts @@ -3,12 +3,14 @@ import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, switchMap } from "rxjs"; +import { firstValueFrom, Subject, switchMap } from "rxjs"; import { first, takeUntil } from "rxjs/operators"; import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -49,6 +51,7 @@ export class SetupComponent implements OnInit, OnDestroy { private providerApiService: ProviderApiServiceAbstraction, private formBuilder: FormBuilder, private toastService: ToastService, + private accountService: AccountService, ) {} ngOnInit() { @@ -118,8 +121,8 @@ export class SetupComponent implements OnInit, OnDestroy { if (!paymentValid || !taxInformationValid || !this.formGroup.valid) { return; } - - const providerKey = await this.keyService.makeOrgKey(); + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + const providerKey = await this.keyService.makeOrgKey(activeUserId); const key = providerKey[0].encryptedString; const request = new ProviderSetupRequest(); diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts index c7d82c3ec0..2d6a0a7550 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts @@ -1,6 +1,9 @@ import { Component, Inject, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; @@ -130,6 +133,7 @@ export class CreateClientDialogComponent implements OnInit { private i18nService: I18nService, private toastService: ToastService, private webProviderService: WebProviderService, + private accountService: AccountService, ) {} async ngOnInit(): Promise { @@ -198,13 +202,14 @@ export class CreateClientDialogComponent implements OnInit { if (!selectedPlanCard) { return; } - + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); await this.webProviderService.createClientOrganization( this.dialogParams.providerId, this.formGroup.controls.organizationName.value, this.formGroup.controls.clientOwnerEmail.value, selectedPlanCard.type, this.formGroup.controls.seats.value, + activeUserId, ); this.toastService.showToast({ diff --git a/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts index c964108936..a3f8acd648 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/setup/setup-business-unit.component.ts @@ -80,8 +80,9 @@ export class SetupBusinessUnitComponent extends BaseAcceptComponent { map((organizationKeysById) => organizationKeysById[organizationId as OrganizationId]), ); + const userId = await firstValueFrom(activeUserId$); const [{ encryptedString: encryptedProviderKey }, providerKey] = - await this.keyService.makeOrgKey(); + await this.keyService.makeOrgKey(userId); const organizationKey = await firstValueFrom(organizationKey$); @@ -92,8 +93,6 @@ export class SetupBusinessUnitComponent extends BaseAcceptComponent { return await fail(); } - const userId = await firstValueFrom(activeUserId$); - const request = { userId, token, diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index 9089c165a3..3254787457 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -1,3 +1,5 @@ +import { UserId } from "@bitwarden/user-core"; + import { OrganizationResponse } from "../../admin-console/models/response/organization.response"; import { InitiationPath } from "../../models/request/reference-event.request"; import { PaymentMethodType, PlanType } from "../enums"; @@ -47,16 +49,22 @@ export abstract class OrganizationBillingServiceAbstraction { abstract purchaseSubscription( subscription: SubscriptionInformation, + activeUserId: UserId, ): Promise; abstract purchaseSubscriptionNoPaymentMethod( subscription: SubscriptionInformation, + activeUserId: UserId, ): Promise; - abstract startFree(subscription: SubscriptionInformation): Promise; + abstract startFree( + subscription: SubscriptionInformation, + activeUserId: UserId, + ): Promise; abstract restartSubscription( organizationId: string, subscription: SubscriptionInformation, + activeUserId: UserId, ): Promise; } diff --git a/libs/common/src/billing/services/organization-billing.service.spec.ts b/libs/common/src/billing/services/organization-billing.service.spec.ts index 1e666e75bb..42cfb4a537 100644 --- a/libs/common/src/billing/services/organization-billing.service.spec.ts +++ b/libs/common/src/billing/services/organization-billing.service.spec.ts @@ -4,6 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction as OrganizationApiService } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { BillingApiServiceAbstraction, + PaymentInformation, SubscriptionInformation, } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; @@ -11,12 +12,16 @@ import { OrganizationBillingService } from "@bitwarden/common/billing/services/o import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SyncService } from "@bitwarden/common/platform/sync"; +import { newGuid } from "@bitwarden/guid"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; +import { OrganizationKeysRequest } from "../../admin-console/models/request/organization-keys.request"; import { OrganizationResponse } from "../../admin-console/models/response/organization.response"; import { EncString } from "../../key-management/crypto/models/enc-string"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { OrgKey } from "../../types/key"; import { PaymentMethodResponse } from "../models/response/payment-method.response"; @@ -31,6 +36,8 @@ describe("OrganizationBillingService", () => { let sut: OrganizationBillingService; + const mockUserId = newGuid() as UserId; + beforeEach(() => { apiService = mock(); billingApiService = mock(); @@ -115,12 +122,12 @@ describe("OrganizationBillingService", () => { } as OrganizationResponse; organizationApiService.create.mockResolvedValue(organizationResponse); - keyService.makeOrgKey.mockResolvedValue([new EncString("encrypyted-key"), {} as OrgKey]); - keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypyted-key")]); - encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted")); //Act - const response = await sut.purchaseSubscription(subscriptionInformation); + const response = await sut.purchaseSubscription(subscriptionInformation, mockUserId); //Assert expect(organizationApiService.create).toHaveBeenCalledTimes(1); @@ -141,10 +148,10 @@ describe("OrganizationBillingService", () => { organizationApiService.create.mockRejectedValue(new Error("Failed to create organization")); keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); - encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted")); // Act & Assert - await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow( + await expect(sut.purchaseSubscription(subscriptionInformation, mockUserId)).rejects.toThrow( "Failed to create organization", ); }); @@ -163,7 +170,7 @@ describe("OrganizationBillingService", () => { keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed")); // Act & Assert - await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow( + await expect(sut.purchaseSubscription(subscriptionInformation, mockUserId)).rejects.toThrow( "Key generation failed", ); }); @@ -180,7 +187,7 @@ describe("OrganizationBillingService", () => { } as SubscriptionInformation; // Act & Assert - await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow(); + await expect(sut.purchaseSubscription(subscriptionInformation, mockUserId)).rejects.toThrow(); }); }); @@ -204,7 +211,10 @@ describe("OrganizationBillingService", () => { encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted")); //Act - const response = await sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation); + const response = await sut.purchaseSubscriptionNoPaymentMethod( + subscriptionInformation, + mockUserId, + ); //Assert expect(organizationApiService.createWithoutPayment).toHaveBeenCalledTimes(1); @@ -223,7 +233,7 @@ describe("OrganizationBillingService", () => { encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted")); await expect( - sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation), + sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation, mockUserId), ).rejects.toThrow("Creation failed"); }); @@ -237,7 +247,7 @@ describe("OrganizationBillingService", () => { keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); await expect( - sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation), + sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation, mockUserId), ).rejects.toThrow("Key generation failed"); }); }); @@ -256,12 +266,12 @@ describe("OrganizationBillingService", () => { } as OrganizationResponse; organizationApiService.create.mockResolvedValue(organizationResponse); - keyService.makeOrgKey.mockResolvedValue([new EncString("encrypyted-key"), {} as OrgKey]); - keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypyted-key")]); - encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted")); //Act - const response = await sut.startFree(subscriptionInformation); + const response = await sut.startFree(subscriptionInformation, mockUserId); //Assert expect(organizationApiService.create).toHaveBeenCalledTimes(1); @@ -277,7 +287,9 @@ describe("OrganizationBillingService", () => { keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed")); keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); - await expect(sut.startFree(subscriptionInformation)).rejects.toThrow("Key generation failed"); + await expect(sut.startFree(subscriptionInformation, mockUserId)).rejects.toThrow( + "Key generation failed", + ); }); it("given organization creation fails, then it throws an error", async () => { @@ -290,11 +302,162 @@ describe("OrganizationBillingService", () => { organizationApiService.create.mockRejectedValue(new Error("Failed to create organization")); keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); - encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted")); // Act & Assert - await expect(sut.startFree(subscriptionInformation)).rejects.toThrow( + await expect(sut.startFree(subscriptionInformation, mockUserId)).rejects.toThrow( "Failed to create organization", ); }); }); + + describe("organization key creation methods", () => { + const organizationKeys = { + orgKey: new SymmetricCryptoKey(new Uint8Array(64)) as OrgKey, + publicKeyEncapsulatedOrgKey: new EncString("encryptedOrgKey"), + publicKey: "public-key", + encryptedPrivateKey: new EncString("encryptedPrivateKey"), + }; + const encryptedCollectionName = new EncString("encryptedCollectionName"); + const mockSubscription = { + organization: { + name: "Test Org", + businessName: "Test Business", + billingEmail: "test@example.com", + initiationPath: "Registration form", + }, + plan: { + type: 0, // Free plan + passwordManagerSeats: 0, + subscribeToSecretsManager: false, + isFromSecretsManagerTrial: false, + }, + } as SubscriptionInformation; + const mockResponse = { id: "org-id" } as OrganizationResponse; + + const expectedRequestObject = { + name: "Test Org", + businessName: "Test Business", + billingEmail: "test@example.com", + initiationPath: "Registration form", + planType: 0, + key: organizationKeys.publicKeyEncapsulatedOrgKey.encryptedString, + keys: new OrganizationKeysRequest( + organizationKeys.publicKey, + organizationKeys.encryptedPrivateKey.encryptedString!, + ), + collectionName: encryptedCollectionName.encryptedString, + }; + + beforeEach(() => { + keyService.makeOrgKey.mockResolvedValue([ + organizationKeys.publicKeyEncapsulatedOrgKey, + organizationKeys.orgKey, + ]); + keyService.makeKeyPair.mockResolvedValue([ + organizationKeys.publicKey, + organizationKeys.encryptedPrivateKey, + ]); + encryptService.encryptString.mockResolvedValueOnce(encryptedCollectionName); + i18nService.t.mockReturnValue("Default Collection"); + + organizationApiService.create.mockResolvedValue(mockResponse); + }); + + describe("purchaseSubscription", () => { + it("sets the correct organization keys on the organization creation request", async () => { + const subscriptionWithPayment = { + ...mockSubscription, + payment: { + paymentMethod: ["test-token", PaymentMethodType.Card], + billing: { + postalCode: "12345", + country: "US", + }, + } as PaymentInformation, + } as SubscriptionInformation; + const result = await sut.purchaseSubscription(subscriptionWithPayment, mockUserId); + + expect(keyService.makeOrgKey).toHaveBeenCalledWith(mockUserId); + expect(keyService.makeKeyPair).toHaveBeenCalledWith(organizationKeys.orgKey); + expect(encryptService.encryptString).toHaveBeenCalledWith( + "Default Collection", + organizationKeys.orgKey, + ); + expect(organizationApiService.create).toHaveBeenCalledWith( + expect.objectContaining(expectedRequestObject), + ); + expect(apiService.refreshIdentityToken).toHaveBeenCalled(); + expect(syncService.fullSync).toHaveBeenCalledWith(true); + expect(result).toBe(mockResponse); + }); + }); + + describe("purchaseSubscriptionNoPaymentMethod", () => { + it("sets the correct organization keys on the organization creation request", async () => { + organizationApiService.createWithoutPayment.mockResolvedValue(mockResponse); + + const result = await sut.purchaseSubscriptionNoPaymentMethod(mockSubscription, mockUserId); + + expect(keyService.makeOrgKey).toHaveBeenCalledWith(mockUserId); + expect(keyService.makeKeyPair).toHaveBeenCalledWith(organizationKeys.orgKey); + expect(encryptService.encryptString).toHaveBeenCalledWith( + "Default Collection", + organizationKeys.orgKey, + ); + expect(organizationApiService.createWithoutPayment).toHaveBeenCalledWith( + expect.objectContaining(expectedRequestObject), + ); + expect(apiService.refreshIdentityToken).toHaveBeenCalled(); + expect(syncService.fullSync).toHaveBeenCalledWith(true); + expect(result).toBe(mockResponse); + }); + }); + + describe("startFree", () => { + it("sets the correct organization keys on the organization creation request", async () => { + const result = await sut.startFree(mockSubscription, mockUserId); + + expect(keyService.makeOrgKey).toHaveBeenCalledWith(mockUserId); + expect(keyService.makeKeyPair).toHaveBeenCalledWith(organizationKeys.orgKey); + expect(encryptService.encryptString).toHaveBeenCalledWith( + "Default Collection", + organizationKeys.orgKey, + ); + expect(organizationApiService.create).toHaveBeenCalledWith( + expect.objectContaining(expectedRequestObject), + ); + expect(apiService.refreshIdentityToken).toHaveBeenCalled(); + expect(syncService.fullSync).toHaveBeenCalledWith(true); + expect(result).toBe(mockResponse); + }); + }); + + describe("restartSubscription", () => { + it("sets the correct organization keys on the organization creation request", async () => { + const subscriptionWithPayment = { + ...mockSubscription, + payment: { + paymentMethod: ["test-token", PaymentMethodType.Card], + billing: { + postalCode: "12345", + country: "US", + }, + } as PaymentInformation, + } as SubscriptionInformation; + + await sut.restartSubscription("org-id", subscriptionWithPayment, mockUserId); + + expect(keyService.makeOrgKey).toHaveBeenCalledWith(mockUserId); + expect(keyService.makeKeyPair).toHaveBeenCalledWith(organizationKeys.orgKey); + expect(encryptService.encryptString).toHaveBeenCalledWith( + "Default Collection", + organizationKeys.orgKey, + ); + expect(billingApiService.restartSubscription).toHaveBeenCalledWith( + "org-id", + expect.objectContaining(expectedRequestObject), + ); + }); + }); + }); }); diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index e4fe2f9a6b..53ce727df6 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -3,6 +3,7 @@ // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; import { ApiService } from "../../abstractions/api.service"; import { OrganizationApiServiceAbstraction as OrganizationApiService } from "../../admin-console/abstractions/organization/organization-api.service.abstraction"; @@ -49,10 +50,13 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs return paymentMethod?.paymentSource; } - async purchaseSubscription(subscription: SubscriptionInformation): Promise { + async purchaseSubscription( + subscription: SubscriptionInformation, + activeUserId: UserId, + ): Promise { const request = new OrganizationCreateRequest(); - const organizationKeys = await this.makeOrganizationKeys(); + const organizationKeys = await this.makeOrganizationKeys(activeUserId); this.setOrganizationKeys(request, organizationKeys); @@ -73,10 +77,11 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs async purchaseSubscriptionNoPaymentMethod( subscription: SubscriptionInformation, + activeUserId: UserId, ): Promise { const request = new OrganizationNoPaymentMethodCreateRequest(); - const organizationKeys = await this.makeOrganizationKeys(); + const organizationKeys = await this.makeOrganizationKeys(activeUserId); this.setOrganizationKeys(request, organizationKeys); @@ -93,10 +98,13 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs return response; } - async startFree(subscription: SubscriptionInformation): Promise { + async startFree( + subscription: SubscriptionInformation, + activeUserId: UserId, + ): Promise { const request = new OrganizationCreateRequest(); - const organizationKeys = await this.makeOrganizationKeys(); + const organizationKeys = await this.makeOrganizationKeys(activeUserId); this.setOrganizationKeys(request, organizationKeys); @@ -113,8 +121,8 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs return response; } - private async makeOrganizationKeys(): Promise { - const [encryptedKey, key] = await this.keyService.makeOrgKey(); + private async makeOrganizationKeys(activeUserId: UserId): Promise { + const [encryptedKey, key] = await this.keyService.makeOrgKey(activeUserId); const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key); const encryptedCollectionName = await this.encryptService.encryptString( this.i18nService.t("defaultCollection"), @@ -214,9 +222,10 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs async restartSubscription( organizationId: string, subscription: SubscriptionInformation, + activeUserId: UserId, ): Promise { const request = new OrganizationCreateRequest(); - const organizationKeys = await this.makeOrganizationKeys(); + const organizationKeys = await this.makeOrganizationKeys(activeUserId); this.setOrganizationKeys(request, organizationKeys); this.setOrganizationInformation(request, subscription.organization); this.setPlanInformation(request, subscription.plan); diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 0f9618cbab..c6c751bf25 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -259,11 +259,12 @@ export abstract class KeyService { /** * Creates a new organization key and encrypts it with the user's public key. * This method can also return Provider keys for creating new Provider users. - * - * @throws Error when no active user or user have no public key - * @returns The new encrypted org key and the decrypted key itself + * @param userId The user id of the target user's public key to use. + * @throws Error when userId is null or undefined. + * @throws Error when no public key is found for the target user. + * @returns The new encrypted OrgKey | ProviderKey and the decrypted key itself */ - abstract makeOrgKey(): Promise<[EncString, T]>; + abstract makeOrgKey(userId: UserId): Promise<[EncString, T]>; /** * Sets the user's encrypted private key in storage and * clears the decrypted private key from memory diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 6e1ee5d650..52fecf26c7 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -39,7 +39,13 @@ import { } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { UserKey, MasterKey } from "@bitwarden/common/types/key"; +import { + UserKey, + MasterKey, + UserPublicKey, + OrgKey, + ProviderKey, +} from "@bitwarden/common/types/key"; import { KdfConfigService } from "./abstractions/kdf-config.service"; import { UserPrivateKeyDecryptionFailedError } from "./abstractions/key.service"; @@ -1029,6 +1035,66 @@ describe("keyService", () => { }); }); + describe("makeOrgKey", () => { + const mockUserPublicKey = new Uint8Array(64) as UserPublicKey; + const shareKey = new SymmetricCryptoKey(new Uint8Array(64)); + const mockEncapsulatedKey = new EncString("mockEncapsulatedKey"); + + beforeEach(() => { + keyService.userPublicKey$ = jest + .fn() + .mockReturnValueOnce(new BehaviorSubject(mockUserPublicKey)); + keyGenerationService.createKey.mockResolvedValue(shareKey); + encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncapsulatedKey); + }); + + it("creates a new OrgKey and encapsulates it with the user's public key", async () => { + const result = await keyService.makeOrgKey(mockUserId); + + expect(result).toEqual([mockEncapsulatedKey, shareKey as OrgKey]); + expect(keyService.userPublicKey$).toHaveBeenCalledWith(mockUserId); + expect(keyGenerationService.createKey).toHaveBeenCalledWith(512); + expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith( + shareKey, + mockUserPublicKey, + ); + }); + + it("creates a new ProviderKey and encapsulates it with the user's public key", async () => { + const result = await keyService.makeOrgKey(mockUserId); + + expect(result).toEqual([mockEncapsulatedKey, shareKey as ProviderKey]); + expect(keyService.userPublicKey$).toHaveBeenCalledWith(mockUserId); + expect(keyGenerationService.createKey).toHaveBeenCalledWith(512); + expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith( + shareKey, + mockUserPublicKey, + ); + }); + + test.each([null as unknown as UserId, undefined as unknown as UserId])( + "throws when the provided userId is %s", + async (userId) => { + await expect(keyService.makeOrgKey(userId)).rejects.toThrow("UserId is required"); + + expect(keyService.userPublicKey$).not.toHaveBeenCalled(); + expect(keyGenerationService.createKey).not.toHaveBeenCalled(); + expect(encryptService.encapsulateKeyUnsigned).not.toHaveBeenCalled(); + }, + ); + + it("throws if the user's public key is not found", async () => { + keyService.userPublicKey$ = jest.fn().mockReturnValueOnce(new BehaviorSubject(null)); + + await expect(keyService.makeOrgKey(mockUserId)).rejects.toThrow( + "No public key found for user " + mockUserId, + ); + + expect(keyGenerationService.createKey).not.toHaveBeenCalled(); + expect(encryptService.encapsulateKeyUnsigned).not.toHaveBeenCalled(); + }); + }); + describe("userEncryptionKeyPair$", () => { type SetupKeysParams = { makeMasterKey: boolean; diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 53f7c6ed15..ab41e66279 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -446,19 +446,17 @@ export class DefaultKeyService implements KeyServiceAbstraction { await this.stateProvider.setUserState(USER_ENCRYPTED_PROVIDER_KEYS, null, userId); } - // TODO: Make userId required - async makeOrgKey(userId?: UserId): Promise<[EncString, T]> { - const shareKey = await this.keyGenerationService.createKey(512); - userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + async makeOrgKey(userId: UserId): Promise<[EncString, T]> { if (userId == null) { - throw new Error("No active user found."); + throw new Error("UserId is required"); } const publicKey = await firstValueFrom(this.userPublicKey$(userId)); if (publicKey == null) { - throw new Error("No public key found."); + throw new Error("No public key found for user " + userId); } + const shareKey = await this.keyGenerationService.createKey(512); const encShareKey = await this.encryptService.encapsulateKeyUnsigned(shareKey, publicKey); return [encShareKey, shareKey as T]; }