1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

[PM-25613] Add report trigger logic (#16615)

* Add password trigger logic to report service. Also updated api to use classes that properly handle encstring with placeholders for upcoming usage

* Fix merged test case conflict

* Fix type errors and test cases. Make create data functions for report and summary
This commit is contained in:
Leslie Tilton
2025-09-29 14:37:23 -05:00
committed by GitHub
parent 4f79cc8c52
commit e784622f67
16 changed files with 630 additions and 374 deletions

View File

@@ -6,7 +6,11 @@ import {
LEGACY_CipherHealthReportDetail,
LEGACY_CipherHealthReportUriDetail,
} from "../models/password-health";
import { ApplicationHealthReportDetail } from "../models/report-models";
import {
ApplicationHealthReportDetail,
OrganizationReportSummary,
RiskInsightsReportData,
} from "../models/report-models";
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
export function flattenMemberDetails(
@@ -144,3 +148,34 @@ export function getApplicationReportDetail(
return reportDetail;
}
/**
* Create a new Risk Insights Report
*
* @returns An empty report
*/
export function createNewReportData(): RiskInsightsReportData {
return {
data: [],
summary: createNewSummaryData(),
};
}
/**
* Create a new Risk Insights Report Summary
*
* @returns An empty report summary
*/
export function createNewSummaryData(): OrganizationReportSummary {
return {
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
totalCriticalMemberCount: 0,
totalCriticalAtRiskMemberCount: 0,
totalCriticalApplicationCount: 0,
totalCriticalAtRiskApplicationCount: 0,
newApplications: [],
};
}

View File

@@ -1 +1,2 @@
export * from "./services";
export * from "./models";

View File

@@ -1,7 +1,10 @@
import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { PasswordHealthReportApplicationId, RiskInsightsReport } from "./report-models";
import { createNewSummaryData } from "../helpers";
import { OrganizationReportSummary, PasswordHealthReportApplicationId } from "./report-models";
// -------------------- Password Health Report Models --------------------
/**
@@ -32,18 +35,76 @@ export interface PasswordHealthReportApplicationsRequest {
// -------------------- Risk Insights Report Models --------------------
export interface SaveRiskInsightsReportRequest {
data: RiskInsightsReport;
data: {
organizationId: OrganizationId;
date: string;
reportData: string;
contentEncryptionKey: string;
};
}
export interface SaveRiskInsightsReportResponse {
export class SaveRiskInsightsReportResponse extends BaseResponse {
id: string;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("organizationId");
}
}
export function isSaveRiskInsightsReportResponse(obj: any): obj is SaveRiskInsightsReportResponse {
return obj && typeof obj.id === "string" && obj.id !== "";
}
export interface GetRiskInsightsReportResponse {
export class GetRiskInsightsReportResponse extends BaseResponse {
id: string;
organizationId: OrganizationId;
// TODO Update to use creationDate from server
date: string;
reportData: EncryptedString;
contentEncryptionKey: EncryptedString;
reportData: EncString;
contentEncryptionKey: EncString;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("organizationId");
this.organizationId = this.getResponseProperty("organizationId");
this.date = this.getResponseProperty("date");
this.reportData = new EncString(this.getResponseProperty("reportData"));
this.contentEncryptionKey = new EncString(this.getResponseProperty("contentEncryptionKey"));
}
}
export class GetRiskInsightsSummaryResponse extends BaseResponse {
id: string;
organizationId: OrganizationId;
encryptedData: EncString; // Decrypted as OrganizationReportSummary
contentEncryptionKey: EncString;
constructor(response: any) {
super(response);
// TODO Handle taking array of summary data and converting to array
this.id = this.getResponseProperty("id");
this.organizationId = this.getResponseProperty("organizationId");
this.encryptedData = this.getResponseProperty("encryptedData");
this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey");
}
// TODO
getSummary(): OrganizationReportSummary {
return createNewSummaryData();
}
}
export class GetRiskInsightsApplicationDataResponse extends BaseResponse {
reportId: string;
organizationId: OrganizationId;
encryptedData: EncString;
contentEncryptionKey: EncString;
constructor(response: any) {
super(response);
this.reportId = this.getResponseProperty("reportId");
this.organizationId = this.getResponseProperty("organizationId");
this.encryptedData = this.getResponseProperty("encryptedData");
this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey");
}
}

View File

@@ -0,0 +1,3 @@
export * from "./api-models.types";
export * from "./password-health";
export * from "./report-models";

View File

@@ -1,9 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BadgeVariant } from "@bitwarden/components";
import { EncString } from "@bitwarden/sdk-internal";
import { ApplicationHealthReportDetail } from "./report-models";
@@ -40,7 +40,7 @@ export type ExposedPasswordDetail = {
export interface EncryptedDataWithKey {
organizationId: OrganizationId;
encryptedData: EncString;
encryptionKey: EncString;
contentEncryptionKey: EncString;
}
export type LEGACY_MemberDetailsFlat = {
@@ -76,10 +76,3 @@ export type LEGACY_CipherHealthReportUriDetail = {
trimmedUri: string;
cipher: CipherView;
};
export interface EncryptedDataModel {
organizationId: OrganizationId;
encryptedData: string;
encryptionKey: string;
date: Date;
}

View File

@@ -1,6 +1,5 @@
import { Opaque } from "type-fest";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BadgeVariant } from "@bitwarden/components";
@@ -154,12 +153,6 @@ export interface RiskInsightsReportData {
data: ApplicationHealthReportDetailEnriched[];
summary: OrganizationReportSummary;
}
export interface RiskInsightsReport {
organizationId: OrganizationId;
date: string;
reportData: string;
reportKey: string;
}
export type ReportScore = { label: string; badgeVariant: BadgeVariant; sortOrder: number };

View File

@@ -32,6 +32,7 @@ export class PasswordHealthService {
.passwordLeaked(cipher.login.password)
.then((exposedCount) => ({ cipher, exposedCount })),
),
// [FIXME] ExposedDetails is can still return a null
filter(({ exposedCount }) => exposedCount > 0),
map(({ cipher, exposedCount }) => ({
exposedXTimes: exposedCount,

View File

@@ -1,11 +1,20 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { makeEncString } from "@bitwarden/common/spec";
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
import { SaveRiskInsightsReportRequest } from "../models/api-models.types";
import { EncryptedDataModel } from "../models/password-health";
import {
GetRiskInsightsApplicationDataResponse,
GetRiskInsightsReportResponse,
GetRiskInsightsSummaryResponse,
SaveRiskInsightsReportRequest,
SaveRiskInsightsReportResponse,
} from "../models/api-models.types";
import { EncryptedDataWithKey } from "../models/password-health";
import { RiskInsightsApiService } from "./risk-insights-api.service";
@@ -13,14 +22,11 @@ describe("RiskInsightsApiService", () => {
let service: RiskInsightsApiService;
const mockApiService = mock<ApiService>();
const mockId = "id";
const orgId = "org1" as OrganizationId;
const getRiskInsightsReportResponse = {
organizationId: orgId,
date: new Date().toISOString(),
reportData: "test",
reportKey: "test-key",
};
const mockReportId = "report-1";
const mockKey = "encryption-key-1";
const mockData = "encrypted-data";
const reportData = makeEncString("test").encryptedString?.toString() ?? "";
const reportKey = makeEncString("test-key").encryptedString?.toString() ?? "";
@@ -30,12 +36,9 @@ describe("RiskInsightsApiService", () => {
organizationId: orgId,
date: new Date().toISOString(),
reportData: reportData,
reportKey: reportKey,
contentEncryptionKey: reportKey,
},
};
const saveRiskInsightsReportResponse = {
...saveRiskInsightsReportRequest.data,
};
beforeEach(() => {
service = new RiskInsightsApiService(mockApiService);
@@ -45,11 +48,19 @@ describe("RiskInsightsApiService", () => {
expect(service).toBeTruthy();
});
it("Get Report: should call apiService.send with correct parameters and return the response for getRiskInsightsReport ", (done) => {
it("getRiskInsightsReport$ should call apiService.send with correct parameters and return the response", () => {
const getRiskInsightsReportResponse = {
id: mockId,
organizationId: orgId,
date: new Date().toISOString(),
reportData: mockData,
contentEncryptionKey: mockKey,
};
mockApiService.send.mockReturnValue(Promise.resolve(getRiskInsightsReportResponse));
service.getRiskInsightsReport$(orgId).subscribe((result) => {
expect(result).toEqual(getRiskInsightsReportResponse);
expect(result).toEqual(new GetRiskInsightsReportResponse(getRiskInsightsReportResponse));
expect(mockApiService.send).toHaveBeenCalledWith(
"GET",
`/reports/organizations/${orgId.toString()}/latest`,
@@ -57,30 +68,24 @@ describe("RiskInsightsApiService", () => {
true,
true,
);
done();
});
});
it("Get Report: should return null if apiService.send rejects with 404 error for getRiskInsightsReport", (done) => {
const error = { statusCode: 404 };
mockApiService.send.mockReturnValue(Promise.reject(error));
it("getRiskInsightsReport$ should return null if apiService.send rejects with 404 error", async () => {
const mockError = new ErrorResponse(null, 404);
mockApiService.send.mockReturnValue(Promise.reject(mockError));
const result = await firstValueFrom(service.getRiskInsightsReport$(orgId));
service.getRiskInsightsReport$(orgId).subscribe((result) => {
expect(result).toBeNull();
done();
});
});
it("Get Report: should throw error if apiService.send rejects with non-404 error for getRiskInsightsReport", (done) => {
it("getRiskInsightsReport$ should propagate errors if apiService.send rejects 500 server error", async () => {
const error = { statusCode: 500, message: "Server error" };
mockApiService.send.mockReturnValue(Promise.reject(error));
service.getRiskInsightsReport$(orgId).subscribe({
next: () => {
// Should not reach here
fail("Expected error to be thrown");
},
error: () => {
await expect(firstValueFrom(service.getRiskInsightsReport$(orgId))).rejects.toEqual(error);
expect(mockApiService.send).toHaveBeenCalledWith(
"GET",
`/reports/organizations/${orgId.toString()}/latest`,
@@ -88,19 +93,16 @@ describe("RiskInsightsApiService", () => {
true,
true,
);
done();
},
complete: () => {
done();
},
});
});
it("Save Report: should call apiService.send with correct parameters for saveRiskInsightsReport", (done) => {
mockApiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportResponse));
it("saveRiskInsightsReport$ should call apiService.send with correct parameters", async () => {
mockApiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportRequest));
service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId).subscribe((result) => {
expect(result).toEqual(saveRiskInsightsReportResponse);
const result = await firstValueFrom(
service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId),
);
expect(result).toEqual(new SaveRiskInsightsReportResponse(saveRiskInsightsReportRequest));
expect(mockApiService.send).toHaveBeenCalledWith(
"POST",
`/reports/organizations/${orgId.toString()}`,
@@ -108,19 +110,16 @@ describe("RiskInsightsApiService", () => {
true,
true,
);
done();
});
});
it("Save Report: should propagate errors from apiService.send for saveRiskInsightsReport - 1", (done) => {
it("saveRiskInsightsReport$ should propagate errors from apiService.send for saveRiskInsightsReport - 1", async () => {
const error = { statusCode: 500, message: "Internal Server Error" };
mockApiService.send.mockReturnValue(Promise.reject(error));
service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId).subscribe({
next: () => {
fail("Expected error to be thrown");
},
error: () => {
await expect(
firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)),
).rejects.toEqual(error);
expect(mockApiService.send).toHaveBeenCalledWith(
"POST",
`/reports/organizations/${orgId.toString()}`,
@@ -128,23 +127,16 @@ describe("RiskInsightsApiService", () => {
true,
true,
);
done();
},
complete: () => {
done();
},
});
});
it("Save Report: should propagate network errors from apiService.send for saveRiskInsightsReport - 2", (done) => {
it("saveRiskInsightsReport$ should propagate network errors from apiService.send - 2", async () => {
const error = new Error("Network error");
mockApiService.send.mockReturnValue(Promise.reject(error));
service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId).subscribe({
next: () => {
fail("Expected error to be thrown");
},
error: () => {
await expect(
firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)),
).rejects.toEqual(error);
expect(mockApiService.send).toHaveBeenCalledWith(
"POST",
`/reports/organizations/${orgId.toString()}`,
@@ -152,22 +144,24 @@ describe("RiskInsightsApiService", () => {
true,
true,
);
done();
},
complete: () => {
done();
},
});
});
it("Get Summary: should call apiService.send with correct parameters and return an Observable", (done) => {
it("getRiskInsightsSummary$ should call apiService.send with correct parameters and return an Observable", async () => {
const minDate = new Date("2024-01-01");
const maxDate = new Date("2024-01-31");
const mockResponse: EncryptedDataModel[] = [{ encryptedData: "abc" } as EncryptedDataModel];
const mockResponse = [
{
reportId: mockReportId,
organizationId: orgId,
encryptedData: mockData,
contentEncryptionKey: mockKey,
},
];
mockApiService.send.mockResolvedValueOnce(mockResponse);
service.getRiskInsightsSummary$(orgId, minDate, maxDate).subscribe((result) => {
const result = await firstValueFrom(service.getRiskInsightsSummary$(orgId, minDate, maxDate));
expect(mockApiService.send).toHaveBeenCalledWith(
"GET",
`/reports/organizations/${orgId.toString()}/data/summary?startDate=${minDate.toISOString().split("T")[0]}&endDate=${maxDate.toISOString().split("T")[0]}`,
@@ -175,18 +169,22 @@ describe("RiskInsightsApiService", () => {
true,
true,
);
expect(result).toEqual(mockResponse);
done();
});
expect(result).toEqual(new GetRiskInsightsSummaryResponse(mockResponse));
});
it("Update Summary: should call apiService.send with correct parameters and return an Observable", (done) => {
const data: EncryptedDataModel = { encryptedData: "xyz" } as EncryptedDataModel;
it("updateRiskInsightsSummary$ should call apiService.send with correct parameters and return an Observable", async () => {
const data: EncryptedDataWithKey = {
organizationId: orgId,
encryptedData: new EncString(mockData),
contentEncryptionKey: new EncString(mockKey),
};
const reportId = "report123" as OrganizationReportId;
mockApiService.send.mockResolvedValueOnce(undefined);
service.updateRiskInsightsSummary$(data, orgId, reportId).subscribe((result) => {
const result = await firstValueFrom(service.updateRiskInsightsSummary$(data, orgId, reportId));
expect(mockApiService.send).toHaveBeenCalledWith(
"PATCH",
`/reports/organizations/${orgId.toString()}/data/summary/${reportId.toString()}`,
@@ -195,19 +193,19 @@ describe("RiskInsightsApiService", () => {
true,
);
expect(result).toBeUndefined();
done();
});
});
it("Get Applications: should call apiService.send with correct parameters and return an Observable", (done) => {
it("getRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => {
const reportId = "report123" as OrganizationReportId;
const mockResponse: EncryptedDataModel | null = {
encryptedData: "abc",
} as EncryptedDataModel;
const mockResponse: EncryptedDataWithKey | null = {
organizationId: orgId,
encryptedData: new EncString(mockData),
contentEncryptionKey: new EncString(mockKey),
};
mockApiService.send.mockResolvedValueOnce(mockResponse);
service.getRiskInsightsApplicationData$(orgId, reportId).subscribe((result) => {
const result = await firstValueFrom(service.getRiskInsightsApplicationData$(orgId, reportId));
expect(mockApiService.send).toHaveBeenCalledWith(
"GET",
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
@@ -215,20 +213,21 @@ describe("RiskInsightsApiService", () => {
true,
true,
);
expect(result).toEqual(mockResponse);
done();
});
expect(result).toEqual(new GetRiskInsightsApplicationDataResponse(mockResponse));
});
it("Update Applications: should call apiService.send with correct parameters and return an Observable", (done) => {
const applicationData: EncryptedDataModel = { encryptedData: "xyz" } as EncryptedDataModel;
it("updateRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => {
const applicationData: EncryptedDataWithKey = {
organizationId: orgId,
encryptedData: new EncString(mockData),
contentEncryptionKey: new EncString(mockKey),
};
const reportId = "report123" as OrganizationReportId;
mockApiService.send.mockResolvedValueOnce(undefined);
service
.updateRiskInsightsApplicationData$(applicationData, orgId, reportId)
.subscribe((result) => {
const result = await firstValueFrom(
service.updateRiskInsightsApplicationData$(applicationData, orgId, reportId),
);
expect(mockApiService.send).toHaveBeenCalledWith(
"PATCH",
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
@@ -237,7 +236,5 @@ describe("RiskInsightsApiService", () => {
true,
);
expect(result).toBeUndefined();
done();
});
});
});

View File

@@ -1,29 +1,38 @@
import { from, Observable } from "rxjs";
import { catchError, from, map, Observable, of, throwError } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
import {
GetRiskInsightsApplicationDataResponse,
GetRiskInsightsReportResponse,
GetRiskInsightsSummaryResponse,
SaveRiskInsightsReportRequest,
SaveRiskInsightsReportResponse,
} from "../models/api-models.types";
import { EncryptedDataModel } from "../models/password-health";
import { EncryptedDataWithKey } from "../models/password-health";
export class RiskInsightsApiService {
constructor(private apiService: ApiService) {}
getRiskInsightsReport$(orgId: OrganizationId): Observable<GetRiskInsightsReportResponse | null> {
const dbResponse = this.apiService
.send("GET", `/reports/organizations/${orgId.toString()}/latest`, null, true, true)
.catch((error: any): any => {
if (error.statusCode === 404) {
return null; // Handle 404 by returning null or an appropriate default value
const dbResponse = this.apiService.send(
"GET",
`/reports/organizations/${orgId.toString()}/latest`,
null,
true,
true,
);
return from(dbResponse).pipe(
map((response) => new GetRiskInsightsReportResponse(response)),
catchError((error: unknown) => {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return of(null); // Handle 404 by returning null or an appropriate default value
}
throw error; // Re-throw other errors
});
return from(dbResponse as Promise<GetRiskInsightsReportResponse>);
return throwError(() => error); // Re-throw other errors
}),
);
}
saveRiskInsightsReport$(
@@ -38,14 +47,14 @@ export class RiskInsightsApiService {
true,
);
return from(dbResponse as Promise<SaveRiskInsightsReportResponse>);
return from(dbResponse).pipe(map((response) => new SaveRiskInsightsReportResponse(response)));
}
getRiskInsightsSummary$(
orgId: string,
minDate: Date,
maxDate: Date,
): Observable<EncryptedDataModel[]> {
): Observable<GetRiskInsightsSummaryResponse> {
const minDateStr = minDate.toISOString().split("T")[0];
const maxDateStr = maxDate.toISOString().split("T")[0];
const dbResponse = this.apiService.send(
@@ -56,11 +65,11 @@ export class RiskInsightsApiService {
true,
);
return from(dbResponse as Promise<EncryptedDataModel[]>);
return from(dbResponse).pipe(map((response) => new GetRiskInsightsSummaryResponse(response)));
}
updateRiskInsightsSummary$(
summaryData: EncryptedDataModel,
summaryData: EncryptedDataWithKey,
organizationId: OrganizationId,
reportId: OrganizationReportId,
): Observable<void> {
@@ -78,7 +87,7 @@ export class RiskInsightsApiService {
getRiskInsightsApplicationData$(
orgId: OrganizationId,
reportId: OrganizationReportId,
): Observable<EncryptedDataModel | null> {
): Observable<GetRiskInsightsApplicationDataResponse> {
const dbResponse = this.apiService.send(
"GET",
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
@@ -87,11 +96,13 @@ export class RiskInsightsApiService {
true,
);
return from(dbResponse as Promise<EncryptedDataModel | null>);
return from(dbResponse).pipe(
map((response) => new GetRiskInsightsApplicationDataResponse(response)),
);
}
updateRiskInsightsApplicationData$(
applicationData: EncryptedDataModel,
applicationData: EncryptedDataWithKey,
orgId: OrganizationId,
reportId: OrganizationReportId,
): Observable<void> {

View File

@@ -1,5 +1,14 @@
import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs";
import { finalize, switchMap, withLatestFrom, map } from "rxjs/operators";
import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of } from "rxjs";
import {
distinctUntilChanged,
exhaustMap,
filter,
finalize,
map,
switchMap,
tap,
withLatestFrom,
} from "rxjs/operators";
import {
getOrganizationById,
@@ -17,6 +26,7 @@ import {
DrawerDetails,
ApplicationHealthReportDetail,
ApplicationHealthReportDetailEnriched,
ReportDetailsAndSummary,
} from "../models/report-models";
import { CriticalAppsService } from "./critical-apps.service";
@@ -66,6 +76,15 @@ export class RiskInsightsDataService {
});
drawerDetails$ = this.drawerDetailsSubject.asObservable();
// ------------------------- Report Variables ----------------
// The last run report details
private reportResultsSubject = new BehaviorSubject<ReportDetailsAndSummary | null>(null);
reportResults$ = this.reportResultsSubject.asObservable();
// Is a report being generated
private isRunningReportSubject = new BehaviorSubject<boolean>(false);
isRunningReport$ = this.isRunningReportSubject.asObservable();
// The error from report generation if there was an error
constructor(
private accountService: AccountService,
private criticalAppsService: CriticalAppsService,
@@ -81,7 +100,7 @@ export class RiskInsightsDataService {
this.userIdSubject.next(userId);
}
// [FIXME] getOrganizationById is now deprecated - update when we can
// [FIXME] getOrganizationById is now deprecated - replace with appropriate method
// Fetch organization details
const org = await firstValueFrom(
this.organizationService.organizations$(userId).pipe(getOrganizationById(organizationId)),
@@ -96,20 +115,18 @@ export class RiskInsightsDataService {
// Load critical applications for organization
await this.criticalAppsService.loadOrganizationContext(organizationId, userId);
// TODO: PM-25613
// // Load existing report
// Load existing report
this.fetchLastReport(organizationId, userId);
// this.fetchLastReport(organizationId, userId);
// // Setup new report generation
// this._runApplicationsReport().subscribe({
// next: (result) => {
// this.isRunningReportSubject.next(false);
// },
// error: () => {
// this.errorSubject.next("Failed to save report");
// },
// });
// Setup new report generation
this._runApplicationsReport().subscribe({
next: (result) => {
this.isRunningReportSubject.next(false);
},
error: () => {
this.errorSubject.next("Failed to save report");
},
});
}
/**
@@ -276,4 +293,88 @@ export class RiskInsightsDataService {
});
}
};
// ------------------- Trigger Report Generation -------------------
/** Trigger generating a report based on the current applications */
triggerReport(): void {
this.isRunningReportSubject.next(true);
}
/**
* Fetches the applications report and updates the applicationsSubject.
* @param organizationId The ID of the organization.
*/
fetchLastReport(organizationId: OrganizationId, userId: UserId): void {
this.isLoadingSubject.next(true);
this.reportService
.getRiskInsightsReport$(organizationId, userId)
.pipe(
switchMap((report) => {
return this.enrichReportData$(report.data).pipe(
map((enrichedReport) => ({
data: enrichedReport,
summary: report.summary,
})),
);
}),
finalize(() => {
this.isLoadingSubject.next(false);
}),
)
.subscribe({
next: ({ data, summary }) => {
this.reportResultsSubject.next({
data,
summary,
dateCreated: new Date(),
});
this.errorSubject.next(null);
this.isLoadingSubject.next(false);
},
error: () => {
this.errorSubject.next("Failed to fetch report");
this.reportResultsSubject.next(null);
this.isLoadingSubject.next(false);
},
});
}
private _runApplicationsReport() {
return this.isRunningReport$.pipe(
distinctUntilChanged(),
filter((isRunning) => isRunning),
withLatestFrom(this.organizationDetails$, this.userId$),
exhaustMap(([_, organizationDetails, userId]) => {
const organizationId = organizationDetails?.organizationId;
if (!organizationId || !userId) {
return EMPTY;
}
// Generate the report
return this.reportService.generateApplicationsReport$(organizationId).pipe(
map((data) => ({
data,
summary: this.reportService.generateApplicationsSummary(data),
})),
switchMap(({ data, summary }) =>
this.enrichReportData$(data).pipe(
map((enrichedData) => ({ data: enrichedData, summary })),
),
),
tap(({ data, summary }) => {
this.reportResultsSubject.next({ data, summary, dateCreated: new Date() });
this.errorSubject.next(null);
}),
switchMap(({ data, summary }) => {
// Just returns ID
return this.reportService.saveRiskInsightsReport$(data, summary, {
organizationId,
userId,
});
}),
);
}),
);
}
}

View File

@@ -70,8 +70,8 @@ describe("RiskInsightsEncryptionService", () => {
);
expect(result).toEqual({
organizationId: orgId,
encryptedData: ENCRYPTED_TEXT,
encryptionKey: ENCRYPTED_KEY,
encryptedData: new EncString(ENCRYPTED_TEXT),
contentEncryptionKey: new EncString(ENCRYPTED_KEY),
});
});

