diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts index aa42cd8bb24..fea9e2c9046 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts @@ -1,8 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore - import { Opaque } from "type-fest"; +import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant } from "@bitwarden/components"; @@ -132,6 +132,16 @@ export type AppAtRiskMembersDialogParams = { applicationName: string; }; +/* + * After data is encrypted, it is returned with the + * encryption key used to encrypt the data. + */ +export interface EncryptedDataWithKey { + organizationId: OrganizationId; + encryptedData: EncryptedString; + encryptionKey: EncryptedString; +} + /** * Request to drop a password health report application * Model is expected by the API endpoint @@ -175,8 +185,8 @@ export interface RiskInsightsReport { } export interface ReportInsightsReportData { - data: string; - key: string; + data: ApplicationHealthReportDetail[]; + summary: ApplicationHealthReportSummary; } export interface SaveRiskInsightsReportRequest { @@ -191,8 +201,8 @@ export interface GetRiskInsightsReportResponse { id: string; organizationId: OrganizationId; date: string; - reportData: string; - reportKey: string; + reportData: EncryptedString; + reportKey: EncryptedString; } export type PasswordHealthReportApplicationId = Opaque; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts index da896cb3753..3b00560d4a1 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { makeEncString } from "@bitwarden/common/spec"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { SaveRiskInsightsReportRequest } from "../models/password-health"; @@ -20,12 +21,15 @@ describe("RiskInsightsApiService", () => { reportKey: "test-key", }; + const reportData = makeEncString("test").encryptedString?.toString() ?? ""; + const reportKey = makeEncString("test-key").encryptedString?.toString() ?? ""; + const saveRiskInsightsReportRequest: SaveRiskInsightsReportRequest = { data: { organizationId: orgId, date: new Date().toISOString(), - reportData: "test", - reportKey: "test-key", + reportData: reportData, + reportKey: reportKey, }, }; const saveRiskInsightsReportResponse = { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts index 386c6fd6865..5e6dbcd54b5 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts @@ -1,6 +1,8 @@ import { BehaviorSubject } from "rxjs"; import { finalize } from "rxjs/operators"; +import { OrganizationId } from "@bitwarden/common/types/guid"; + import { AppAtRiskMembersDialogParams, ApplicationHealthReportDetail, @@ -40,7 +42,7 @@ export class RiskInsightsDataService { * Fetches the applications report and updates the applicationsSubject. * @param organizationId The ID of the organization. */ - fetchApplicationsReport(organizationId: string, isRefresh?: boolean): void { + fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void { if (isRefresh) { this.isRefreshingSubject.next(true); } else { @@ -66,7 +68,7 @@ export class RiskInsightsDataService { }); } - refreshApplicationsReport(organizationId: string): void { + refreshApplicationsReport(organizationId: OrganizationId): void { this.fetchApplicationsReport(organizationId, true); } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts new file mode 100644 index 00000000000..c66a8095c7a --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts @@ -0,0 +1,220 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { makeSymmetricCryptoKey } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; + +describe("RiskInsightsEncryptionService", () => { + let service: RiskInsightsEncryptionService; + const mockKeyService = mock(); + const mockEncryptService = mock(); + const mockKeyGenerationService = mock(); + + const ENCRYPTED_TEXT = "This data has been encrypted"; + const ENCRYPTED_KEY = "Re-encrypted Cipher Key"; + const orgId = "org-123" as OrganizationId; + const userId = "user-123" as UserId; + const orgKey = makeSymmetricCryptoKey(); + const contentEncryptionKey = new SymmetricCryptoKey(new Uint8Array(64)); + const testData = { foo: "bar" }; + const OrgRecords: Record = { + [orgId]: orgKey, + ["testOrg" as OrganizationId]: makeSymmetricCryptoKey(), + }; + const orgKey$ = new BehaviorSubject(OrgRecords); + + beforeEach(() => { + service = new RiskInsightsEncryptionService( + mockKeyService, + mockEncryptService, + mockKeyGenerationService, + ); + + jest.clearAllMocks(); + + // Always use the same contentEncryptionKey for both encrypt and decrypt tests + mockKeyGenerationService.createKey.mockResolvedValue(contentEncryptionKey); + mockEncryptService.wrapSymmetricKey.mockResolvedValue(new EncString(ENCRYPTED_KEY)); + mockEncryptService.encryptString.mockResolvedValue(new EncString(ENCRYPTED_TEXT)); + mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); + mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData)); + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + }); + + describe("encryptRiskInsightsReport", () => { + it("should encrypt data and return encrypted packet", async () => { + // arrange: setup our mocks + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + + // Act: call the method under test + const result = await service.encryptRiskInsightsReport(orgId, userId, testData); + + // Assert: ensure that the methods were called with the expected parameters + expect(mockKeyService.orgKeys$).toHaveBeenCalledWith(userId); + expect(mockKeyGenerationService.createKey).toHaveBeenCalledWith(512); + expect(mockEncryptService.encryptString).toHaveBeenCalledWith( + JSON.stringify(testData), + contentEncryptionKey, + ); + expect(mockEncryptService.wrapSymmetricKey).toHaveBeenCalledWith( + contentEncryptionKey, + orgKey, + ); + expect(result).toEqual({ + organizationId: orgId, + encryptedData: ENCRYPTED_TEXT, + encryptionKey: ENCRYPTED_KEY, + }); + }); + + it("should throw an error when encrypted text is null or empty", async () => { + // arrange: setup our mocks + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + mockEncryptService.encryptString.mockResolvedValue(new EncString("")); + mockEncryptService.wrapSymmetricKey.mockResolvedValue(new EncString(ENCRYPTED_KEY)); + + // Act & Assert: call the method under test and expect rejection + await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow( + "Encryption failed, encrypted strings are null", + ); + }); + + it("should throw an error when encrypted key is null or empty", async () => { + // arrange: setup our mocks + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + mockEncryptService.encryptString.mockResolvedValue(new EncString(ENCRYPTED_TEXT)); + mockEncryptService.wrapSymmetricKey.mockResolvedValue(new EncString("")); + + // Act & Assert: call the method under test and expect rejection + await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow( + "Encryption failed, encrypted strings are null", + ); + }); + + it("should throw if org key is not found", async () => { + // when we cannot get an organization key, we should throw an error + mockKeyService.orgKeys$.mockReturnValue(new BehaviorSubject({})); + + await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow( + "Organization key not found", + ); + }); + }); + + describe("decryptRiskInsightsReport", () => { + it("should decrypt data and return original object", async () => { + // Arrange: setup our mocks + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); + mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData)); + + // act: call the decrypt method - with any params + // actual decryption does not happen here, + // we just want to ensure the method calls are correct + const result = await service.decryptRiskInsightsReport( + orgId, + userId, + new EncString("encrypted-data"), + new EncString("wrapped-key"), + (data) => data as typeof testData, + ); + + expect(mockKeyService.orgKeys$).toHaveBeenCalledWith(userId); + expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith( + new EncString("wrapped-key"), + orgKey, + ); + expect(mockEncryptService.decryptString).toHaveBeenCalledWith( + new EncString("encrypted-data"), + contentEncryptionKey, + ); + expect(result).toEqual(testData); + }); + + it("should invoke data type validation method during decryption", async () => { + // Arrange: setup our mocks + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); + mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData)); + const mockParseFn = jest.fn((data) => data as typeof testData); + + // act: call the decrypt method - with any params + // actual decryption does not happen here, + // we just want to ensure the method calls are correct + const result = await service.decryptRiskInsightsReport( + orgId, + userId, + new EncString("encrypted-data"), + new EncString("wrapped-key"), + mockParseFn, + ); + + expect(mockParseFn).toHaveBeenCalledWith(JSON.parse(JSON.stringify(testData))); + expect(result).toEqual(testData); + }); + + it("should return null if org key is not found", async () => { + mockKeyService.orgKeys$.mockReturnValue(new BehaviorSubject({})); + + const result = await service.decryptRiskInsightsReport( + orgId, + userId, + new EncString("encrypted-data"), + new EncString("wrapped-key"), + (data) => data as typeof testData, + ); + + expect(result).toBeNull(); + }); + + it("should return null if decrypt throws", async () => { + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail")); + + const result = await service.decryptRiskInsightsReport( + orgId, + userId, + new EncString("encrypted-data"), + new EncString("wrapped-key"), + (data) => data as typeof testData, + ); + expect(result).toBeNull(); + }); + + it("should return null if decrypt throws", async () => { + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail")); + + const result = await service.decryptRiskInsightsReport( + orgId, + userId, + new EncString("encrypted-data"), + new EncString("wrapped-key"), + (data) => data as typeof testData, + ); + expect(result).toBeNull(); + }); + + it("should return null if decrypt throws", async () => { + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail")); + + const result = await service.decryptRiskInsightsReport( + orgId, + userId, + new EncString("encrypted-data"), + new EncString("wrapped-key"), + (data) => data as typeof testData, + ); + expect(result).toBeNull(); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts new file mode 100644 index 00000000000..c01b86964f5 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts @@ -0,0 +1,105 @@ +import { firstValueFrom, map } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { KeyService } from "@bitwarden/key-management"; + +import { EncryptedDataWithKey } from "../models/password-health"; + +export class RiskInsightsEncryptionService { + constructor( + private keyService: KeyService, + private encryptService: EncryptService, + private keyGeneratorService: KeyGenerationService, + ) {} + + async encryptRiskInsightsReport( + organizationId: OrganizationId, + userId: UserId, + data: T, + ): Promise { + const orgKey = await firstValueFrom( + this.keyService + .orgKeys$(userId) + .pipe( + map((organizationKeysById) => + organizationKeysById ? organizationKeysById[organizationId] : null, + ), + ), + ); + + if (!orgKey) { + throw new Error("Organization key not found"); + } + + const contentEncryptionKey = await this.keyGeneratorService.createKey(512); + + const dataEncrypted = await this.encryptService.encryptString( + JSON.stringify(data), + contentEncryptionKey, + ); + + const wrappedEncryptionKey = await this.encryptService.wrapSymmetricKey( + contentEncryptionKey, + orgKey, + ); + + if (!dataEncrypted.encryptedString || !wrappedEncryptionKey.encryptedString) { + throw new Error("Encryption failed, encrypted strings are null"); + } + + const encryptedData = dataEncrypted.encryptedString; + const encryptionKey = wrappedEncryptionKey.encryptedString; + + const encryptedDataPacket = { + organizationId: organizationId, + encryptedData: encryptedData, + encryptionKey: encryptionKey, + }; + + return encryptedDataPacket; + } + + async decryptRiskInsightsReport( + organizationId: OrganizationId, + userId: UserId, + encryptedData: EncString, + wrappedKey: EncString, + parser: (data: Jsonify) => T, + ): Promise { + try { + const orgKey = await firstValueFrom( + this.keyService + .orgKeys$(userId) + .pipe( + map((organizationKeysById) => + organizationKeysById ? organizationKeysById[organizationId] : null, + ), + ), + ); + + if (!orgKey) { + throw new Error("Organization key not found"); + } + + const unwrappedEncryptionKey = await this.encryptService.unwrapSymmetricKey( + wrappedKey, + orgKey, + ); + + const dataUnencrypted = await this.encryptService.decryptString( + encryptedData, + unwrappedEncryptionKey, + ); + + const dataUnencryptedJson = parser(JSON.parse(dataUnencrypted)); + + return dataUnencryptedJson as T; + } catch { + return null; + } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts index 3aa624f1e59..00561d7b04d 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts @@ -1,14 +1,20 @@ import { mock } from "jest-mock-extended"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import { ZXCVBNResult } from "zxcvbn"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { GetRiskInsightsReportResponse } from "../models/password-health"; + import { mockCiphers } from "./ciphers.mock"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec"; +import { RiskInsightsApiService } from "./risk-insights-api.service"; +import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; import { RiskInsightsReportService } from "./risk-insights-report.service"; describe("RiskInsightsReportService", () => { @@ -17,6 +23,12 @@ describe("RiskInsightsReportService", () => { const auditService = mock(); const cipherService = mock(); const memberCipherDetailsService = mock(); + const mockRiskInsightsApiService = mock(); + const mockRiskInsightsEncryptionService = mock({ + encryptRiskInsightsReport: jest.fn().mockResolvedValue("encryptedReportData"), + decryptRiskInsightsReport: jest.fn().mockResolvedValue("decryptedReportData"), + }); + const orgId = "orgId" as OrganizationId; beforeEach(() => { pwdStrengthService.getPasswordStrength.mockImplementation((password: string) => { @@ -37,11 +49,13 @@ describe("RiskInsightsReportService", () => { auditService, cipherService, memberCipherDetailsService, + mockRiskInsightsApiService, + mockRiskInsightsEncryptionService, ); }); it("should generate the raw data report correctly", async () => { - const result = await firstValueFrom(service.generateRawDataReport$("orgId")); + const result = await firstValueFrom(service.generateRawDataReport$(orgId)); expect(result).toHaveLength(6); @@ -67,7 +81,7 @@ describe("RiskInsightsReportService", () => { }); it("should generate the raw data + uri report correctly", async () => { - const result = await firstValueFrom(service.generateRawDataUriReport$("orgId")); + const result = await firstValueFrom(service.generateRawDataUriReport$(orgId)); expect(result).toHaveLength(11); @@ -90,7 +104,7 @@ describe("RiskInsightsReportService", () => { }); it("should generate applications health report data correctly", async () => { - const result = await firstValueFrom(service.generateApplicationsReport$("orgId")); + const result = await firstValueFrom(service.generateApplicationsReport$(orgId)); expect(result).toHaveLength(8); @@ -131,7 +145,7 @@ describe("RiskInsightsReportService", () => { }); it("should generate applications summary data correctly", async () => { - const reportResult = await firstValueFrom(service.generateApplicationsReport$("orgId")); + const reportResult = await firstValueFrom(service.generateApplicationsReport$(orgId)); const reportSummary = service.generateApplicationsSummary(reportResult); expect(reportSummary.totalMemberCount).toEqual(7); @@ -139,4 +153,191 @@ describe("RiskInsightsReportService", () => { expect(reportSummary.totalApplicationCount).toEqual(8); expect(reportSummary.totalAtRiskApplicationCount).toEqual(7); }); + + describe("saveRiskInsightsReport", () => { + it("should encrypt and save the report, then update subjects if response has id", async () => { + const organizationId = "orgId" as OrganizationId; + const userId = "userId" as UserId; + const report = [{ applicationName: "app1" }] as any; + const summary = { + totalMemberCount: 1, + totalAtRiskMemberCount: 1, + totalApplicationCount: 1, + totalAtRiskApplicationCount: 1, + }; + + const encryptedReport = { + organizationId: organizationId as OrganizationId, + encryptedData: "encryptedData" as EncryptedString, + encryptionKey: "encryptionKey" as EncryptedString, + }; + + mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue( + encryptedReport, + ); + + const saveResponse = { id: "reportId" }; + mockRiskInsightsApiService.saveRiskInsightsReport.mockReturnValue(of(saveResponse)); + + const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next"); + const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next"); + + await service.saveRiskInsightsReport(organizationId, userId, report, summary); + + expect(mockRiskInsightsEncryptionService.encryptRiskInsightsReport).toHaveBeenCalledWith( + organizationId, + userId, + { data: report, summary }, + ); + + expect(mockRiskInsightsApiService.saveRiskInsightsReport).toHaveBeenCalledWith({ + data: expect.objectContaining({ + organizationId, + date: expect.any(String), // Date should be generated in the service + reportData: encryptedReport.encryptedData, + reportKey: encryptedReport.encryptionKey, + }), + }); + expect(reportSubjectSpy).toHaveBeenCalledWith(report); + expect(summarySubjectSpy).toHaveBeenCalledWith(summary); + }); + + it("should not update subjects if save response does not have id", async () => { + const organizationId = "orgId" as OrganizationId; + const userId = "userId" as UserId; + const report = [{ applicationName: "app1" }] as any; + const summary = { + totalMemberCount: 1, + totalAtRiskMemberCount: 1, + totalApplicationCount: 1, + totalAtRiskApplicationCount: 1, + }; + + const encryptedReport = { + organizationId: organizationId as OrganizationId, + encryptedData: "encryptedData" as EncryptedString, + encryptionKey: "encryptionKey" as EncryptedString, + }; + + mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue( + encryptedReport, + ); + + const saveResponse = { id: "" }; // Simulating no ID in response + mockRiskInsightsApiService.saveRiskInsightsReport.mockReturnValue(of(saveResponse)); + + const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next"); + const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next"); + + await service.saveRiskInsightsReport(organizationId, userId, report, summary); + + expect(reportSubjectSpy).not.toHaveBeenCalled(); + expect(summarySubjectSpy).not.toHaveBeenCalled(); + }); + }); + + describe("getRiskInsightsReport", () => { + beforeEach(() => { + // Reset the mocks before each test + jest.clearAllMocks(); + }); + + it("should call riskInsightsApiService.getRiskInsightsReport with the correct organizationId", () => { + // we need to ensure that the api is invoked with the specified organizationId + // here it doesn't matter what the Api returns + const apiResponse = { + id: "reportId", + date: new Date().toISOString(), + organizationId: "orgId", + reportData: "encryptedReportData", + reportKey: "encryptionKey", + } as GetRiskInsightsReportResponse; + + const organizationId = "orgId" as OrganizationId; + const userId = "userId" as UserId; + mockRiskInsightsApiService.getRiskInsightsReport.mockReturnValue(of(apiResponse)); + service.getRiskInsightsReport(organizationId, userId); + expect(mockRiskInsightsApiService.getRiskInsightsReport).toHaveBeenCalledWith(organizationId); + expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith( + organizationId, + userId, + expect.anything(), // encryptedData + expect.anything(), // wrappedKey + expect.any(Function), // parser + ); + }); + + it("should set empty report and summary if response is falsy", async () => { + // arrange: Api service returns undefined or null + const organizationId = "orgId" as OrganizationId; + const userId = "userId" as UserId; + + // Simulate a falsy response from the API (undefined) + mockRiskInsightsApiService.getRiskInsightsReport.mockReturnValue(of(null)); + const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next"); + const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next"); + + // act: call the service method + service.getRiskInsightsReport(organizationId, userId); + + // wait for the observable to emit and microtasks to complete + await Promise.resolve(); + + // assert: verify that the report and summary subjects are updated with empty values + expect(reportSubjectSpy).toHaveBeenCalledWith([]); + expect(summarySubjectSpy).toHaveBeenCalledWith({ + totalMemberCount: 0, + totalAtRiskMemberCount: 0, + totalApplicationCount: 0, + totalAtRiskApplicationCount: 0, + }); + }); + + it("should decrypt report and update subjects if response is present", async () => { + // Arrange: setup a mock response from the API + // and ensure the decryption service is called with the correct parameters + const organizationId = "orgId" as OrganizationId; + const userId = "userId" as UserId; + + const mockResponse = { + id: "reportId", + date: new Date().toISOString(), + organizationId: organizationId as OrganizationId, + reportData: "encryptedReportData", + reportKey: "encryptionKey", + } as GetRiskInsightsReportResponse; + + const decryptedReport = { + data: [{ foo: "bar" }], + summary: { + totalMemberCount: 1, + totalAtRiskMemberCount: 1, + totalApplicationCount: 1, + totalAtRiskApplicationCount: 1, + }, + }; + mockRiskInsightsApiService.getRiskInsightsReport.mockReturnValue(of(mockResponse)); + mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockResolvedValue( + decryptedReport, + ); + + const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next"); + const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next"); + + service.getRiskInsightsReport(organizationId, userId); + + // Wait for all microtasks to complete + await Promise.resolve(); + + expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith( + organizationId, + userId, + expect.anything(), + expect.anything(), + expect.any(Function), + ); + expect(reportSubjectSpy).toHaveBeenCalledWith(decryptedReport.data); + expect(summarySubjectSpy).toHaveBeenCalledWith(decryptedReport.summary); + }); + }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts index 182e8aa6882..2cde5b4733c 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts @@ -1,10 +1,23 @@ // FIXME: Update this file to be type safe // @ts-strict-ignore -import { concatMap, first, from, map, Observable, zip } from "rxjs"; +import { + BehaviorSubject, + concatMap, + first, + firstValueFrom, + from, + map, + Observable, + of, + switchMap, + zip, +} from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -21,9 +34,12 @@ import { WeakPasswordDetail, WeakPasswordScore, ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ReportInsightsReportData, } from "../models/password-health"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; +import { RiskInsightsApiService } from "./risk-insights-api.service"; +import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; export class RiskInsightsReportService { constructor( @@ -31,8 +47,21 @@ export class RiskInsightsReportService { private auditService: AuditService, private cipherService: CipherService, private memberCipherDetailsApiService: MemberCipherDetailsApiService, + private riskInsightsApiService: RiskInsightsApiService, + private riskInsightsEncryptionService: RiskInsightsEncryptionService, ) {} + private riskInsightsReportSubject = new BehaviorSubject([]); + riskInsightsReport$ = this.riskInsightsReportSubject.asObservable(); + + private riskInsightsSummarySubject = new BehaviorSubject({ + totalMemberCount: 0, + totalAtRiskMemberCount: 0, + totalApplicationCount: 0, + totalAtRiskApplicationCount: 0, + }); + riskInsightsSummary$ = this.riskInsightsSummarySubject.asObservable(); + /** * Report data from raw cipher health data. * Can be used in the Raw Data diagnostic tab (just exclude the members in the view) @@ -40,7 +69,7 @@ export class RiskInsightsReportService { * @param organizationId * @returns Cipher health report data with members and trimmed uris */ - generateRawDataReport$(organizationId: string): Observable { + generateRawDataReport$(organizationId: OrganizationId): Observable { const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId)); const memberCiphers$ = from( this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), @@ -68,7 +97,9 @@ export class RiskInsightsReportService { * @param organizationId Id of the organization * @returns Cipher health report data flattened to the uris */ - generateRawDataUriReport$(organizationId: string): Observable { + generateRawDataUriReport$( + organizationId: OrganizationId, + ): Observable { const cipherHealthDetails$ = this.generateRawDataReport$(organizationId); const results$ = cipherHealthDetails$.pipe( map((healthDetails) => this.getCipherUriDetails(healthDetails)), @@ -84,7 +115,9 @@ export class RiskInsightsReportService { * @param organizationId Id of the organization * @returns The all applications health report data */ - generateApplicationsReport$(organizationId: string): Observable { + generateApplicationsReport$( + organizationId: OrganizationId, + ): Observable { const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId); const results$ = cipherHealthUriReport$.pipe( map((uriDetails) => this.getApplicationHealthReport(uriDetails)), @@ -167,7 +200,7 @@ export class RiskInsightsReportService { async identifyCiphers( data: ApplicationHealthReportDetail[], - organizationId: string, + organizationId: OrganizationId, ): Promise { const cipherViews = await this.cipherService.getAllFromApiForOrganization(organizationId); @@ -181,6 +214,78 @@ export class RiskInsightsReportService { return dataWithCiphers; } + getRiskInsightsReport(organizationId: OrganizationId, userId: UserId): void { + this.riskInsightsApiService + .getRiskInsightsReport(organizationId) + .pipe( + switchMap((response) => { + if (!response) { + // Return an empty report and summary if response is falsy + return of({ + data: [], + summary: { + totalMemberCount: 0, + totalAtRiskMemberCount: 0, + totalApplicationCount: 0, + totalAtRiskApplicationCount: 0, + }, + }); + } + return from( + this.riskInsightsEncryptionService.decryptRiskInsightsReport( + organizationId, + userId, + new EncString(response.reportData), + new EncString(response.reportKey), + (data) => data as ReportInsightsReportData, + ), + ); + }), + ) + .subscribe({ + next: (decryptRiskInsightsReport) => { + this.riskInsightsReportSubject.next(decryptRiskInsightsReport.data); + this.riskInsightsSummarySubject.next(decryptRiskInsightsReport.summary); + }, + }); + } + + async saveRiskInsightsReport( + organizationId: OrganizationId, + userId: UserId, + report: ApplicationHealthReportDetail[], + summary: ApplicationHealthReportSummary, + ): Promise { + const reportWithSummary = { + data: report, + summary: summary, + }; + + const encryptedReport = await this.riskInsightsEncryptionService.encryptRiskInsightsReport( + organizationId, + userId, + reportWithSummary, + ); + + const saveRequest = { + data: { + organizationId: organizationId, + date: new Date().toISOString(), + reportData: encryptedReport.encryptedData, + reportKey: encryptedReport.encryptionKey, + }, + }; + + const response = await firstValueFrom( + this.riskInsightsApiService.saveRiskInsightsReport(saveRequest), + ); + + if (response && response.id) { + this.riskInsightsReportSubject.next(report); + this.riskInsightsSummarySubject.next(summary); + } + } + /** * Associates the members with the ciphers they have access to. Calculates the password health. * Finds the trimmed uris. diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts index 25baf2e0fed..8569e7b2f89 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts @@ -8,9 +8,11 @@ import { RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights/services"; +import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { KeyService } from "@bitwarden/key-management"; @@ -38,6 +40,11 @@ import { RiskInsightsComponent } from "./risk-insights.component"; provide: RiskInsightsDataService, deps: [RiskInsightsReportService], }, + { + provide: RiskInsightsEncryptionService, + useClass: RiskInsightsEncryptionService, + deps: [KeyService, EncryptService, KeyGenerationService], + }, safeProvider({ provide: CriticalAppsService, useClass: CriticalAppsService, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts index ee08ec6661e..cc773f936ee 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts @@ -15,6 +15,7 @@ import { ApplicationHealthReportDetailWithCriticalFlagAndCipher, ApplicationHealthReportSummary, } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; +import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service"; import { getOrganizationById, OrganizationService, @@ -24,6 +25,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { IconButtonModule, @@ -107,7 +109,7 @@ export class AllApplicationsComponent implements OnInit { if (data && organization) { const dataWithCiphers = await this.reportService.identifyCiphers( data, - organization.id, + organization.id as OrganizationId, ); return { @@ -144,6 +146,7 @@ export class AllApplicationsComponent implements OnInit { protected reportService: RiskInsightsReportService, private accountService: AccountService, protected criticalAppsService: CriticalAppsService, + protected riskInsightsEncryptionService: RiskInsightsEncryptionService, ) { this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts index 765d979bbe6..29b65a695b0 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts @@ -60,7 +60,7 @@ export class CriticalApplicationsComponent implements OnInit { protected searchControl = new FormControl("", { nonNullable: true }); private destroyRef = inject(DestroyRef); protected loading = false; - protected organizationId: string; + protected organizationId: OrganizationId; protected applicationSummary = {} as ApplicationHealthReportSummary; noItemsIcon = Icons.Security; isNotificationsFeatureEnabled: boolean = false; @@ -71,7 +71,9 @@ export class CriticalApplicationsComponent implements OnInit { FeatureFlag.EnableRiskInsightsNotifications, ); - this.organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId") ?? ""; + this.organizationId = this.activatedRoute.snapshot.paramMap.get( + "organizationId", + ) as OrganizationId; combineLatest([ this.dataService.applications$, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health.component.ts index 16c783c3f4f..3ac695c38f6 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health.component.ts @@ -10,6 +10,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { RiskInsightsReportService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CipherHealthReportDetail } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { BadgeModule, ContainerComponent, @@ -54,13 +55,13 @@ export class PasswordHealthComponent implements OnInit { takeUntilDestroyed(this.destroyRef), map(async (params) => { const organizationId = params.get("organizationId"); - await this.setCiphers(organizationId); + await this.setCiphers(organizationId as OrganizationId); }), ) .subscribe(); } - async setCiphers(organizationId: string) { + async setCiphers(organizationId: OrganizationId) { this.dataSource.data = await firstValueFrom( this.riskInsightsReportService.generateRawDataReport$(organizationId), ); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 11f7f336f61..96591d87622 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -75,7 +75,7 @@ export class RiskInsightsComponent implements OnInit { criticalAppsCount: number = 0; notifiedMembersCount: number = 0; - private organizationId: string | null = null; + private organizationId: OrganizationId = "" as OrganizationId; private destroyRef = inject(DestroyRef); isLoading$: Observable = new Observable(); isRefreshing$: Observable = new Observable(); @@ -103,10 +103,10 @@ export class RiskInsightsComponent implements OnInit { .pipe( takeUntilDestroyed(this.destroyRef), map((params) => params.get("organizationId")), - switchMap((orgId: string | null) => { + switchMap((orgId) => { if (orgId) { - this.organizationId = orgId; - this.dataService.fetchApplicationsReport(orgId); + this.organizationId = orgId as OrganizationId; + this.dataService.fetchApplicationsReport(this.organizationId); this.isLoading$ = this.dataService.isLoading$; this.isRefreshing$ = this.dataService.isRefreshing$; this.dataLastUpdated$ = this.dataService.dataLastUpdated$;