mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 22:03:36 +00:00
[PM-20578] [PM-20579] Merge existing feature branch into main (#16364)
* PM-20578 Added api to fetch and save data (#15334) * [PM-20579] Update risk-insights report service to use api service with encryption (#15357) * Fix type error * Fix paths for changed key generation service * Finalize the api services * Fixing test case for summary date range * Fixing report service tests. Encryption will be modified in the future * Fixing encryption service tests * fixing linting issues --------- Co-authored-by: Vijay Oommen <voommen@livefront.com> Co-authored-by: Tom <ttalty@bitwarden.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
|
|
||||||
import { Opaque } from "type-fest";
|
import { Opaque } from "type-fest";
|
||||||
|
|
||||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { BadgeVariant } from "@bitwarden/components";
|
import { BadgeVariant } from "@bitwarden/components";
|
||||||
|
import { EncString } from "@bitwarden/sdk-internal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All applications report summary. The total members,
|
* All applications report summary. The total members,
|
||||||
@@ -132,6 +132,16 @@ export type AppAtRiskMembersDialogParams = {
|
|||||||
applicationName: string;
|
applicationName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* After data is encrypted, it is returned with the
|
||||||
|
* encryption key used to encrypt the data.
|
||||||
|
*/
|
||||||
|
export interface EncryptedDataWithKey {
|
||||||
|
organizationId: OrganizationId;
|
||||||
|
encryptedData: EncString;
|
||||||
|
encryptionKey: EncString;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request to drop a password health report application
|
* Request to drop a password health report application
|
||||||
* Model is expected by the API endpoint
|
* Model is expected by the API endpoint
|
||||||
@@ -174,4 +184,32 @@ export enum DrawerType {
|
|||||||
OrgAtRiskApps = 3,
|
OrgAtRiskApps = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RiskInsightsReport {
|
||||||
|
organizationId: OrganizationId;
|
||||||
|
date: string;
|
||||||
|
reportData: string;
|
||||||
|
reportKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportInsightsReportData {
|
||||||
|
data: ApplicationHealthReportDetail[];
|
||||||
|
summary: ApplicationHealthReportSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveRiskInsightsReportRequest {
|
||||||
|
data: RiskInsightsReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveRiskInsightsReportResponse {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetRiskInsightsReportResponse {
|
||||||
|
id: string;
|
||||||
|
organizationId: OrganizationId;
|
||||||
|
date: string;
|
||||||
|
reportData: EncString;
|
||||||
|
reportKey: EncString;
|
||||||
|
}
|
||||||
|
|
||||||
export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">;
|
export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
|
import { makeEncString } from "@bitwarden/common/spec";
|
||||||
|
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import { EncryptedDataModel } from "../models/password-health";
|
import { EncryptedDataModel, SaveRiskInsightsReportRequest } from "../models/password-health";
|
||||||
|
|
||||||
import { RiskInsightsApiService } from "./risk-insights-api.service";
|
import { RiskInsightsApiService } from "./risk-insights-api.service";
|
||||||
|
|
||||||
@@ -10,6 +12,30 @@ describe("RiskInsightsApiService", () => {
|
|||||||
let service: RiskInsightsApiService;
|
let service: RiskInsightsApiService;
|
||||||
const mockApiService = mock<ApiService>();
|
const mockApiService = mock<ApiService>();
|
||||||
|
|
||||||
|
const orgId = "org1" as OrganizationId;
|
||||||
|
|
||||||
|
const getRiskInsightsReportResponse = {
|
||||||
|
organizationId: orgId,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
reportData: "test",
|
||||||
|
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: reportData,
|
||||||
|
reportKey: reportKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const saveRiskInsightsReportResponse = {
|
||||||
|
...saveRiskInsightsReportRequest.data,
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new RiskInsightsApiService(mockApiService);
|
service = new RiskInsightsApiService(mockApiService);
|
||||||
});
|
});
|
||||||
@@ -18,66 +44,157 @@ describe("RiskInsightsApiService", () => {
|
|||||||
expect(service).toBeTruthy();
|
expect(service).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getRiskInsightsSummary", () => {
|
it("should call apiService.send with correct parameters and return the response for getRiskInsightsReport ", (done) => {
|
||||||
it("should call apiService.send with correct parameters and return an Observable", (done) => {
|
mockApiService.send.mockReturnValue(Promise.resolve(getRiskInsightsReportResponse));
|
||||||
const orgId = "org123";
|
|
||||||
const minDate = new Date("2024-01-01");
|
|
||||||
const maxDate = new Date("2024-01-31");
|
|
||||||
const mockResponse: EncryptedDataModel[] = [{ encryptedData: "abc" } as EncryptedDataModel];
|
|
||||||
|
|
||||||
mockApiService.send.mockResolvedValueOnce(mockResponse);
|
service.getRiskInsightsReport(orgId).subscribe((result) => {
|
||||||
|
expect(result).toEqual(getRiskInsightsReportResponse);
|
||||||
|
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||||
|
"GET",
|
||||||
|
`/reports/organizations/${orgId.toString()}/latest`,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
service.getRiskInsightsSummary(orgId, minDate, maxDate).subscribe((result) => {
|
it("should return null if apiService.send rejects with 404 error for getRiskInsightsReport", (done) => {
|
||||||
|
const error = { statusCode: 404 };
|
||||||
|
mockApiService.send.mockReturnValue(Promise.reject(error));
|
||||||
|
|
||||||
|
service.getRiskInsightsReport(orgId).subscribe((result) => {
|
||||||
|
expect(result).toBeNull();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if apiService.send rejects with non-404 error for getRiskInsightsReport", (done) => {
|
||||||
|
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: () => {
|
||||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||||
"GET",
|
"GET",
|
||||||
`organization-report-summary/org123?from=2024-01-01&to=2024-01-31`,
|
`/reports/organizations/${orgId.toString()}/latest`,
|
||||||
null,
|
null,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(result).toEqual(mockResponse);
|
|
||||||
done();
|
done();
|
||||||
});
|
},
|
||||||
|
complete: () => {
|
||||||
|
done();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("saveRiskInsightsSummary", () => {
|
it("should call apiService.send with correct parameters for saveRiskInsightsReport", (done) => {
|
||||||
it("should call apiService.send with correct parameters and return an Observable", (done) => {
|
mockApiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportResponse));
|
||||||
const data: EncryptedDataModel = { encryptedData: "xyz" } as EncryptedDataModel;
|
|
||||||
|
|
||||||
mockApiService.send.mockResolvedValueOnce(undefined);
|
service.saveRiskInsightsReport(saveRiskInsightsReportRequest, orgId).subscribe((result) => {
|
||||||
|
expect(result).toEqual(saveRiskInsightsReportResponse);
|
||||||
|
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||||
|
"POST",
|
||||||
|
`/reports/organizations/${orgId.toString()}`,
|
||||||
|
saveRiskInsightsReportRequest.data,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
service.saveRiskInsightsSummary(data).subscribe((result) => {
|
it("should propagate errors from apiService.send for saveRiskInsightsReport - 1", (done) => {
|
||||||
|
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: () => {
|
||||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||||
"POST",
|
"POST",
|
||||||
"organization-report-summary",
|
`/reports/organizations/${orgId.toString()}`,
|
||||||
data,
|
saveRiskInsightsReportRequest.data,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(result).toBeUndefined();
|
|
||||||
done();
|
done();
|
||||||
});
|
},
|
||||||
|
complete: () => {
|
||||||
|
done();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("updateRiskInsightsSummary", () => {
|
it("should propagate network errors from apiService.send for saveRiskInsightsReport - 2", (done) => {
|
||||||
it("should call apiService.send with correct parameters and return an Observable", (done) => {
|
const error = new Error("Network error");
|
||||||
const data: EncryptedDataModel = { encryptedData: "xyz" } as EncryptedDataModel;
|
mockApiService.send.mockReturnValue(Promise.reject(error));
|
||||||
|
|
||||||
mockApiService.send.mockResolvedValueOnce(undefined);
|
service.saveRiskInsightsReport(saveRiskInsightsReportRequest, orgId).subscribe({
|
||||||
|
next: () => {
|
||||||
service.updateRiskInsightsSummary(data).subscribe((result) => {
|
fail("Expected error to be thrown");
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||||
"PUT",
|
"POST",
|
||||||
"organization-report-summary",
|
`/reports/organizations/${orgId.toString()}`,
|
||||||
data,
|
saveRiskInsightsReportRequest.data,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(result).toBeUndefined();
|
|
||||||
done();
|
done();
|
||||||
});
|
},
|
||||||
|
complete: () => {
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call apiService.send with correct parameters and return an Observable", (done) => {
|
||||||
|
const minDate = new Date("2024-01-01");
|
||||||
|
const maxDate = new Date("2024-01-31");
|
||||||
|
const mockResponse: EncryptedDataModel[] = [{ encryptedData: "abc" } as EncryptedDataModel];
|
||||||
|
|
||||||
|
mockApiService.send.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
service.getRiskInsightsSummary(orgId, minDate, maxDate).subscribe((result) => {
|
||||||
|
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||||
|
"GET",
|
||||||
|
`/reports/organizations/${orgId.toString()}/data/summary?startDate=${minDate.toISOString().split("T")[0]}&endDate=${maxDate.toISOString().split("T")[0]}`,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call apiService.send with correct parameters and return an Observable", (done) => {
|
||||||
|
const data: EncryptedDataModel = { encryptedData: "xyz" } as EncryptedDataModel;
|
||||||
|
const reportId = "report123" as OrganizationReportId;
|
||||||
|
|
||||||
|
mockApiService.send.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
service.updateRiskInsightsSummary(data, orgId, reportId).subscribe((result) => {
|
||||||
|
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||||
|
"PATCH",
|
||||||
|
`/reports/organizations/${orgId.toString()}/data/summary/${reportId.toString()}`,
|
||||||
|
data,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,46 @@
|
|||||||
import { from, Observable } from "rxjs";
|
import { from, Observable } from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
|
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import { EncryptedDataModel } from "../models/password-health";
|
import {
|
||||||
|
EncryptedDataModel,
|
||||||
|
GetRiskInsightsReportResponse,
|
||||||
|
SaveRiskInsightsReportRequest,
|
||||||
|
SaveRiskInsightsReportResponse,
|
||||||
|
} from "../models/password-health";
|
||||||
|
|
||||||
export class RiskInsightsApiService {
|
export class RiskInsightsApiService {
|
||||||
constructor(private apiService: ApiService) {}
|
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
|
||||||
|
}
|
||||||
|
throw error; // Re-throw other errors
|
||||||
|
});
|
||||||
|
|
||||||
|
return from(dbResponse as Promise<GetRiskInsightsReportResponse>);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRiskInsightsReport(
|
||||||
|
request: SaveRiskInsightsReportRequest,
|
||||||
|
organizationId: OrganizationId,
|
||||||
|
): Observable<SaveRiskInsightsReportResponse> {
|
||||||
|
const dbResponse = this.apiService.send(
|
||||||
|
"POST",
|
||||||
|
`/reports/organizations/${organizationId.toString()}`,
|
||||||
|
request.data,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return from(dbResponse as Promise<SaveRiskInsightsReportResponse>);
|
||||||
|
}
|
||||||
|
|
||||||
getRiskInsightsSummary(
|
getRiskInsightsSummary(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
minDate: Date,
|
minDate: Date,
|
||||||
@@ -16,7 +50,7 @@ export class RiskInsightsApiService {
|
|||||||
const maxDateStr = maxDate.toISOString().split("T")[0];
|
const maxDateStr = maxDate.toISOString().split("T")[0];
|
||||||
const dbResponse = this.apiService.send(
|
const dbResponse = this.apiService.send(
|
||||||
"GET",
|
"GET",
|
||||||
`organization-report-summary/${orgId.toString()}?from=${minDateStr}&to=${maxDateStr}`,
|
`/reports/organizations/${orgId.toString()}/data/summary?startDate=${minDateStr}&endDate=${maxDateStr}`,
|
||||||
null,
|
null,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
@@ -25,10 +59,14 @@ export class RiskInsightsApiService {
|
|||||||
return from(dbResponse as Promise<EncryptedDataModel[]>);
|
return from(dbResponse as Promise<EncryptedDataModel[]>);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveRiskInsightsSummary(data: EncryptedDataModel): Observable<void> {
|
updateRiskInsightsSummary(
|
||||||
|
data: EncryptedDataModel,
|
||||||
|
organizationId: OrganizationId,
|
||||||
|
reportId: OrganizationReportId,
|
||||||
|
): Observable<void> {
|
||||||
const dbResponse = this.apiService.send(
|
const dbResponse = this.apiService.send(
|
||||||
"POST",
|
"PATCH",
|
||||||
"organization-report-summary",
|
`/reports/organizations/${organizationId.toString()}/data/summary/${reportId.toString()}`,
|
||||||
data,
|
data,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
@@ -36,10 +74,4 @@ export class RiskInsightsApiService {
|
|||||||
|
|
||||||
return from(dbResponse as Promise<void>);
|
return from(dbResponse as Promise<void>);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRiskInsightsSummary(data: EncryptedDataModel): Observable<void> {
|
|
||||||
const dbResponse = this.apiService.send("PUT", "organization-report-summary", data, true, true);
|
|
||||||
|
|
||||||
return from(dbResponse as Promise<void>);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
import { finalize } from "rxjs/operators";
|
import { finalize } from "rxjs/operators";
|
||||||
|
|
||||||
|
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AppAtRiskMembersDialogParams,
|
AppAtRiskMembersDialogParams,
|
||||||
ApplicationHealthReportDetail,
|
ApplicationHealthReportDetail,
|
||||||
@@ -40,7 +42,7 @@ export class RiskInsightsDataService {
|
|||||||
* Fetches the applications report and updates the applicationsSubject.
|
* Fetches the applications report and updates the applicationsSubject.
|
||||||
* @param organizationId The ID of the organization.
|
* @param organizationId The ID of the organization.
|
||||||
*/
|
*/
|
||||||
fetchApplicationsReport(organizationId: string, isRefresh?: boolean): void {
|
fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void {
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
this.isRefreshingSubject.next(true);
|
this.isRefreshingSubject.next(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -66,7 +68,7 @@ export class RiskInsightsDataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshApplicationsReport(organizationId: string): void {
|
refreshApplicationsReport(organizationId: OrganizationId): void {
|
||||||
this.fetchApplicationsReport(organizationId, true);
|
this.fetchApplicationsReport(organizationId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
|
||||||
|
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||||
|
import { EncString } from "@bitwarden/common/key-management/crypto/models/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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { firstValueFrom, map } from "rxjs";
|
||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
|
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
|
||||||
|
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||||
|
import { EncString } from "@bitwarden/common/key-management/crypto/models/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom, of } from "rxjs";
|
||||||
import { ZXCVBNResult } from "zxcvbn";
|
import { ZXCVBNResult } from "zxcvbn";
|
||||||
|
|
||||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
|
import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
|
|
||||||
|
import { GetRiskInsightsReportResponse } from "../models/password-health";
|
||||||
|
|
||||||
import { mockCiphers } from "./ciphers.mock";
|
import { mockCiphers } from "./ciphers.mock";
|
||||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
||||||
import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec";
|
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";
|
import { RiskInsightsReportService } from "./risk-insights-report.service";
|
||||||
|
|
||||||
describe("RiskInsightsReportService", () => {
|
describe("RiskInsightsReportService", () => {
|
||||||
@@ -17,6 +23,12 @@ describe("RiskInsightsReportService", () => {
|
|||||||
const auditService = mock<AuditService>();
|
const auditService = mock<AuditService>();
|
||||||
const cipherService = mock<CipherService>();
|
const cipherService = mock<CipherService>();
|
||||||
const memberCipherDetailsService = mock<MemberCipherDetailsApiService>();
|
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(() => {
|
beforeEach(() => {
|
||||||
pwdStrengthService.getPasswordStrength.mockImplementation((password: string) => {
|
pwdStrengthService.getPasswordStrength.mockImplementation((password: string) => {
|
||||||
@@ -37,11 +49,13 @@ describe("RiskInsightsReportService", () => {
|
|||||||
auditService,
|
auditService,
|
||||||
cipherService,
|
cipherService,
|
||||||
memberCipherDetailsService,
|
memberCipherDetailsService,
|
||||||
|
mockRiskInsightsApiService,
|
||||||
|
mockRiskInsightsEncryptionService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should generate the raw data report correctly", async () => {
|
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);
|
expect(result).toHaveLength(6);
|
||||||
|
|
||||||
@@ -67,7 +81,7 @@ describe("RiskInsightsReportService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should generate the raw data + uri report correctly", async () => {
|
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);
|
expect(result).toHaveLength(11);
|
||||||
|
|
||||||
@@ -90,7 +104,7 @@ describe("RiskInsightsReportService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should generate applications health report data correctly", async () => {
|
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);
|
expect(result).toHaveLength(8);
|
||||||
|
|
||||||
@@ -131,7 +145,7 @@ describe("RiskInsightsReportService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should generate applications summary data correctly", async () => {
|
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);
|
const reportSummary = service.generateApplicationsSummary(reportResult);
|
||||||
|
|
||||||
expect(reportSummary.totalMemberCount).toEqual(7);
|
expect(reportSummary.totalMemberCount).toEqual(7);
|
||||||
@@ -139,4 +153,104 @@ describe("RiskInsightsReportService", () => {
|
|||||||
expect(reportSummary.totalApplicationCount).toEqual(8);
|
expect(reportSummary.totalApplicationCount).toEqual(8);
|
||||||
expect(reportSummary.totalAtRiskApplicationCount).toEqual(7);
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 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" }],
|
||||||
|
};
|
||||||
|
mockRiskInsightsApiService.getRiskInsightsReport.mockReturnValue(of(mockResponse));
|
||||||
|
mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockResolvedValue(
|
||||||
|
decryptedReport,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
// FIXME: Update this file to be type safe
|
// FIXME: Update this file to be type safe
|
||||||
// @ts-strict-ignore
|
// @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 { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
|
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
@@ -21,9 +34,12 @@ import {
|
|||||||
WeakPasswordDetail,
|
WeakPasswordDetail,
|
||||||
WeakPasswordScore,
|
WeakPasswordScore,
|
||||||
ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||||
|
ReportInsightsReportData,
|
||||||
} from "../models/password-health";
|
} from "../models/password-health";
|
||||||
|
|
||||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
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 {
|
export class RiskInsightsReportService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -31,8 +47,21 @@ export class RiskInsightsReportService {
|
|||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
private cipherService: CipherService,
|
private cipherService: CipherService,
|
||||||
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
|
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.
|
* Report data from raw cipher health data.
|
||||||
* Can be used in the Raw Data diagnostic tab (just exclude the members in the view)
|
* 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
|
* @param organizationId
|
||||||
* @returns Cipher health report data with members and trimmed uris
|
* @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 allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId));
|
||||||
const memberCiphers$ = from(
|
const memberCiphers$ = from(
|
||||||
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
|
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
|
||||||
@@ -68,7 +97,9 @@ export class RiskInsightsReportService {
|
|||||||
* @param organizationId Id of the organization
|
* @param organizationId Id of the organization
|
||||||
* @returns Cipher health report data flattened to the uris
|
* @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 cipherHealthDetails$ = this.generateRawDataReport$(organizationId);
|
||||||
const results$ = cipherHealthDetails$.pipe(
|
const results$ = cipherHealthDetails$.pipe(
|
||||||
map((healthDetails) => this.getCipherUriDetails(healthDetails)),
|
map((healthDetails) => this.getCipherUriDetails(healthDetails)),
|
||||||
@@ -84,7 +115,9 @@ export class RiskInsightsReportService {
|
|||||||
* @param organizationId Id of the organization
|
* @param organizationId Id of the organization
|
||||||
* @returns The all applications health report data
|
* @returns The all applications health report data
|
||||||
*/
|
*/
|
||||||
generateApplicationsReport$(organizationId: string): Observable<ApplicationHealthReportDetail[]> {
|
generateApplicationsReport$(
|
||||||
|
organizationId: OrganizationId,
|
||||||
|
): Observable<ApplicationHealthReportDetail[]> {
|
||||||
const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId);
|
const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId);
|
||||||
const results$ = cipherHealthUriReport$.pipe(
|
const results$ = cipherHealthUriReport$.pipe(
|
||||||
map((uriDetails) => this.getApplicationHealthReport(uriDetails)),
|
map((uriDetails) => this.getApplicationHealthReport(uriDetails)),
|
||||||
@@ -167,7 +200,7 @@ export class RiskInsightsReportService {
|
|||||||
|
|
||||||
async identifyCiphers(
|
async identifyCiphers(
|
||||||
data: ApplicationHealthReportDetail[],
|
data: ApplicationHealthReportDetail[],
|
||||||
organizationId: string,
|
organizationId: OrganizationId,
|
||||||
): Promise<ApplicationHealthReportDetailWithCriticalFlagAndCipher[]> {
|
): Promise<ApplicationHealthReportDetailWithCriticalFlagAndCipher[]> {
|
||||||
const cipherViews = await this.cipherService.getAllFromApiForOrganization(organizationId);
|
const cipherViews = await this.cipherService.getAllFromApiForOrganization(organizationId);
|
||||||
|
|
||||||
@@ -181,6 +214,75 @@ export class RiskInsightsReportService {
|
|||||||
return dataWithCiphers;
|
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[],
|
||||||
|
): 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.riskInsightsApiService.saveRiskInsightsReport(saveRequest, organizationId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response && response.id) {
|
||||||
|
this.riskInsightsReportSubject.next(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Associates the members with the ciphers they have access to. Calculates the password health.
|
* Associates the members with the ciphers they have access to. Calculates the password health.
|
||||||
* Finds the trimmed uris.
|
* Finds the trimmed uris.
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
RiskInsightsDataService,
|
RiskInsightsDataService,
|
||||||
RiskInsightsReportService,
|
RiskInsightsReportService,
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
|
} 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 { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
|
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
|
||||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction";
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
@@ -38,6 +40,11 @@ import { RiskInsightsComponent } from "./risk-insights.component";
|
|||||||
provide: RiskInsightsDataService,
|
provide: RiskInsightsDataService,
|
||||||
deps: [RiskInsightsReportService],
|
deps: [RiskInsightsReportService],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: RiskInsightsEncryptionService,
|
||||||
|
useClass: RiskInsightsEncryptionService,
|
||||||
|
deps: [KeyService, EncryptService, KeyGenerationService],
|
||||||
|
},
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: CriticalAppsService,
|
provide: CriticalAppsService,
|
||||||
useClass: CriticalAppsService,
|
useClass: CriticalAppsService,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||||
ApplicationHealthReportSummary,
|
ApplicationHealthReportSummary,
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
|
} 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 {
|
import {
|
||||||
getOrganizationById,
|
getOrganizationById,
|
||||||
OrganizationService,
|
OrganizationService,
|
||||||
@@ -108,7 +109,7 @@ export class AllApplicationsComponent implements OnInit {
|
|||||||
if (data && organization) {
|
if (data && organization) {
|
||||||
const dataWithCiphers = await this.reportService.identifyCiphers(
|
const dataWithCiphers = await this.reportService.identifyCiphers(
|
||||||
data,
|
data,
|
||||||
organization.id,
|
organization.id as OrganizationId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -145,6 +146,7 @@ export class AllApplicationsComponent implements OnInit {
|
|||||||
protected reportService: RiskInsightsReportService,
|
protected reportService: RiskInsightsReportService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
protected criticalAppsService: CriticalAppsService,
|
protected criticalAppsService: CriticalAppsService,
|
||||||
|
protected riskInsightsEncryptionService: RiskInsightsEncryptionService,
|
||||||
) {
|
) {
|
||||||
this.searchControl.valueChanges
|
this.searchControl.valueChanges
|
||||||
.pipe(debounceTime(200), takeUntilDestroyed())
|
.pipe(debounceTime(200), takeUntilDestroyed())
|
||||||
|
|||||||
@@ -56,15 +56,18 @@ export class CriticalApplicationsComponent implements OnInit {
|
|||||||
protected searchControl = new FormControl("", { nonNullable: true });
|
protected searchControl = new FormControl("", { nonNullable: true });
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
protected loading = false;
|
protected loading = false;
|
||||||
protected organizationId: string;
|
protected organizationId: OrganizationId;
|
||||||
protected applicationSummary = {} as ApplicationHealthReportSummary;
|
protected applicationSummary = {} as ApplicationHealthReportSummary;
|
||||||
noItemsIcon = Security;
|
noItemsIcon = Security;
|
||||||
enableRequestPasswordChange = false;
|
enableRequestPasswordChange = false;
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId") ?? "";
|
this.organizationId = this.activatedRoute.snapshot.paramMap.get(
|
||||||
|
"organizationId",
|
||||||
|
) as OrganizationId;
|
||||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||||
this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId, userId);
|
this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId, userId);
|
||||||
|
// this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId);
|
||||||
combineLatest([
|
combineLatest([
|
||||||
this.dataService.applications$,
|
this.dataService.applications$,
|
||||||
this.criticalAppsService.getAppsListForOrg(this.organizationId as OrganizationId),
|
this.criticalAppsService.getAppsListForOrg(this.organizationId as OrganizationId),
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export class RiskInsightsComponent implements OnInit {
|
|||||||
criticalAppsCount: number = 0;
|
criticalAppsCount: number = 0;
|
||||||
notifiedMembersCount: number = 0;
|
notifiedMembersCount: number = 0;
|
||||||
|
|
||||||
private organizationId: string | null = null;
|
private organizationId: OrganizationId = "" as OrganizationId;
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
isLoading$: Observable<boolean> = new Observable<boolean>();
|
isLoading$: Observable<boolean> = new Observable<boolean>();
|
||||||
isRefreshing$: Observable<boolean> = new Observable<boolean>();
|
isRefreshing$: Observable<boolean> = new Observable<boolean>();
|
||||||
@@ -94,10 +94,10 @@ export class RiskInsightsComponent implements OnInit {
|
|||||||
.pipe(
|
.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
map((params) => params.get("organizationId")),
|
map((params) => params.get("organizationId")),
|
||||||
switchMap((orgId: string | null) => {
|
switchMap((orgId) => {
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
this.organizationId = orgId;
|
this.organizationId = orgId as OrganizationId;
|
||||||
this.dataService.fetchApplicationsReport(orgId);
|
this.dataService.fetchApplicationsReport(this.organizationId);
|
||||||
this.isLoading$ = this.dataService.isLoading$;
|
this.isLoading$ = this.dataService.isLoading$;
|
||||||
this.isRefreshing$ = this.dataService.isRefreshing$;
|
this.isRefreshing$ = this.dataService.isRefreshing$;
|
||||||
this.dataLastUpdated$ = this.dataService.dataLastUpdated$;
|
this.dataLastUpdated$ = this.dataService.dataLastUpdated$;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type OrganizationIntegrationConfigurationId = Opaque<
|
|||||||
string,
|
string,
|
||||||
"OrganizationIntegrationConfigurationId"
|
"OrganizationIntegrationConfigurationId"
|
||||||
>;
|
>;
|
||||||
|
export type OrganizationReportId = Opaque<string, "OrganizationReportId">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A string representation of an empty guid.
|
* A string representation of an empty guid.
|
||||||
|
|||||||
Reference in New Issue
Block a user