1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 03:13:55 +00:00

[PM-20579] Update risk-insights report service to use api service with encryption (#15357)

This commit is contained in:
Vijay Oommen
2025-07-08 13:18:36 -05:00
committed by GitHub
parent e591773d4b
commit 7a8f0866ce
12 changed files with 688 additions and 28 deletions

View File

@@ -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<string, "PasswordHealthReportApplicationId">;

View File

@@ -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 = {

View File

@@ -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);
}

View File

@@ -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<KeyService>();
const mockEncryptService = mock<EncryptService>();
const mockKeyGenerationService = mock<KeyGenerationService>();
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<OrgKey>();
const contentEncryptionKey = new SymmetricCryptoKey(new Uint8Array(64));
const testData = { foo: "bar" };
const OrgRecords: Record<OrganizationId, OrgKey> = {
[orgId]: orgKey,
["testOrg" as OrganizationId]: makeSymmetricCryptoKey<OrgKey>(),
};
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();
});
});
});

View File

@@ -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<T>(
organizationId: OrganizationId,
userId: UserId,
data: T,
): Promise<EncryptedDataWithKey> {
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<T>(
organizationId: OrganizationId,
userId: UserId,
encryptedData: EncString,
wrappedKey: EncString,
parser: (data: Jsonify<T>) => T,
): Promise<T | null> {
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;
}
}
}

View File

@@ -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<AuditService>();
const cipherService = mock<CipherService>();
const memberCipherDetailsService = mock<MemberCipherDetailsApiService>();
const mockRiskInsightsApiService = mock<RiskInsightsApiService>();
const mockRiskInsightsEncryptionService = mock<RiskInsightsEncryptionService>({
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);
});
});
});

View File

@@ -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<ApplicationHealthReportDetail[]>([]);
riskInsightsReport$ = this.riskInsightsReportSubject.asObservable();
private riskInsightsSummarySubject = new BehaviorSubject<ApplicationHealthReportSummary>({
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<CipherHealthReportDetail[]> {
generateRawDataReport$(organizationId: OrganizationId): Observable<CipherHealthReportDetail[]> {
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<CipherHealthReportUriDetail[]> {
generateRawDataUriReport$(
organizationId: OrganizationId,
): Observable<CipherHealthReportUriDetail[]> {
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<ApplicationHealthReportDetail[]> {
generateApplicationsReport$(
organizationId: OrganizationId,
): Observable<ApplicationHealthReportDetail[]> {
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<ApplicationHealthReportDetailWithCriticalFlagAndCipher[]> {
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<ReportInsightsReportData>({
data: [],
summary: {
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
},
});
}
return from(
this.riskInsightsEncryptionService.decryptRiskInsightsReport<ReportInsightsReportData>(
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<void> {
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.

View File

@@ -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,

View File

@@ -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())

View File

@@ -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$,

View File

@@ -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),
);

View File

@@ -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<boolean> = new Observable<boolean>();
isRefreshing$: Observable<boolean> = new Observable<boolean>();
@@ -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$;