View File

@@ -51,13 +51,13 @@ export class RiskInsightsEncryptionService {
throw new Error("Encryption failed, encrypted strings are null");
}
const encryptedData = dataEncrypted.encryptedString;
const encryptionKey = wrappedEncryptionKey.encryptedString;
const encryptedData = dataEncrypted;
const contentEncryptionKeyString = wrappedEncryptionKey;
const encryptedDataPacket = {
organizationId: organizationId,
encryptedData: encryptedData,
encryptionKey: encryptionKey,
const encryptedDataPacket: EncryptedDataWithKey = {
organizationId,
encryptedData,
contentEncryptionKey: contentEncryptionKeyString,
};
return encryptedDataPacket;

View File

@@ -1,13 +1,24 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
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";
import { GetRiskInsightsReportResponse } from "../models/api-models.types";
import { createNewSummaryData } from "../helpers";
import {
GetRiskInsightsReportResponse,
SaveRiskInsightsReportResponse,
} from "../models/api-models.types";
import { EncryptedDataWithKey } from "../models/password-health";
import {
ApplicationHealthReportDetail,
OrganizationReportSummary,
RiskInsightsReportData,
} from "../models/report-models";
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
import { mockCiphers } from "./ciphers.mock";
@@ -31,10 +42,20 @@ describe("RiskInsightsReportService", () => {
decryptRiskInsightsReport: jest.fn().mockResolvedValue("decryptedReportData"),
});
// Mock data
const mockOrgId = "orgId" as OrganizationId;
// Non changing mock data
const mockOrganizationId = "orgId" as OrganizationId;
const mockUserId = "userId" as UserId;
const ENCRYPTED_TEXT = "This data has been encrypted";
const ENCRYPTED_KEY = "Re-encrypted Cipher Key";
const mockEncryptedText = new EncString(ENCRYPTED_TEXT);
const mockEncryptedKey = new EncString(ENCRYPTED_KEY);
// Changing mock data
let mockCipherViews: CipherView[];
let mockMemberDetails: MemberCipherDetailsResponse[];
let mockReport: ApplicationHealthReportDetail[];
let mockSummary: OrganizationReportSummary;
let mockEncryptedReport: EncryptedDataWithKey;
beforeEach(() => {
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers);
@@ -115,6 +136,27 @@ describe("RiskInsightsReportService", () => {
email: "user3@other.com",
}),
];
mockReport = [
{
applicationName: "app1",
passwordCount: 0,
atRiskPasswordCount: 0,
atRiskCipherIds: [],
memberCount: 0,
atRiskMemberCount: 0,
memberDetails: [],
atRiskMemberDetails: [],
cipherIds: [],
},
];
mockSummary = createNewSummaryData();
mockEncryptedReport = {
organizationId: mockOrganizationId,
encryptedData: mockEncryptedText,
contentEncryptionKey: mockEncryptedKey,
};
});
it("should group and aggregate application health reports correctly", (done) => {
@@ -137,7 +179,7 @@ describe("RiskInsightsReportService", () => {
});
it("should generate the raw data report correctly", async () => {
const result = await firstValueFrom(service.LEGACY_generateRawDataReport$(mockOrgId));
const result = await firstValueFrom(service.LEGACY_generateRawDataReport$(mockOrganizationId));
expect(result).toHaveLength(6);
@@ -163,7 +205,7 @@ describe("RiskInsightsReportService", () => {
});
it("should generate the raw data + uri report correctly", async () => {
const result = await firstValueFrom(service.generateRawDataUriReport$(mockOrgId));
const result = await firstValueFrom(service.generateRawDataUriReport$(mockOrganizationId));
expect(result).toHaveLength(11);
@@ -186,7 +228,9 @@ describe("RiskInsightsReportService", () => {
});
it("should generate applications health report data correctly", async () => {
const result = await firstValueFrom(service.LEGACY_generateApplicationsReport$(mockOrgId));
const result = await firstValueFrom(
service.LEGACY_generateApplicationsReport$(mockOrganizationId),
);
expect(result).toHaveLength(8);
@@ -228,7 +272,7 @@ describe("RiskInsightsReportService", () => {
it("should generate applications summary data correctly", async () => {
const reportResult = await firstValueFrom(
service.LEGACY_generateApplicationsReport$(mockOrgId),
service.LEGACY_generateApplicationsReport$(mockOrganizationId),
);
const reportSummary = service.generateApplicationsSummary(reportResult);
@@ -238,56 +282,81 @@ describe("RiskInsightsReportService", () => {
expect(reportSummary.totalAtRiskApplicationCount).toEqual(7);
});
describe("saveRiskInsightsReport", () => {
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 encryptedReport = {
organizationId: organizationId as OrganizationId,
encryptedData: "encryptedData" as EncryptedString,
encryptionKey: "encryptionKey" as EncryptedString,
};
describe("saveRiskInsightsReport$", () => {
it("should not update subjects if save response does not have id", (done) => {
mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue(
encryptedReport,
mockEncryptedReport,
);
const saveResponse = { id: "" }; // Simulating no ID in response
const saveResponse = new SaveRiskInsightsReportResponse({ 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);
expect(reportSubjectSpy).not.toHaveBeenCalled();
expect(summarySubjectSpy).not.toHaveBeenCalled();
service
.saveRiskInsightsReport$(mockReport, mockSummary, {
organizationId: mockOrganizationId,
userId: mockUserId,
})
.subscribe({
next: (response) => {
done.fail("Expected error due to invalid response");
},
error: (error: unknown) => {
if (error instanceof ErrorResponse && error.statusCode) {
expect(error.message).toBe("Invalid response from API");
}
done();
},
});
});
describe("getRiskInsightsReport", () => {
it("should encrypt and save report, then update subjects", async () => {});
});
describe("getRiskInsightsReport$", () => {
beforeEach(() => {
// Reset the mocks before each test
jest.clearAllMocks();
});
it("should call riskInsightsApiService.getRiskInsightsReport with the correct organizationId", () => {
it("should call with the correct organizationId", async () => {
// 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",
contentEncryptionKey: "encryptionKey",
organizationId: mockOrganizationId,
reportData: mockEncryptedReport.encryptedData,
contentEncryptionKey: mockEncryptedReport.contentEncryptionKey,
} as GetRiskInsightsReportResponse;
const decryptedResponse: RiskInsightsReportData = {
data: [],
summary: {
totalMemberCount: 1,
totalAtRiskMemberCount: 1,
totalApplicationCount: 1,
totalAtRiskApplicationCount: 1,
totalCriticalMemberCount: 1,
totalCriticalAtRiskMemberCount: 1,
totalCriticalApplicationCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: [],
},
};
const organizationId = "orgId" as OrganizationId;
const userId = "userId" as UserId;
// Mock api returned encrypted data
mockRiskInsightsApiService.getRiskInsightsReport$.mockReturnValue(of(apiResponse));
service.getRiskInsightsReport(organizationId, userId);
// Mock decrypted data
mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockReturnValue(
Promise.resolve(decryptedResponse),
);
await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId));
expect(mockRiskInsightsApiService.getRiskInsightsReport$).toHaveBeenCalledWith(
organizationId,
);
@@ -310,8 +379,8 @@ describe("RiskInsightsReportService", () => {
id: "reportId",
date: new Date().toISOString(),
organizationId: organizationId as OrganizationId,
reportData: "encryptedReportData",
contentEncryptionKey: "encryptionKey",
reportData: mockEncryptedReport.encryptedData,
contentEncryptionKey: mockEncryptedReport.contentEncryptionKey,
} as GetRiskInsightsReportResponse;
const decryptedReport = {
@@ -322,12 +391,7 @@ describe("RiskInsightsReportService", () => {
decryptedReport,
);
const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next");
service.getRiskInsightsReport(organizationId, userId);
// Wait for all microtasks to complete
await Promise.resolve();
const result = await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId));
expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith(
organizationId,
@@ -336,7 +400,7 @@ describe("RiskInsightsReportService", () => {
expect.anything(),
expect.any(Function),
);
expect(reportSubjectSpy).toHaveBeenCalledWith(decryptedReport.data);
expect(result).toEqual(decryptedReport);
});
});
});

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe
// @ts-strict-ignore
import {
BehaviorSubject,
concatMap,
@@ -11,15 +9,17 @@ import {
Observable,
of,
switchMap,
throwError,
zip,
} from "rxjs";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
createNewReportData,
createNewSummaryData,
flattenMemberDetails,
getApplicationReportDetail,
getFlattenedCipherDetails,
@@ -27,6 +27,10 @@ import {
getTrimmedCipherUris,
getUniqueMembers,
} from "../helpers/risk-insights-data-mappers";
import {
isSaveRiskInsightsReportResponse,
SaveRiskInsightsReportResponse,
} from "../models/api-models.types";
import {
LEGACY_CipherHealthReportDetail,
LEGACY_CipherHealthReportUriDetail,
@@ -53,17 +57,9 @@ export class RiskInsightsReportService {
private riskInsightsReportSubject = new BehaviorSubject<ApplicationHealthReportDetail[]>([]);
riskInsightsReport$ = this.riskInsightsReportSubject.asObservable();
private riskInsightsSummarySubject = new BehaviorSubject<OrganizationReportSummary>({
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
totalCriticalMemberCount: 0,
totalCriticalAtRiskMemberCount: 0,
totalCriticalApplicationCount: 0,
totalCriticalAtRiskApplicationCount: 0,
newApplications: [],
});
private riskInsightsSummarySubject = new BehaviorSubject<OrganizationReportSummary>(
createNewSummaryData(),
);
riskInsightsSummary$ = this.riskInsightsSummarySubject.asObservable();
// [FIXME] CipherData
@@ -189,11 +185,8 @@ export class RiskInsightsReportService {
cipherHealthReportDetails.forEach((app) => {
app.atRiskMemberDetails.forEach((member) => {
if (memberRiskMap.has(member.email)) {
memberRiskMap.set(member.email, memberRiskMap.get(member.email) + 1);
} else {
memberRiskMap.set(member.email, 1);
}
const currentCount = memberRiskMap.get(member.email) ?? 0;
memberRiskMap.set(member.email, currentCount + 1);
});
});
@@ -206,25 +199,24 @@ export class RiskInsightsReportService {
generateAtRiskApplicationList(
cipherHealthReportDetails: ApplicationHealthReportDetail[],
): AtRiskApplicationDetail[] {
const appsRiskMap = new Map<string, number>();
const applicationPasswordRiskMap = new Map<string, number>();
cipherHealthReportDetails
.filter((app) => app.atRiskPasswordCount > 0)
.forEach((app) => {
if (appsRiskMap.has(app.applicationName)) {
appsRiskMap.set(
const atRiskPasswordCount = applicationPasswordRiskMap.get(app.applicationName) ?? 0;
applicationPasswordRiskMap.set(
app.applicationName,
appsRiskMap.get(app.applicationName) + app.atRiskPasswordCount,
atRiskPasswordCount + app.atRiskPasswordCount,
);
} else {
appsRiskMap.set(app.applicationName, app.atRiskPasswordCount);
}
});
return Array.from(appsRiskMap.entries()).map(([applicationName, atRiskPasswordCount]) => ({
return Array.from(applicationPasswordRiskMap.entries()).map(
([applicationName, atRiskPasswordCount]) => ({
applicationName,
atRiskPasswordCount,
}));
}),
);
}
/**
@@ -270,78 +262,88 @@ export class RiskInsightsReportService {
return dataWithCiphers;
}
getRiskInsightsReport(organizationId: OrganizationId, userId: UserId): void {
this.riskInsightsApiService
.getRiskInsightsReport$(organizationId)
.pipe(
switchMap((response) => {
/**
* Gets the risk insights report for a specific organization and user.
*
* @param organizationId
* @param userId
* @returns An observable that emits the decrypted risk insights report data.
*/
getRiskInsightsReport$(
organizationId: OrganizationId,
userId: UserId,
): Observable<RiskInsightsReportData> {
return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe(
switchMap((response): Observable<RiskInsightsReportData> => {
if (!response) {
// Return an empty report and summary if response is falsy
return of<RiskInsightsReportData>({
data: [],
summary: {
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
totalCriticalMemberCount: 0,
totalCriticalAtRiskMemberCount: 0,
totalCriticalApplicationCount: 0,
totalCriticalAtRiskApplicationCount: 0,
newApplications: [],
},
});
return of<RiskInsightsReportData>(createNewReportData());
}
if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") {
return throwError(() => new Error("Report key not found"));
}
if (!response.reportData) {
return throwError(() => new Error("Report data not found"));
}
return from(
this.riskInsightsEncryptionService.decryptRiskInsightsReport<RiskInsightsReportData>(
organizationId,
userId,
new EncString(response.reportData),
new EncString(response.contentEncryptionKey),
response.reportData,
response.contentEncryptionKey,
(data) => data as RiskInsightsReportData,
),
);
).pipe(map((decryptedReport) => decryptedReport ?? createNewReportData()));
}),
)
.subscribe({
next: (decryptRiskInsightsReport) => {
this.riskInsightsReportSubject.next(decryptRiskInsightsReport.data);
this.riskInsightsSummarySubject.next(decryptRiskInsightsReport.summary);
},
});
);
}
async saveRiskInsightsReport(
organizationId: OrganizationId,
userId: UserId,
/**
* Encrypts the risk insights report data for a specific organization.
* @param organizationId The ID of the organization.
* @param userId The ID of the user.
* @param report The report data to encrypt.
* @returns A promise that resolves to an object containing the encrypted data and encryption key.
*/
saveRiskInsightsReport$(
report: ApplicationHealthReportDetail[],
): Promise<void> {
const riskReport = {
data: report,
};
const encryptedReport = await this.riskInsightsEncryptionService.encryptRiskInsightsReport(
organizationId,
userId,
riskReport,
);
const saveRequest = {
data: {
organizationId: organizationId,
date: new Date().toISOString(),
reportData: encryptedReport.encryptedData,
reportKey: encryptedReport.encryptionKey,
summary: OrganizationReportSummary,
encryptionParameters: {
organizationId: OrganizationId;
userId: UserId;
},
};
const response = await firstValueFrom(
this.riskInsightsApiService.saveRiskInsightsReport$(saveRequest, organizationId),
);
if (response && response.id) {
this.riskInsightsReportSubject.next(report);
): Observable<SaveRiskInsightsReportResponse> {
return from(
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
encryptionParameters.organizationId,
encryptionParameters.userId,
{
data: report,
summary: summary,
},
),
).pipe(
map(({ encryptedData, contentEncryptionKey }) => ({
data: {
organizationId: encryptionParameters.organizationId,
date: new Date().toISOString(),
reportData: encryptedData.toSdk(),
contentEncryptionKey: contentEncryptionKey.toSdk(),
},
})),
switchMap((encryptedReport) =>
this.riskInsightsApiService.saveRiskInsightsReport$(
encryptedReport,
encryptionParameters.organizationId,
),
),
map((response) => {
if (!isSaveRiskInsightsReportResponse(response)) {
throw new Error("Invalid response from API");
}
return response;
}),
);
}
/**
@@ -374,7 +376,7 @@ export class RiskInsightsReportService {
passwordUseMap.set(cipher.login.password, 1);
}
const exposedPassword = exposedDetails.find((x) => x.cipherId === cipher.id);
const exposedPassword = exposedDetails.find((x) => x?.cipherId === cipher.id);
// Get the cipher members
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
@@ -599,7 +601,7 @@ export class RiskInsightsReportService {
return this.passwordHealthService.auditPasswordLeaks$(validCiphers).pipe(
map((exposedDetails) => {
return validCiphers.map((cipher) => {
const exposedPassword = exposedDetails.find((x) => x.cipherId === cipher.id);
const exposedPassword = exposedDetails.find((x) => x?.cipherId === cipher.id);
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
const result = {

View File

@@ -10,6 +10,7 @@ import {
RiskInsightsDataService,
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
import {
LEGACY_ApplicationHealthReportDetailWithCriticalFlag,
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
@@ -66,17 +67,7 @@ export class AllApplicationsComponent implements OnInit {
protected organization = new Organization();
noItemsIcon = Security;
protected markingAsCritical = false;
protected applicationSummary: OrganizationReportSummary = {
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
totalCriticalMemberCount: 0,
totalCriticalAtRiskMemberCount: 0,
totalCriticalApplicationCount: 0,
totalCriticalAtRiskApplicationCount: 0,
newApplications: [],
};
protected applicationSummary: OrganizationReportSummary = createNewSummaryData();
destroyRef = inject(DestroyRef);
isLoading$: Observable<boolean> = of(false);

View File

@@ -146,6 +146,9 @@ export class RiskInsightsComponent implements OnInit {
this._isDrawerOpen = details.open;
});
}
runReport = () => {
this.dataService.triggerReport();
};
/**
* Refreshes the data by re-fetching the applications report.