diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 7f08d3f02d..dafd7f00a0 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -187,6 +190,9 @@ "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -4317,6 +4323,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts index 3f679924df..487ac28e96 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts @@ -9,7 +9,7 @@ import { import { ApplicationHealthReportDetail, OrganizationReportSummary, - RiskInsightsReportData, + RiskInsightsData, } from "../models/report-models"; import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; @@ -154,10 +154,12 @@ export function getApplicationReportDetail( * * @returns An empty report */ -export function createNewReportData(): RiskInsightsReportData { +export function createNewReportData(): RiskInsightsData { return { - data: [], - summary: createNewSummaryData(), + creationDate: new Date(), + reportData: [], + summaryData: createNewSummaryData(), + applicationData: [], }; } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts index 89293651a2..871db2b68a 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts @@ -37,8 +37,10 @@ export interface PasswordHealthReportApplicationsRequest { export interface SaveRiskInsightsReportRequest { data: { organizationId: OrganizationId; - date: string; + creationDate: string; reportData: string; + summaryData: string; + applicationData: string; contentEncryptionKey: string; }; } @@ -58,9 +60,10 @@ export function isSaveRiskInsightsReportResponse(obj: any): obj is SaveRiskInsig export class GetRiskInsightsReportResponse extends BaseResponse { id: string; organizationId: OrganizationId; - // TODO Update to use creationDate from server - date: string; + creationDate: Date; reportData: EncString; + summaryData: EncString; + applicationData: EncString; contentEncryptionKey: EncString; constructor(response: any) { @@ -68,8 +71,10 @@ export class GetRiskInsightsReportResponse extends BaseResponse { this.id = this.getResponseProperty("organizationId"); this.organizationId = this.getResponseProperty("organizationId"); - this.date = this.getResponseProperty("date"); + this.creationDate = new Date(this.getResponseProperty("creationDate")); this.reportData = new EncString(this.getResponseProperty("reportData")); + this.summaryData = new EncString(this.getResponseProperty("summaryData")); + this.applicationData = new EncString(this.getResponseProperty("applicationData")); this.contentEncryptionKey = new EncString(this.getResponseProperty("contentEncryptionKey")); } } @@ -77,7 +82,7 @@ export class GetRiskInsightsReportResponse extends BaseResponse { export class GetRiskInsightsSummaryResponse extends BaseResponse { id: string; organizationId: OrganizationId; - encryptedData: EncString; // Decrypted as OrganizationReportSummary + encryptedSummary: EncString; // Decrypted as OrganizationReportSummary contentEncryptionKey: EncString; constructor(response: any) { @@ -85,7 +90,7 @@ export class GetRiskInsightsSummaryResponse extends BaseResponse { // 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.encryptedSummary = this.getResponseProperty("encryptedData"); this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts index b8fcfe251f..abe1f7200d 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts @@ -1,3 +1,5 @@ export * from "./api-models.types"; export * from "./password-health"; +export * from "./report-data-service.types"; +export * from "./report-encryption.types"; export * from "./report-models"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts new file mode 100644 index 0000000000..c790fc327a --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts @@ -0,0 +1,140 @@ +import { mock } from "jest-mock-extended"; + +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; + +import { ApplicationHealthReportDetailEnriched } from "./report-data-service.types"; +import { + ApplicationHealthReportDetail, + OrganizationReportApplication, + OrganizationReportSummary, +} from "./report-models"; + +const mockApplication1: ApplicationHealthReportDetail = { + applicationName: "application1.com", + passwordCount: 2, + atRiskPasswordCount: 1, + atRiskCipherIds: ["cipher-1"], + memberCount: 2, + atRiskMemberCount: 1, + memberDetails: [ + { + userGuid: "user-id-1", + userName: "tom", + email: "tom@application1.com", + cipherId: "cipher-1", + }, + ], + atRiskMemberDetails: [ + { + userGuid: "user-id-2", + userName: "tom", + email: "tom2@application1.com", + cipherId: "cipher-2", + }, + ], + cipherIds: ["cipher-1", "cipher-2"], +}; + +const mockApplication2: ApplicationHealthReportDetail = { + applicationName: "site2.application1.com", + passwordCount: 0, + atRiskPasswordCount: 0, + atRiskCipherIds: [], + memberCount: 0, + atRiskMemberCount: 0, + memberDetails: [], + atRiskMemberDetails: [], + cipherIds: [], +}; +const mockApplication3: ApplicationHealthReportDetail = { + applicationName: "application2.com", + passwordCount: 0, + atRiskPasswordCount: 0, + atRiskCipherIds: [], + memberCount: 0, + atRiskMemberCount: 0, + memberDetails: [], + atRiskMemberDetails: [], + cipherIds: [], +}; + +export const mockReportData: ApplicationHealthReportDetail[] = [ + mockApplication1, + mockApplication2, + mockApplication3, +]; + +export const mockSummaryData: OrganizationReportSummary = { + totalMemberCount: 5, + totalAtRiskMemberCount: 2, + totalApplicationCount: 3, + totalAtRiskApplicationCount: 1, + totalCriticalMemberCount: 1, + totalCriticalAtRiskMemberCount: 1, + totalCriticalApplicationCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: [], +}; +export const mockApplicationData: OrganizationReportApplication[] = [ + { + applicationName: "application1.com", + isCritical: true, + }, + { + applicationName: "application2.com", + isCritical: false, + }, +]; + +export const mockEnrichedReportData: ApplicationHealthReportDetailEnriched[] = [ + { ...mockApplication1, isMarkedAsCritical: true, ciphers: [] }, + { ...mockApplication2, isMarkedAsCritical: false, ciphers: [] }, +]; + +export const mockCipherViews: CipherView[] = [ + mock({ + id: "cipher-1", + type: CipherType.Login, + login: { password: "pass1", username: "user1", uris: [{ uri: "https://app.com/login" }] }, + isDeleted: false, + viewPassword: true, + }), + mock({ + id: "cipher-2", + type: CipherType.Login, + login: { password: "pass2", username: "user2", uris: [{ uri: "app.com/home" }] }, + isDeleted: false, + viewPassword: true, + }), + mock({ + id: "cipher-3", + type: CipherType.Login, + login: { password: "pass3", username: "user3", uris: [{ uri: "https://other.com" }] }, + isDeleted: false, + viewPassword: true, + }), +]; + +export const mockMemberDetails = [ + mock({ + cipherIds: ["cipher-1"], + userGuid: "user1", + userName: "User 1", + email: "user1@app.com", + }), + mock({ + cipherIds: ["cipher-2"], + userGuid: "user2", + userName: "User 2", + email: "user2@app.com", + }), + mock({ + cipherIds: ["cipher-3"], + userGuid: "user3", + userName: "User 3", + email: "user3@other.com", + }), +]; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts index e026a4475b..8127ea4108 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts @@ -1,7 +1,5 @@ // 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"; @@ -33,16 +31,6 @@ export type ExposedPasswordDetail = { exposedXTimes: number; } | null; -/* - * After data is encrypted, it is returned with the - * encryption key used to encrypt the data. - */ -export interface EncryptedDataWithKey { - organizationId: OrganizationId; - encryptedData: EncString; - contentEncryptionKey: EncString; -} - export type LEGACY_MemberDetailsFlat = { userGuid: string; userName: string; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-data-service.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-data-service.types.ts new file mode 100644 index 0000000000..6196c788ec --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-data-service.types.ts @@ -0,0 +1,18 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { + ApplicationHealthReportDetail, + OrganizationReportApplication, + OrganizationReportSummary, +} from "./report-models"; + +export type ApplicationHealthReportDetailEnriched = ApplicationHealthReportDetail & { + isMarkedAsCritical: boolean; + ciphers: CipherView[]; +}; +export interface RiskInsightsEnrichedData { + reportData: ApplicationHealthReportDetailEnriched[]; + summaryData: OrganizationReportSummary; + applicationData: OrganizationReportApplication[]; + creationDate: Date; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-encryption.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-encryption.types.ts new file mode 100644 index 0000000000..d5f2726d7c --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-encryption.types.ts @@ -0,0 +1,32 @@ +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { + ApplicationHealthReportDetail, + OrganizationReportApplication, + OrganizationReportSummary, +} from "./report-models"; + +/* + * After data is encrypted, it is returned with the + * encryption key used to encrypt the data. + */ +export interface EncryptedDataWithKey { + organizationId: OrganizationId; + encryptedReportData: EncString; + encryptedSummaryData: EncString; + encryptedApplicationData: EncString; + contentEncryptionKey: EncString; +} + +export interface DecryptedReportData { + reportData: ApplicationHealthReportDetail[]; + summaryData: OrganizationReportSummary; + applicationData: OrganizationReportApplication[]; +} + +export interface EncryptedReportData { + encryptedReportData: EncString; + encryptedSummaryData: EncString; + encryptedApplicationData: EncString; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts index 1758bb41b1..564f483813 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts @@ -131,11 +131,6 @@ export type ApplicationHealthReportDetail = { cipherIds: string[]; }; -export type ApplicationHealthReportDetailEnriched = ApplicationHealthReportDetail & { - isMarkedAsCritical: boolean; - ciphers: CipherView[]; -}; - /* * A list of applications and the count of * at risk passwords for each application @@ -148,12 +143,6 @@ export type AtRiskApplicationDetail = { // -------------------- Password Health Report Models -------------------- export type PasswordHealthReportApplicationId = Opaque; -// -------------------- Risk Insights Report Models -------------------- -export interface RiskInsightsReportData { - data: ApplicationHealthReportDetailEnriched[]; - summary: OrganizationReportSummary; -} - export type ReportScore = { label: string; badgeVariant: BadgeVariant; sortOrder: number }; export type ReportResult = CipherView & { @@ -162,8 +151,9 @@ export type ReportResult = CipherView & { scoreKey: number; }; -export type ReportDetailsAndSummary = { - data: ApplicationHealthReportDetailEnriched[]; - summary: OrganizationReportSummary; - dateCreated: Date; -}; +export interface RiskInsightsData { + creationDate: Date; + reportData: ApplicationHealthReportDetail[]; + summaryData: OrganizationReportSummary; + applicationData: OrganizationReportApplication[]; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts index 3ea67d8f7c..b8992b1a05 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts @@ -1,8 +1,10 @@ import { BehaviorSubject } from "rxjs"; -import { LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher } from "../models"; +import { ApplicationHealthReportDetailEnriched } from "../models"; import { OrganizationReportSummary } from "../models/report-models"; +import { RiskInsightsDataService } from "./risk-insights-data.service"; + export class AllActivitiesService { /// This class is used to manage the summary of all applications /// and critical applications. @@ -20,12 +22,10 @@ export class AllActivitiesService { totalCriticalAtRiskApplicationCount: 0, newApplications: [], }); - reportSummary$ = this.reportSummarySubject$.asObservable(); - private allApplicationsDetailsSubject$: BehaviorSubject< - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[] - > = new BehaviorSubject([]); + private allApplicationsDetailsSubject$: BehaviorSubject = + new BehaviorSubject([]); allApplicationsDetails$ = this.allApplicationsDetailsSubject$.asObservable(); private atRiskPasswordsCountSubject$ = new BehaviorSubject(0); @@ -35,6 +35,22 @@ export class AllActivitiesService { passwordChangeProgressMetricHasProgressBar$ = this.passwordChangeProgressMetricHasProgressBarSubject$.asObservable(); + constructor(private dataService: RiskInsightsDataService) { + // All application summary changes + this.dataService.reportResults$.subscribe((report) => { + if (report) { + this.setAllAppsReportSummary(report.summaryData); + this.setAllAppsReportDetails(report.reportData); + } + }); + // Critical application summary changes + this.dataService.criticalReportResults$.subscribe((report) => { + if (report) { + this.setCriticalAppsReportSummary(report.summaryData); + } + }); + } + setCriticalAppsReportSummary(summary: OrganizationReportSummary) { this.reportSummarySubject$.next({ ...this.reportSummarySubject$.getValue(), @@ -55,9 +71,7 @@ export class AllActivitiesService { }); } - setAllAppsReportDetails( - applications: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], - ) { + setAllAppsReportDetails(applications: ApplicationHealthReportDetailEnriched[]) { const totalAtRiskPasswords = applications.reduce( (sum, app) => sum + app.atRiskPasswordCount, 0, diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts index 72d7e88fca..28d670f226 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts @@ -82,9 +82,10 @@ describe("CriticalAppsService", () => { }); it("should exclude records that already exist", async () => { + const privateCriticalAppsSubject = service["criticalAppsListSubject$"]; // arrange // one record already exists - service.setAppsInListForOrg([ + privateCriticalAppsSubject.next([ { id: randomUUID() as PasswordHealthReportApplicationId, organizationId: SomeOrganization, @@ -145,6 +146,7 @@ describe("CriticalAppsService", () => { it("should get by org id", () => { const orgId = "some organization" as OrganizationId; + const privateCriticalAppsSubject = service["criticalAppsListSubject$"]; const response = [ { id: "id1", organizationId: "some organization", uri: "https://example.com" }, { id: "id2", organizationId: "some organization", uri: "https://example.org" }, @@ -155,13 +157,14 @@ describe("CriticalAppsService", () => { const orgKey$ = new BehaviorSubject(OrgRecords); keyService.orgKeys$.mockReturnValue(orgKey$); service.loadOrganizationContext(SomeOrganization, SomeUser); - service.setAppsInListForOrg(response); + privateCriticalAppsSubject.next(response); service.getAppsListForOrg(orgId as OrganizationId).subscribe((res) => { expect(res).toHaveLength(2); }); }); it("should drop a critical app", async () => { + const privateCriticalAppsSubject = service["criticalAppsListSubject$"]; // arrange const selectedUrl = "https://example.com"; @@ -175,7 +178,7 @@ describe("CriticalAppsService", () => { service.loadOrganizationContext(SomeOrganization, SomeUser); - service.setAppsInListForOrg(initialList); + privateCriticalAppsSubject.next(initialList); // act await service.dropCriticalApp(SomeOrganization, selectedUrl); @@ -193,6 +196,7 @@ describe("CriticalAppsService", () => { }); it("should not drop a critical app if it does not exist", async () => { + const privateCriticalAppsSubject = service["criticalAppsListSubject$"]; // arrange const selectedUrl = "https://nonexistent.com"; @@ -206,7 +210,7 @@ describe("CriticalAppsService", () => { service.loadOrganizationContext(SomeOrganization, SomeUser); - service.setAppsInListForOrg(initialList); + privateCriticalAppsSubject.next(initialList); // act await service.dropCriticalApp(SomeOrganization, selectedUrl); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts index 82001387bb..b3b2f7c44e 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts @@ -83,11 +83,6 @@ export class CriticalAppsService { .pipe(map((apps) => apps.filter((app) => app.organizationId === orgId))); } - // Reset the critical apps list - setAppsInListForOrg(apps: PasswordHealthReportApplicationsResponse[]) { - this.criticalAppsListSubject$.next(apps); - } - // Save the selected critical apps for a given organization async setCriticalApps(orgId: OrganizationId, selectedUrls: string[]) { if (orgId != this.organizationId.value) { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts index 4eda92f0eb..56246f3c3b 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts @@ -7,6 +7,7 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response" import { makeEncString } from "@bitwarden/common/spec"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; +import { EncryptedDataWithKey } from "../models"; import { GetRiskInsightsApplicationDataResponse, GetRiskInsightsReportResponse, @@ -14,7 +15,7 @@ import { SaveRiskInsightsReportRequest, SaveRiskInsightsReportResponse, } from "../models/api-models.types"; -import { EncryptedDataWithKey } from "../models/password-health"; +import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data"; import { RiskInsightsApiService } from "./risk-insights-api.service"; @@ -26,17 +27,21 @@ describe("RiskInsightsApiService", () => { const orgId = "org1" as OrganizationId; 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() ?? ""; + const mockReportKey = makeEncString("test-key"); - const saveRiskInsightsReportRequest: SaveRiskInsightsReportRequest = { + const mockReportEnc = makeEncString(JSON.stringify(mockReportData)); + const mockSummaryEnc = makeEncString(JSON.stringify(mockSummaryData)); + const mockApplicationsEnc = makeEncString(JSON.stringify(mockApplicationData)); + + const mockSaveRiskInsightsReportRequest: SaveRiskInsightsReportRequest = { data: { organizationId: orgId, - date: new Date().toISOString(), - reportData: reportData, - contentEncryptionKey: reportKey, + creationDate: new Date().toISOString(), + reportData: mockReportEnc.decryptedValue ?? "", + summaryData: mockReportEnc.decryptedValue ?? "", + applicationData: mockReportEnc.decryptedValue ?? "", + contentEncryptionKey: mockReportKey.decryptedValue ?? "", }, }; @@ -53,7 +58,9 @@ describe("RiskInsightsApiService", () => { id: mockId, organizationId: orgId, date: new Date().toISOString(), - reportData: mockData, + reportData: mockReportEnc, + summaryData: mockSummaryEnc, + applicationData: mockApplicationsEnc, contentEncryptionKey: mockKey, }; @@ -96,17 +103,17 @@ describe("RiskInsightsApiService", () => { }); it("saveRiskInsightsReport$ should call apiService.send with correct parameters", async () => { - mockApiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportRequest)); + mockApiService.send.mockReturnValue(Promise.resolve(mockSaveRiskInsightsReportRequest)); const result = await firstValueFrom( - service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId), + service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId), ); - expect(result).toEqual(new SaveRiskInsightsReportResponse(saveRiskInsightsReportRequest)); + expect(result).toEqual(new SaveRiskInsightsReportResponse(mockSaveRiskInsightsReportRequest)); expect(mockApiService.send).toHaveBeenCalledWith( "POST", `/reports/organizations/${orgId.toString()}`, - saveRiskInsightsReportRequest.data, + mockSaveRiskInsightsReportRequest.data, true, true, ); @@ -117,13 +124,13 @@ describe("RiskInsightsApiService", () => { mockApiService.send.mockReturnValue(Promise.reject(error)); await expect( - firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)), + firstValueFrom(service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId)), ).rejects.toEqual(error); expect(mockApiService.send).toHaveBeenCalledWith( "POST", `/reports/organizations/${orgId.toString()}`, - saveRiskInsightsReportRequest.data, + mockSaveRiskInsightsReportRequest.data, true, true, ); @@ -134,13 +141,13 @@ describe("RiskInsightsApiService", () => { mockApiService.send.mockReturnValue(Promise.reject(error)); await expect( - firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)), + firstValueFrom(service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId)), ).rejects.toEqual(error); expect(mockApiService.send).toHaveBeenCalledWith( "POST", `/reports/organizations/${orgId.toString()}`, - saveRiskInsightsReportRequest.data, + mockSaveRiskInsightsReportRequest.data, true, true, ); @@ -153,7 +160,7 @@ describe("RiskInsightsApiService", () => { { reportId: mockReportId, organizationId: orgId, - encryptedData: mockData, + encryptedData: mockReportData, contentEncryptionKey: mockKey, }, ]; @@ -175,8 +182,10 @@ describe("RiskInsightsApiService", () => { 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), + encryptedReportData: new EncString(JSON.stringify(mockReportData)), + encryptedSummaryData: new EncString(JSON.stringify(mockSummaryData)), + encryptedApplicationData: new EncString(JSON.stringify(mockApplicationData)), }; const reportId = "report123" as OrganizationReportId; @@ -199,7 +208,9 @@ describe("RiskInsightsApiService", () => { const reportId = "report123" as OrganizationReportId; const mockResponse: EncryptedDataWithKey | null = { organizationId: orgId, - encryptedData: new EncString(mockData), + encryptedReportData: new EncString(JSON.stringify(mockReportData)), + encryptedSummaryData: new EncString(JSON.stringify(mockSummaryData)), + encryptedApplicationData: new EncString(JSON.stringify(mockApplicationData)), contentEncryptionKey: new EncString(mockKey), }; @@ -217,21 +228,17 @@ describe("RiskInsightsApiService", () => { }); 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; + const mockApplication = mockApplicationData[0]; mockApiService.send.mockResolvedValueOnce(undefined); const result = await firstValueFrom( - service.updateRiskInsightsApplicationData$(applicationData, orgId, reportId), + service.updateRiskInsightsApplicationData$(mockApplication, orgId, reportId), ); expect(mockApiService.send).toHaveBeenCalledWith( "PATCH", `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, - applicationData, + mockApplication, true, true, ); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts index 8f40ae91b4..99bf27506b 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts @@ -4,6 +4,7 @@ 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 { EncryptedDataWithKey, OrganizationReportApplication } from "../models"; import { GetRiskInsightsApplicationDataResponse, GetRiskInsightsReportResponse, @@ -11,7 +12,6 @@ import { SaveRiskInsightsReportRequest, SaveRiskInsightsReportResponse, } from "../models/api-models.types"; -import { EncryptedDataWithKey } from "../models/password-health"; export class RiskInsightsApiService { constructor(private apiService: ApiService) {} @@ -102,7 +102,7 @@ export class RiskInsightsApiService { } updateRiskInsightsApplicationData$( - applicationData: EncryptedDataWithKey, + applicationData: OrganizationReportApplication, orgId: OrganizationId, reportId: OrganizationReportId, ): Observable { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts index 7038844998..6b775f8432 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts @@ -1,10 +1,12 @@ -import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of } from "rxjs"; +import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of, throwError } from "rxjs"; import { + catchError, distinctUntilChanged, exhaustMap, filter, finalize, map, + shareReplay, switchMap, tap, withLatestFrom, @@ -18,19 +20,13 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { - AppAtRiskMembersDialogParams, - AtRiskApplicationDetail, - AtRiskMemberDetail, - DrawerType, - DrawerDetails, - ApplicationHealthReportDetail, - ApplicationHealthReportDetailEnriched, - ReportDetailsAndSummary, -} from "../models/report-models"; +import { ApplicationHealthReportDetailEnriched } from "../models"; +import { RiskInsightsEnrichedData } from "../models/report-data-service.types"; +import { DrawerType, DrawerDetails, ApplicationHealthReportDetail } from "../models/report-models"; import { CriticalAppsService } from "./critical-apps.service"; import { RiskInsightsReportService } from "./risk-insights-report.service"; + export class RiskInsightsDataService { // -------------------------- Context state -------------------------- // Current user viewing risk insights @@ -45,16 +41,17 @@ export class RiskInsightsDataService { organizationDetails$ = this.organizationDetailsSubject.asObservable(); // -------------------------- Data ------------------------------------ - private applicationsSubject = new BehaviorSubject(null); - applications$ = this.applicationsSubject.asObservable(); + // TODO: Remove. Will use report results + private LEGACY_applicationsSubject = new BehaviorSubject( + null, + ); + LEGACY_applications$ = this.LEGACY_applicationsSubject.asObservable(); - private dataLastUpdatedSubject = new BehaviorSubject(null); - dataLastUpdated$ = this.dataLastUpdatedSubject.asObservable(); - - criticalApps$ = this.criticalAppsService.criticalAppsList$; + // TODO: Remove. Will use date from report results + private LEGACY_dataLastUpdatedSubject = new BehaviorSubject(null); + dataLastUpdated$ = this.LEGACY_dataLastUpdatedSubject.asObservable(); // --------------------------- UI State ------------------------------------ - private isLoadingSubject = new BehaviorSubject(false); isLoading$ = this.isLoadingSubject.asObservable(); @@ -78,21 +75,52 @@ export class RiskInsightsDataService { // ------------------------- Report Variables ---------------- // The last run report details - private reportResultsSubject = new BehaviorSubject(null); + private reportResultsSubject = new BehaviorSubject(null); reportResults$ = this.reportResultsSubject.asObservable(); // Is a report being generated private isRunningReportSubject = new BehaviorSubject(false); isRunningReport$ = this.isRunningReportSubject.asObservable(); - // The error from report generation if there was an error + + // --------------------------- Critical Application data --------------------- + criticalReportResults$: Observable = of(null); constructor( private accountService: AccountService, private criticalAppsService: CriticalAppsService, private organizationService: OrganizationService, private reportService: RiskInsightsReportService, - ) {} + ) { + // Reload report if critical applications change + // This also handles the original report load + this.criticalAppsService.criticalAppsList$ + .pipe(withLatestFrom(this.organizationDetails$, this.userId$)) + .subscribe({ + next: ([_criticalApps, organizationDetails, userId]) => { + if (organizationDetails?.organizationId && userId) { + this.fetchLastReport(organizationDetails?.organizationId, userId); + } + }, + }); + + // Setup critical application data and summary generation for live critical application usage + this.criticalReportResults$ = this.reportResults$.pipe( + filter((report) => !!report), + map((r) => { + const criticalApplications = r.reportData.filter( + (application) => application.isMarkedAsCritical, + ); + const summary = this.reportService.generateApplicationsSummary(criticalApplications); + + return { + ...r, + summaryData: summary, + reportData: criticalApplications, + }; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + } - // [FIXME] PM-25612 - Call Initialization in RiskInsightsComponent instead of child components async initializeForOrganization(organizationId: OrganizationId) { // Fetch current user const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); @@ -115,9 +143,6 @@ export class RiskInsightsDataService { // Load critical applications for organization await this.criticalAppsService.loadOrganizationContext(organizationId, userId); - // Load existing report - this.fetchLastReport(organizationId, userId); - // Setup new report generation this._runApplicationsReport().subscribe({ next: (result) => { @@ -133,7 +158,7 @@ export class RiskInsightsDataService { * Fetches the applications report and updates the applicationsSubject. * @param organizationId The ID of the organization. */ - fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void { + LEGACY_fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void { if (isRefresh) { this.isRefreshingSubject.next(true); } else { @@ -145,24 +170,20 @@ export class RiskInsightsDataService { finalize(() => { this.isLoadingSubject.next(false); this.isRefreshingSubject.next(false); - this.dataLastUpdatedSubject.next(new Date()); + this.LEGACY_dataLastUpdatedSubject.next(new Date()); }), ) .subscribe({ next: (reports: ApplicationHealthReportDetail[]) => { - this.applicationsSubject.next(reports); + this.LEGACY_applicationsSubject.next(reports); this.errorSubject.next(null); }, error: () => { - this.applicationsSubject.next([]); + this.LEGACY_applicationsSubject.next([]); }, }); } - refreshApplicationsReport(organizationId: OrganizationId): void { - this.fetchApplicationsReport(organizationId, true); - } - // ------------------------------- Enrichment methods ------------------------------- /** * Takes the basic application health report details and enriches them to include @@ -174,8 +195,10 @@ export class RiskInsightsDataService { enrichReportData$( applications: ApplicationHealthReportDetail[], ): Observable { + // TODO Compare applications on report to updated critical applications + // TODO Compare applications on report to any new applications return of(applications).pipe( - withLatestFrom(this.organizationDetails$, this.criticalApps$), + withLatestFrom(this.organizationDetails$, this.criticalAppsService.criticalAppsList$), switchMap(async ([apps, orgDetails, criticalApps]) => { if (!orgDetails) { return []; @@ -200,19 +223,11 @@ export class RiskInsightsDataService { ); } - // ------------------------------- Drawer management methods ------------------------------- // ------------------------- Drawer functions ----------------------------- - - isActiveDrawerType$ = (drawerType: DrawerType): Observable => { - return this.drawerDetails$.pipe(map((details) => details.activeDrawerType === drawerType)); - }; isActiveDrawerType = (drawerType: DrawerType): boolean => { return this.drawerDetailsSubject.value.activeDrawerType === drawerType; }; - isDrawerOpenForInvoker$ = (applicationName: string) => { - return this.drawerDetails$.pipe(map((details) => details.invokerId === applicationName)); - }; isDrawerOpenForInvoker = (applicationName: string): boolean => { return this.drawerDetailsSubject.value.invokerId === applicationName; }; @@ -228,10 +243,7 @@ export class RiskInsightsDataService { }); }; - setDrawerForOrgAtRiskMembers = ( - atRiskMemberDetails: AtRiskMemberDetail[], - invokerId: string = "", - ): void => { + setDrawerForOrgAtRiskMembers = async (invokerId: string = ""): Promise => { const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; const shouldClose = open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId; @@ -239,6 +251,15 @@ export class RiskInsightsDataService { if (shouldClose) { this.closeDrawer(); } else { + const reportResults = await firstValueFrom(this.reportResults$); + if (!reportResults) { + return; + } + + const atRiskMemberDetails = this.reportService.generateAtRiskMemberList( + reportResults.reportData, + ); + this.drawerDetailsSubject.next({ open: true, invokerId, @@ -250,10 +271,7 @@ export class RiskInsightsDataService { } }; - setDrawerForAppAtRiskMembers = ( - atRiskMembersDialogParams: AppAtRiskMembersDialogParams, - invokerId: string = "", - ): void => { + setDrawerForAppAtRiskMembers = async (invokerId: string = ""): Promise => { const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; const shouldClose = open && activeDrawerType === DrawerType.AppAtRiskMembers && currentInvokerId === invokerId; @@ -261,21 +279,29 @@ export class RiskInsightsDataService { if (shouldClose) { this.closeDrawer(); } else { + const reportResults = await firstValueFrom(this.reportResults$); + if (!reportResults) { + return; + } + + const atRiskMembers = { + members: + reportResults.reportData.find((app) => app.applicationName === invokerId) + ?.atRiskMemberDetails ?? [], + applicationName: invokerId, + }; this.drawerDetailsSubject.next({ open: true, invokerId, activeDrawerType: DrawerType.AppAtRiskMembers, atRiskMemberDetails: [], - appAtRiskMembers: atRiskMembersDialogParams, + appAtRiskMembers: atRiskMembers, atRiskAppDetails: null, }); } }; - setDrawerForOrgAtRiskApps = ( - atRiskApps: AtRiskApplicationDetail[], - invokerId: string = "", - ): void => { + setDrawerForOrgAtRiskApps = async (invokerId: string = ""): Promise => { const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; const shouldClose = open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId; @@ -283,13 +309,21 @@ export class RiskInsightsDataService { if (shouldClose) { this.closeDrawer(); } else { + const reportResults = await firstValueFrom(this.reportResults$); + if (!reportResults) { + return; + } + const atRiskAppDetails = this.reportService.generateAtRiskApplicationList( + reportResults.reportData, + ); + this.drawerDetailsSubject.next({ open: true, invokerId, activeDrawerType: DrawerType.OrgAtRiskApps, atRiskMemberDetails: [], appAtRiskMembers: null, - atRiskAppDetails: atRiskApps, + atRiskAppDetails, }); } }; @@ -311,23 +345,31 @@ export class RiskInsightsDataService { .getRiskInsightsReport$(organizationId, userId) .pipe( switchMap((report) => { - return this.enrichReportData$(report.data).pipe( + // Take fetched report data and merge with critical applications + return this.enrichReportData$(report.reportData).pipe( map((enrichedReport) => ({ - data: enrichedReport, - summary: report.summary, + report: enrichedReport, + summary: report.summaryData, + applications: report.applicationData, + creationDate: report.creationDate, })), ); }), + catchError((error: unknown) => { + // console.error("An error occurred when fetching the last report", error); + return EMPTY; + }), finalize(() => { this.isLoadingSubject.next(false); }), ) .subscribe({ - next: ({ data, summary }) => { + next: ({ report, summary, applications, creationDate }) => { this.reportResultsSubject.next({ - data, - summary, - dateCreated: new Date(), + reportData: report, + summaryData: summary, + applicationData: applications, + creationDate: creationDate, }); this.errorSubject.next(null); this.isLoadingSubject.next(false); @@ -343,6 +385,7 @@ export class RiskInsightsDataService { private _runApplicationsReport() { return this.isRunningReport$.pipe( distinctUntilChanged(), + // Only run this report if the flag for running is true filter((isRunning) => isRunning), withLatestFrom(this.organizationDetails$, this.userId$), exhaustMap(([_, organizationDetails, userId]) => { @@ -353,22 +396,30 @@ export class RiskInsightsDataService { // Generate the report return this.reportService.generateApplicationsReport$(organizationId).pipe( - map((data) => ({ - data, - summary: this.reportService.generateApplicationsSummary(data), + map((report) => ({ + report, + summary: this.reportService.generateApplicationsSummary(report), + applications: this.reportService.generateOrganizationApplications(report), })), - switchMap(({ data, summary }) => - this.enrichReportData$(data).pipe( - map((enrichedData) => ({ data: enrichedData, summary })), + // Enrich report with critical markings + switchMap(({ report, summary, applications }) => + this.enrichReportData$(report).pipe( + map((enrichedReport) => ({ report: enrichedReport, summary, applications })), ), ), - tap(({ data, summary }) => { - this.reportResultsSubject.next({ data, summary, dateCreated: new Date() }); + // Load the updated data into the UI + tap(({ report, summary, applications }) => { + this.reportResultsSubject.next({ + reportData: report, + summaryData: summary, + applicationData: applications, + creationDate: new Date(), + }); this.errorSubject.next(null); }), - switchMap(({ data, summary }) => { - // Just returns ID - return this.reportService.saveRiskInsightsReport$(data, summary, { + switchMap(({ report, summary, applications }) => { + // Save the generated data + return this.reportService.saveRiskInsightsReport$(report, summary, applications, { organizationId, userId, }); @@ -377,4 +428,42 @@ export class RiskInsightsDataService { }), ); } + + // ------------------------------ Critical application methods -------------- + + saveCriticalApplications(selectedUrls: string[]) { + return this.organizationDetails$.pipe( + exhaustMap((organizationDetails) => { + if (!organizationDetails?.organizationId) { + return EMPTY; + } + return this.criticalAppsService.setCriticalApps( + organizationDetails?.organizationId, + selectedUrls, + ); + }), + catchError((error: unknown) => { + this.errorSubject.next("Failed to save critical applications"); + return throwError(() => error); + }), + ); + } + + removeCriticalApplication(hostname: string) { + return this.organizationDetails$.pipe( + exhaustMap((organizationDetails) => { + if (!organizationDetails?.organizationId) { + return EMPTY; + } + return this.criticalAppsService.dropCriticalApp( + organizationDetails?.organizationId, + hostname, + ); + }), + catchError((error: unknown) => { + this.errorSubject.next("Failed to remove critical application"); + return throwError(() => error); + }), + ); + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts index 9b7bb3b725..e2c92ad4b9 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts @@ -10,6 +10,9 @@ import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; +import { EncryptedReportData, DecryptedReportData } from "../models"; +import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data"; + import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; describe("RiskInsightsEncryptionService", () => { @@ -31,6 +34,10 @@ describe("RiskInsightsEncryptionService", () => { }; const orgKey$ = new BehaviorSubject(OrgRecords); + let mockDecryptedData: DecryptedReportData; + let mockEncryptedData: EncryptedReportData; + let mockKey: EncString; + beforeEach(() => { service = new RiskInsightsEncryptionService( mockKeyService, @@ -47,6 +54,18 @@ describe("RiskInsightsEncryptionService", () => { mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData)); mockKeyService.orgKeys$.mockReturnValue(orgKey$); + + mockKey = new EncString("wrapped-key"); + mockEncryptedData = { + encryptedReportData: new EncString(JSON.stringify(mockReportData)), + encryptedSummaryData: new EncString(JSON.stringify(mockSummaryData)), + encryptedApplicationData: new EncString(JSON.stringify(mockApplicationData)), + }; + mockDecryptedData = { + reportData: mockReportData, + summaryData: mockSummaryData, + applicationData: mockApplicationData, + }; }); describe("encryptRiskInsightsReport", () => { @@ -55,22 +74,40 @@ describe("RiskInsightsEncryptionService", () => { mockKeyService.orgKeys$.mockReturnValue(orgKey$); // Act: call the method under test - const result = await service.encryptRiskInsightsReport(orgId, userId, testData); + const result = await service.encryptRiskInsightsReport( + { organizationId: orgId, userId }, + mockDecryptedData, + ); // Assert: ensure that the methods were called with the expected parameters expect(mockKeyService.orgKeys$).toHaveBeenCalledWith(userId); expect(mockKeyGenerationService.createKey).toHaveBeenCalledWith(512); + + // Assert all variables were encrypted expect(mockEncryptService.encryptString).toHaveBeenCalledWith( - JSON.stringify(testData), + JSON.stringify(mockDecryptedData.reportData), contentEncryptionKey, ); + expect(mockEncryptService.encryptString).toHaveBeenCalledWith( + JSON.stringify(mockDecryptedData.summaryData), + contentEncryptionKey, + ); + expect(mockEncryptService.encryptString).toHaveBeenCalledWith( + JSON.stringify(mockDecryptedData.applicationData), + contentEncryptionKey, + ); + expect(mockEncryptService.wrapSymmetricKey).toHaveBeenCalledWith( contentEncryptionKey, orgKey, ); + + // Mocked encrypt returns ENCRYPTED_TEXT expect(result).toEqual({ organizationId: orgId, - encryptedData: new EncString(ENCRYPTED_TEXT), + encryptedReportData: new EncString(ENCRYPTED_TEXT), + encryptedSummaryData: new EncString(ENCRYPTED_TEXT), + encryptedApplicationData: new EncString(ENCRYPTED_TEXT), contentEncryptionKey: new EncString(ENCRYPTED_KEY), }); }); @@ -82,9 +119,9 @@ describe("RiskInsightsEncryptionService", () => { 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", - ); + await expect( + service.encryptRiskInsightsReport({ organizationId: orgId, userId }, mockDecryptedData), + ).rejects.toThrow("Encryption failed, encrypted strings are null"); }); it("should throw an error when encrypted key is null or empty", async () => { @@ -94,18 +131,18 @@ describe("RiskInsightsEncryptionService", () => { 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", - ); + await expect( + service.encryptRiskInsightsReport({ organizationId: orgId, userId }, mockDecryptedData), + ).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", - ); + await expect( + service.encryptRiskInsightsReport({ organizationId: orgId, userId }, mockDecryptedData), + ).rejects.toThrow("Organization key not found"); }); }); @@ -120,23 +157,21 @@ describe("RiskInsightsEncryptionService", () => { // 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, + { organizationId: orgId, userId }, + mockEncryptedData, + mockKey, ); 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); + expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith(mockKey, orgKey); + expect(mockEncryptService.decryptString).toHaveBeenCalledTimes(3); + + // Mock decrypt returns JSON.stringify(testData) + expect(result).toEqual({ + reportData: testData, + summaryData: testData, + applicationData: testData, + }); }); it("should invoke data type validation method during decryption", async () => { @@ -144,77 +179,47 @@ describe("RiskInsightsEncryptionService", () => { 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, + { organizationId: orgId, userId }, + mockEncryptedData, + mockKey, ); - expect(mockParseFn).toHaveBeenCalledWith(JSON.parse(JSON.stringify(testData))); - expect(result).toEqual(testData); + expect(result).toEqual({ + reportData: testData, + summaryData: testData, + applicationData: testData, + }); }); it("should return null if org key is not found", async () => { mockKeyService.orgKeys$.mockReturnValue(new BehaviorSubject({})); + await expect( + service.decryptRiskInsightsReport( + { organizationId: orgId, userId }, - const result = await service.decryptRiskInsightsReport( - orgId, - userId, - new EncString("encrypted-data"), - new EncString("wrapped-key"), - (data) => data as typeof testData, - ); - - expect(result).toBeNull(); + mockEncryptedData, + mockKey, + ), + ).rejects.toEqual(Error("Organization key not found")); }); 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(); - }); + await expect( + service.decryptRiskInsightsReport( + { organizationId: orgId, userId }, - 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(); + mockEncryptedData, + mockKey, + ), + ).rejects.toEqual(Error("fail")); }); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts index 7bf01b04a6..04811f9cfc 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts @@ -1,13 +1,13 @@ 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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; -import { EncryptedDataWithKey } from "../models/password-health"; +import { DecryptedReportData, EncryptedReportData, EncryptedDataWithKey } from "../models"; export class RiskInsightsEncryptionService { constructor( @@ -16,11 +16,15 @@ export class RiskInsightsEncryptionService { private keyGeneratorService: KeyGenerationService, ) {} - async encryptRiskInsightsReport( - organizationId: OrganizationId, - userId: UserId, - data: T, + async encryptRiskInsightsReport( + context: { + organizationId: OrganizationId; + userId: UserId; + }, + data: DecryptedReportData, + wrappedKey?: EncString, ): Promise { + const { userId, organizationId } = context; const orgKey = await firstValueFrom( this.keyService .orgKeys$(userId) @@ -35,10 +39,28 @@ export class RiskInsightsEncryptionService { throw new Error("Organization key not found"); } - const contentEncryptionKey = await this.keyGeneratorService.createKey(512); + let contentEncryptionKey: SymmetricCryptoKey; + if (!wrappedKey) { + // Generate a new key + contentEncryptionKey = await this.keyGeneratorService.createKey(512); + } else { + // Unwrap the existing key + contentEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey); + } - const dataEncrypted = await this.encryptService.encryptString( - JSON.stringify(data), + const { reportData, summaryData, applicationData } = data; + + // Encrypt the data + const encryptedReportData = await this.encryptService.encryptString( + JSON.stringify(reportData), + contentEncryptionKey, + ); + const encryptedSummaryData = await this.encryptService.encryptString( + JSON.stringify(summaryData), + contentEncryptionKey, + ); + const encryptedApplicationData = await this.encryptService.encryptString( + JSON.stringify(applicationData), contentEncryptionKey, ); @@ -47,59 +69,87 @@ export class RiskInsightsEncryptionService { orgKey, ); - if (!dataEncrypted.encryptedString || !wrappedEncryptionKey.encryptedString) { + if ( + !encryptedReportData.encryptedString || + !encryptedSummaryData.encryptedString || + !encryptedApplicationData.encryptedString || + !wrappedEncryptionKey.encryptedString + ) { throw new Error("Encryption failed, encrypted strings are null"); } - const encryptedData = dataEncrypted; - const contentEncryptionKeyString = wrappedEncryptionKey; - const encryptedDataPacket: EncryptedDataWithKey = { organizationId, - encryptedData, - contentEncryptionKey: contentEncryptionKeyString, + encryptedReportData: encryptedReportData, + encryptedSummaryData: encryptedSummaryData, + encryptedApplicationData: encryptedApplicationData, + contentEncryptionKey: wrappedEncryptionKey, }; return encryptedDataPacket; } - async decryptRiskInsightsReport( - organizationId: OrganizationId, - userId: UserId, - encryptedData: EncString, + async decryptRiskInsightsReport( + context: { + organizationId: OrganizationId; + userId: UserId; + }, + encryptedData: EncryptedReportData, wrappedKey: EncString, - parser: (data: Jsonify) => T, - ): Promise { - try { - const orgKey = await firstValueFrom( - this.keyService - .orgKeys$(userId) - .pipe( - map((organizationKeysById) => - organizationKeysById ? organizationKeysById[organizationId] : null, - ), + ): Promise { + const { userId, organizationId } = context; + 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; + if (!orgKey) { + throw new Error("Organization key not found"); } + + const unwrappedEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey); + if (!unwrappedEncryptionKey) { + throw Error("Encryption key not found"); + } + + const { encryptedReportData, encryptedSummaryData, encryptedApplicationData } = encryptedData; + if (!encryptedReportData || !encryptedSummaryData || !encryptedApplicationData) { + throw new Error("Missing data"); + } + + // Decrypt the data + const decryptedReportData = await this.encryptService.decryptString( + encryptedReportData, + unwrappedEncryptionKey, + ); + const decryptedSummaryData = await this.encryptService.decryptString( + encryptedSummaryData, + unwrappedEncryptionKey, + ); + const decryptedApplicationData = await this.encryptService.decryptString( + encryptedApplicationData, + unwrappedEncryptionKey, + ); + + if (!decryptedReportData || !decryptedSummaryData || !decryptedApplicationData) { + throw new Error("Decryption failed, decrypted strings are null"); + } + + const decryptedReportDataJson = JSON.parse(decryptedReportData); + const decryptedSummaryDataJson = JSON.parse(decryptedSummaryData); + const decryptedApplicationDataJson = JSON.parse(decryptedApplicationData); + + const decryptedFullReport = { + reportData: decryptedReportDataJson, + summaryData: decryptedSummaryDataJson, + applicationData: decryptedApplicationDataJson, + }; + + return decryptedFullReport; } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts index 18836fb131..5f8fdaa244 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts @@ -1,25 +1,23 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; -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, 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 { createNewSummaryData } from "../helpers"; +import { DecryptedReportData, EncryptedDataWithKey } from "../models"; 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"; + mockApplicationData, + mockCipherViews, + mockMemberDetails, + mockReportData, + mockSummaryData, +} from "../models/mock-data"; import { mockCiphers } from "./ciphers.mock"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; @@ -45,17 +43,13 @@ describe("RiskInsightsReportService", () => { // 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); + const mockEncryptedKey = makeEncString("test-key"); // Changing mock data - let mockCipherViews: CipherView[]; - let mockMemberDetails: MemberCipherDetailsResponse[]; - let mockReport: ApplicationHealthReportDetail[]; - let mockSummary: OrganizationReportSummary; - let mockEncryptedReport: EncryptedDataWithKey; + let mockDecryptedData: DecryptedReportData; + const mockReportEnc = makeEncString(JSON.stringify(mockReportData)); + const mockSummaryEnc = makeEncString(JSON.stringify(mockSummaryData)); + const mockApplicationsEnc = makeEncString(JSON.stringify(mockApplicationData)); beforeEach(() => { cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers); @@ -87,75 +81,15 @@ describe("RiskInsightsReportService", () => { service = new RiskInsightsReportService( cipherService, memberCipherDetailsService, + mockPasswordHealthService, mockRiskInsightsApiService, mockRiskInsightsEncryptionService, - mockPasswordHealthService, ); - // Reset mock ciphers before each test - mockCipherViews = [ - mock({ - id: "cipher-1", - type: CipherType.Login, - login: { password: "pass1", username: "user1", uris: [{ uri: "https://app.com/login" }] }, - isDeleted: false, - viewPassword: true, - }), - mock({ - id: "cipher-2", - type: CipherType.Login, - login: { password: "pass2", username: "user2", uris: [{ uri: "app.com/home" }] }, - isDeleted: false, - viewPassword: true, - }), - mock({ - id: "cipher-3", - type: CipherType.Login, - login: { password: "pass3", username: "user3", uris: [{ uri: "https://other.com" }] }, - isDeleted: false, - viewPassword: true, - }), - ]; - mockMemberDetails = [ - mock({ - cipherIds: ["cipher-1"], - userGuid: "user1", - userName: "User 1", - email: "user1@app.com", - }), - mock({ - cipherIds: ["cipher-2"], - userGuid: "user2", - userName: "User 2", - email: "user2@app.com", - }), - mock({ - cipherIds: ["cipher-3"], - userGuid: "user3", - userName: "User 3", - 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, + mockDecryptedData = { + reportData: mockReportData, + summaryData: mockSummaryData, + applicationData: mockApplicationData, }; }); @@ -284,15 +218,22 @@ describe("RiskInsightsReportService", () => { describe("saveRiskInsightsReport$", () => { it("should not update subjects if save response does not have id", (done) => { + const mockEncryptedOutput: EncryptedDataWithKey = { + organizationId: mockOrganizationId, + encryptedReportData: mockReportEnc, + encryptedSummaryData: mockSummaryEnc, + encryptedApplicationData: mockApplicationsEnc, + contentEncryptionKey: mockEncryptedKey, + }; mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue( - mockEncryptedReport, + mockEncryptedOutput, ); const saveResponse = new SaveRiskInsightsReportResponse({ id: "" }); // Simulating no ID in response mockRiskInsightsApiService.saveRiskInsightsReport$.mockReturnValue(of(saveResponse)); service - .saveRiskInsightsReport$(mockReport, mockSummary, { + .saveRiskInsightsReport$(mockReportData, mockSummaryData, mockApplicationData, { organizationId: mockOrganizationId, userId: mockUserId, }) @@ -321,17 +262,19 @@ describe("RiskInsightsReportService", () => { 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 = { + const apiResponse = new GetRiskInsightsReportResponse({ id: "reportId", - date: new Date().toISOString(), + date: new Date(), organizationId: mockOrganizationId, - reportData: mockEncryptedReport.encryptedData, - contentEncryptionKey: mockEncryptedReport.contentEncryptionKey, - } as GetRiskInsightsReportResponse; + reportData: mockReportEnc.encryptedString, + summaryData: mockSummaryEnc.encryptedString, + applicationData: mockApplicationsEnc.encryptedString, + contentEncryptionKey: mockEncryptedKey.encryptedString, + }); - const decryptedResponse: RiskInsightsReportData = { - data: [], - summary: { + const decryptedResponse: DecryptedReportData = { + reportData: [], + summaryData: { totalMemberCount: 1, totalAtRiskMemberCount: 1, totalApplicationCount: 1, @@ -342,9 +285,9 @@ describe("RiskInsightsReportService", () => { totalCriticalAtRiskApplicationCount: 1, newApplications: [], }, + applicationData: [], }; - const organizationId = "orgId" as OrganizationId; const userId = "userId" as UserId; // Mock api returned encrypted data @@ -355,17 +298,15 @@ describe("RiskInsightsReportService", () => { Promise.resolve(decryptedResponse), ); - await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId)); + await firstValueFrom(service.getRiskInsightsReport$(mockOrganizationId, userId)); expect(mockRiskInsightsApiService.getRiskInsightsReport$).toHaveBeenCalledWith( - organizationId, + mockOrganizationId, ); expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith( - organizationId, - userId, - expect.anything(), // encryptedData - expect.anything(), // wrappedKey - expect.any(Function), // parser + { organizationId: mockOrganizationId, userId }, + expect.anything(), + expect.anything(), ); }); @@ -375,32 +316,29 @@ describe("RiskInsightsReportService", () => { const organizationId = "orgId" as OrganizationId; const userId = "userId" as UserId; - const mockResponse = { + const mockResponse = new GetRiskInsightsReportResponse({ id: "reportId", - date: new Date().toISOString(), + creationDate: new Date(), organizationId: organizationId as OrganizationId, - reportData: mockEncryptedReport.encryptedData, - contentEncryptionKey: mockEncryptedReport.contentEncryptionKey, - } as GetRiskInsightsReportResponse; + reportData: mockReportEnc.encryptedString, + summaryData: mockSummaryEnc.encryptedString, + applicationData: mockApplicationsEnc.encryptedString, + contentEncryptionKey: mockEncryptedKey.encryptedString, + }); - const decryptedReport = { - data: [{ foo: "bar" }], - }; mockRiskInsightsApiService.getRiskInsightsReport$.mockReturnValue(of(mockResponse)); mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockResolvedValue( - decryptedReport, + mockDecryptedData, ); const result = await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId)); expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith( - organizationId, - userId, + { organizationId: mockOrganizationId, userId }, expect.anything(), expect.anything(), - expect.any(Function), ); - expect(result).toEqual(decryptedReport); + expect(result).toEqual({ ...mockDecryptedData, creationDate: mockResponse.creationDate }); }); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts index d82366c015..c9a51e804d 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts @@ -1,6 +1,7 @@ import { - BehaviorSubject, + catchError, concatMap, + EMPTY, first, firstValueFrom, forkJoin, @@ -19,7 +20,6 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { createNewReportData, - createNewSummaryData, flattenMemberDetails, getApplicationReportDetail, getFlattenedCipherDetails, @@ -45,7 +45,8 @@ import { CipherHealthReport, MemberDetails, PasswordHealthData, - RiskInsightsReportData, + OrganizationReportApplication, + RiskInsightsData, } from "../models/report-models"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; @@ -54,14 +55,6 @@ import { RiskInsightsApiService } from "./risk-insights-api.service"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; export class RiskInsightsReportService { - private riskInsightsReportSubject = new BehaviorSubject([]); - riskInsightsReport$ = this.riskInsightsReportSubject.asObservable(); - - private riskInsightsSummarySubject = new BehaviorSubject( - createNewSummaryData(), - ); - riskInsightsSummary$ = this.riskInsightsSummarySubject.asObservable(); - // [FIXME] CipherData // Cipher data // private _ciphersSubject = new BehaviorSubject(null); @@ -70,9 +63,9 @@ export class RiskInsightsReportService { constructor( private cipherService: CipherService, private memberCipherDetailsApiService: MemberCipherDetailsApiService, + private passwordHealthService: PasswordHealthService, private riskInsightsApiService: RiskInsightsApiService, private riskInsightsEncryptionService: RiskInsightsEncryptionService, - private passwordHealthService: PasswordHealthService, ) {} // [FIXME] CipherData @@ -152,6 +145,7 @@ export class RiskInsightsReportService { /** * Report data for the aggregation of uris to like uris and getting password/member counts, * members, and at risk statuses. + * * @param organizationId Id of the organization * @returns The all applications health report data */ @@ -235,17 +229,32 @@ export class RiskInsightsReportService { // TODO: totalCriticalMemberCount, totalCriticalAtRiskMemberCount, totalCriticalApplicationCount, totalCriticalAtRiskApplicationCount, and newApplications will be handled with future logic implementation return { totalMemberCount: uniqueMembers.length, - totalCriticalMemberCount: 0, totalAtRiskMemberCount: uniqueAtRiskMembers.length, - totalCriticalAtRiskMemberCount: 0, totalApplicationCount: reports.length, - totalCriticalApplicationCount: 0, totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length, + totalCriticalMemberCount: 0, + totalCriticalAtRiskMemberCount: 0, + totalCriticalApplicationCount: 0, totalCriticalAtRiskApplicationCount: 0, newApplications: [], }; } + /** + * Generate a snapshot of applications and related data associated to this report + * + * @param reports + * @returns A list of applications with a critical marking flag + */ + generateOrganizationApplications( + reports: ApplicationHealthReportDetail[], + ): OrganizationReportApplication[] { + return reports.map((report) => ({ + applicationName: report.applicationName, + isCritical: false, + })); + } + async identifyCiphers( data: ApplicationHealthReportDetail[], organizationId: OrganizationId, @@ -272,12 +281,12 @@ export class RiskInsightsReportService { getRiskInsightsReport$( organizationId: OrganizationId, userId: UserId, - ): Observable { + ): Observable { return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe( - switchMap((response): Observable => { + switchMap((response) => { if (!response) { // Return an empty report and summary if response is falsy - return of(createNewReportData()); + return of(createNewReportData()); } if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") { return throwError(() => new Error("Report key not found")); @@ -285,15 +294,43 @@ export class RiskInsightsReportService { if (!response.reportData) { return throwError(() => new Error("Report data not found")); } + if (!response.summaryData) { + return throwError(() => new Error("Summary data not found")); + } + if (!response.applicationData) { + return throwError(() => new Error("Application data not found")); + } + return from( - this.riskInsightsEncryptionService.decryptRiskInsightsReport( - organizationId, - userId, - response.reportData, + this.riskInsightsEncryptionService.decryptRiskInsightsReport( + { + organizationId, + userId, + }, + { + encryptedReportData: response.reportData, + encryptedSummaryData: response.summaryData, + encryptedApplicationData: response.applicationData, + }, response.contentEncryptionKey, - (data) => data as RiskInsightsReportData, ), - ).pipe(map((decryptedReport) => decryptedReport ?? createNewReportData())); + ).pipe( + map((decryptedData) => ({ + reportData: decryptedData.reportData, + summaryData: decryptedData.summaryData, + applicationData: decryptedData.applicationData, + creationDate: response.creationDate, + })), + catchError((error: unknown) => { + // TODO Handle errors appropriately + // console.error("An error occurred when decrypting report", error); + return EMPTY; + }), + ); + }), + catchError((error: unknown) => { + // console.error("An error occurred when fetching the last report", error); + return EMPTY; }), ); } @@ -308,6 +345,7 @@ export class RiskInsightsReportService { saveRiskInsightsReport$( report: ApplicationHealthReportDetail[], summary: OrganizationReportSummary, + applications: OrganizationReportApplication[], encryptionParameters: { organizationId: OrganizationId; userId: UserId; @@ -315,28 +353,43 @@ export class RiskInsightsReportService { ): Observable { return from( this.riskInsightsEncryptionService.encryptRiskInsightsReport( - encryptionParameters.organizationId, - encryptionParameters.userId, { - data: report, - summary: summary, + organizationId: encryptionParameters.organizationId, + userId: encryptionParameters.userId, + }, + { + reportData: report, + summaryData: summary, + applicationData: applications, }, ), ).pipe( - map(({ encryptedData, contentEncryptionKey }) => ({ - data: { - organizationId: encryptionParameters.organizationId, - date: new Date().toISOString(), - reportData: encryptedData.toSdk(), - contentEncryptionKey: contentEncryptionKey.toSdk(), - }, - })), + map( + ({ + encryptedReportData, + encryptedSummaryData, + encryptedApplicationData, + contentEncryptionKey, + }) => ({ + data: { + organizationId: encryptionParameters.organizationId, + creationDate: new Date().toISOString(), + reportData: encryptedReportData.toSdk(), + summaryData: encryptedSummaryData.toSdk(), + applicationData: encryptedApplicationData.toSdk(), + contentEncryptionKey: contentEncryptionKey.toSdk(), + }, + }), + ), switchMap((encryptedReport) => this.riskInsightsApiService.saveRiskInsightsReport$( encryptedReport, encryptionParameters.organizationId, ), ), + catchError((error: unknown) => { + return EMPTY; + }), map((response) => { if (!isSaveRiskInsightsReportResponse(response)) { throw new Error("Invalid response from API"); @@ -457,6 +510,13 @@ export class RiskInsightsReportService { const applicationMap = new Map(); cipherHealthData.forEach((cipher: CipherHealthReport) => { + // Warning: Currently does not show ciphers with NO Application + // if (cipher.applications.length === 0) { + // const existingApplication = applicationMap.get("None") || []; + // existingApplication.push(cipher); + // applicationMap.set("None", existingApplication); + // } + cipher.applications.forEach((application) => { const existingApplication = applicationMap.get(application) || []; existingApplication.push(cipher); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts index 01bf19f30a..657f76d414 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts @@ -29,28 +29,32 @@ import { RiskInsightsComponent } from "./risk-insights.component"; @NgModule({ imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule], providers: [ - { + safeProvider({ provide: MemberCipherDetailsApiService, + useClass: MemberCipherDetailsApiService, deps: [ApiService], - }, - { + }), + safeProvider({ provide: PasswordHealthService, + useClass: PasswordHealthService, deps: [PasswordStrengthServiceAbstraction, AuditService], - }, - { + }), + safeProvider({ provide: RiskInsightsApiService, + useClass: RiskInsightsApiService, deps: [ApiService], - }, - { + }), + safeProvider({ provide: RiskInsightsReportService, + useClass: RiskInsightsReportService, deps: [ CipherService, MemberCipherDetailsApiService, + PasswordHealthService, RiskInsightsApiService, RiskInsightsEncryptionService, - PasswordHealthService, ], - }, + }), safeProvider({ provide: RiskInsightsDataService, deps: [ @@ -78,7 +82,7 @@ import { RiskInsightsComponent } from "./risk-insights.component"; safeProvider({ provide: AllActivitiesService, useClass: AllActivitiesService, - deps: [], + deps: [RiskInsightsDataService], }), safeProvider({ provide: SecurityTasksApiService, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html index a1b5611ff1..390102aced 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html @@ -1,10 +1,6 @@ -@if (isLoading$ | async) { -
- -
-} - -@if (!(isLoading$ | async)) { +@if (dataService.isLoading$ | async) { + +} @else {
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts index 1691e35c81..83ce743d6d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts @@ -20,7 +20,7 @@ import { ApplicationsLoadingComponent } from "./risk-insights-loading.component" import { RiskInsightsTabType } from "./risk-insights.component"; @Component({ - selector: "tools-all-activity", + selector: "dirt-all-activity", imports: [ ApplicationsLoadingComponent, SharedModule, @@ -30,7 +30,6 @@ import { RiskInsightsTabType } from "./risk-insights.component"; templateUrl: "./all-activity.component.html", }) export class AllActivityComponent implements OnInit { - protected isLoading$ = this.dataService.isLoading$; organization: Organization | null = null; totalCriticalAppsAtRiskMemberCount = 0; totalCriticalAppsCount = 0; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html index febdb5fa0d..1971b61d51 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html @@ -1,96 +1,102 @@ -
    - -
    -
    - - -

    - {{ "noAppsInOrgTitle" | i18n: organization?.name }} -

    -
    - -
    - - {{ "noAppsInOrgDescription" | i18n }} - - {{ "learnMore" | i18n }} +@if (dataService.isLoading$ | async) { + +} @else { + @let drawerDetails = dataService.drawerDetails$ | async; + @if (!dataSource.data.length) { +
    + + +

    + {{ + "noAppsInOrgTitle" + | i18n: (dataService.organizationDetails$ | async)?.organizationName || "" + }} +

    +
    + +
    + + {{ "noAppsInOrgDescription" | i18n }} + + {{ "learnMore" | i18n }} +
    +
    + + + +
    +
    + } @else { +
    +

    {{ "allApplications" | i18n }}

    +
    + +
    - - - - - -
    -
    -

    {{ "allApplications" | i18n }}

    - @if (dataService.drawerDetails$ | async; as drawerDetails) { -
    - - -
    -
    - - -
    + + {{ "markAppAsCritical" | i18n }} + +
    - + +
    } -
    +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts index 3b7490dbc1..bc04884c79 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts @@ -2,33 +2,17 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { combineLatest, debounceTime, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; +import { debounceTime } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; import { - AllActivitiesService, - CriticalAppsService, + ApplicationHealthReportDetailEnriched, 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, -} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; -import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { OrganizationId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { IconButtonModule, NoItemsModule, @@ -45,7 +29,7 @@ import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.compo import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; @Component({ - selector: "tools-all-applications", + selector: "dirt-all-applications", templateUrl: "./all-applications.component.html", imports: [ ApplicationsLoadingComponent, @@ -60,97 +44,44 @@ import { ApplicationsLoadingComponent } from "./risk-insights-loading.component" ], }) export class AllApplicationsComponent implements OnInit { - protected dataSource = - new TableDataSource(); + protected dataSource = new TableDataSource(); protected selectedUrls: Set = new Set(); protected searchControl = new FormControl("", { nonNullable: true }); - protected loading = true; protected organization = new Organization(); noItemsIcon = Security; protected markingAsCritical = false; protected applicationSummary: OrganizationReportSummary = createNewSummaryData(); destroyRef = inject(DestroyRef); - isLoading$: Observable = of(false); - - async ngOnInit() { - const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - - if (organizationId) { - const organization$ = this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(organizationId)); - - combineLatest([ - this.dataService.applications$, - this.criticalAppsService.getAppsListForOrg(organizationId as OrganizationId), - organization$, - ]) - .pipe( - takeUntilDestroyed(this.destroyRef), - map(([applications, criticalApps, organization]) => { - if (applications && applications.length === 0 && criticalApps && criticalApps) { - const criticalUrls = criticalApps.map((ca) => ca.uri); - const data = applications?.map((app) => ({ - ...app, - isMarkedAsCritical: criticalUrls.includes(app.applicationName), - })) as LEGACY_ApplicationHealthReportDetailWithCriticalFlag[]; - return { data, organization }; - } - - return { data: applications, organization }; - }), - switchMap(async ({ data, organization }) => { - if (data && organization) { - const dataWithCiphers = await this.reportService.identifyCiphers( - data, - organization.id as OrganizationId, - ); - - return { - data: dataWithCiphers, - organization, - }; - } - - return { data: [], organization }; - }), - ) - .subscribe(({ data, organization }) => { - if (data) { - this.dataSource.data = data; - this.applicationSummary = this.reportService.generateApplicationsSummary(data); - this.allActivitiesService.setAllAppsReportSummary(this.applicationSummary); - } - if (organization) { - this.organization = organization; - } - }); - - this.isLoading$ = this.dataService.isLoading$; - } - } constructor( - protected cipherService: CipherService, protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, protected toastService: ToastService, - protected configService: ConfigService, protected dataService: RiskInsightsDataService, - protected organizationService: OrganizationService, - protected reportService: RiskInsightsReportService, - private accountService: AccountService, - protected criticalAppsService: CriticalAppsService, - protected riskInsightsEncryptionService: RiskInsightsEncryptionService, - protected allActivitiesService: AllActivitiesService, + // protected allActivitiesService: AllActivitiesService, ) { this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) .subscribe((v) => (this.dataSource.filter = v)); } + async ngOnInit() { + this.dataService.reportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (report) => { + this.applicationSummary = report?.summaryData ?? createNewSummaryData(); + this.dataSource.data = report?.reportData ?? []; + }, + error: () => { + this.dataSource.data = []; + }, + }); + + // TODO + // this.applicationSummary = this.reportService.generateApplicationsSummary(data); + // this.allActivitiesService.setAllAppsReportSummary(this.applicationSummary); + } + goToCreateNewLoginItem = async () => { // TODO: implement this.toastService.showToast({ @@ -167,41 +98,31 @@ export class AllApplicationsComponent implements OnInit { markAppsAsCritical = async () => { this.markingAsCritical = true; - try { - await this.criticalAppsService.setCriticalApps( - this.organization.id as OrganizationId, - Array.from(this.selectedUrls), - ); - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"), + this.dataService + .saveCriticalApplications(Array.from(this.selectedUrls)) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"), + }); + this.selectedUrls.clear(); + this.markingAsCritical = false; + }, + error: () => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("applicationsMarkedAsCriticalFail"), + }); + }, }); - } finally { - this.selectedUrls.clear(); - this.markingAsCritical = false; - } }; showAppAtRiskMembers = async (applicationName: string) => { - const info = { - members: - this.dataSource.data.find((app) => app.applicationName === applicationName) - ?.atRiskMemberDetails ?? [], - applicationName, - }; - this.dataService.setDrawerForAppAtRiskMembers(info, applicationName); - }; - - showOrgAtRiskMembers = async (invokerId: string) => { - const dialogData = this.reportService.generateAtRiskMemberList(this.dataSource.data); - this.dataService.setDrawerForOrgAtRiskMembers(dialogData, invokerId); - }; - - showOrgAtRiskApps = async (invokerId: string) => { - const data = this.reportService.generateAtRiskApplicationList(this.dataSource.data); - this.dataService.setDrawerForOrgAtRiskApps(data, invokerId); + await this.dataService.setDrawerForAppAtRiskMembers(applicationName); }; onCheckboxChange = (applicationName: string, event: Event) => { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts index 01f3b8fb49..e34b13176e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; +import { ApplicationHealthReportDetailEnriched } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { MenuModule, TableDataSource, TableModule } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; @@ -14,7 +14,7 @@ import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pip }) export class AppTableRowScrollableComponent { @Input() - dataSource!: TableDataSource; + dataSource!: TableDataSource; @Input() showRowMenuForCriticalApps: boolean = false; @Input() showRowCheckBox: boolean = false; @Input() selectedUrls: Set = new Set(); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html index ed2a6b9652..cfcdf3a184 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html @@ -49,7 +49,7 @@ type="button" class="tw-flex-1" tabindex="0" - (click)="showOrgAtRiskMembers('criticalAppsAtRiskMembers')" + (click)="dataService.setDrawerForOrgAtRiskMembers('criticalAppsAtRiskMembers')" > } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts index 7848d37ea9..6a0ac5c288 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts @@ -4,23 +4,15 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, debounceTime, firstValueFrom, map, switchMap } from "rxjs"; +import { debounceTime, EMPTY, map, switchMap } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; import { - AllActivitiesService, - CriticalAppsService, + ApplicationHealthReportDetailEnriched, RiskInsightsDataService, - RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { - LEGACY_ApplicationHealthReportDetailWithCriticalFlag, - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, -} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; +import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; @@ -37,7 +29,7 @@ import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.compo import { RiskInsightsTabType } from "./risk-insights.component"; @Component({ - selector: "tools-critical-applications", + selector: "dirt-critical-applications", templateUrl: "./critical-applications.component.html", imports: [ CardComponent, @@ -51,60 +43,57 @@ import { RiskInsightsTabType } from "./risk-insights.component"; providers: [DefaultAdminTaskService], }) export class CriticalApplicationsComponent implements OnInit { - protected dataSource = - new TableDataSource(); - protected selectedIds: Set = new Set(); - protected searchControl = new FormControl("", { nonNullable: true }); private destroyRef = inject(DestroyRef); protected loading = false; + protected enableRequestPasswordChange = false; protected organizationId: OrganizationId; - protected applicationSummary = {} as OrganizationReportSummary; noItemsIcon = Security; - enableRequestPasswordChange = false; + + protected dataSource = new TableDataSource(); + protected applicationSummary = {} as OrganizationReportSummary; + + protected selectedIds: Set = new Set(); + protected searchControl = new FormControl("", { nonNullable: true }); + + constructor( + protected activatedRoute: ActivatedRoute, + protected router: Router, + protected toastService: ToastService, + protected dataService: RiskInsightsDataService, + protected i18nService: I18nService, + private adminTaskService: DefaultAdminTaskService, + ) { + this.searchControl.valueChanges + .pipe(debounceTime(200), takeUntilDestroyed()) + .subscribe((v) => (this.dataSource.filter = v)); + } async ngOnInit() { - this.organizationId = this.activatedRoute.snapshot.paramMap.get( - "organizationId", - ) as OrganizationId; - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - this.criticalAppsService.loadOrganizationContext(this.organizationId as OrganizationId, userId); - - if (this.organizationId) { - combineLatest([ - this.dataService.applications$, - this.criticalAppsService.getAppsListForOrg(this.organizationId as OrganizationId), - ]) - .pipe( - takeUntilDestroyed(this.destroyRef), - map(([applications, criticalApps]) => { - const criticalUrls = criticalApps.map((ca) => ca.uri); - const data = applications?.map((app) => ({ - ...app, - isMarkedAsCritical: criticalUrls.includes(app.applicationName), - })) as LEGACY_ApplicationHealthReportDetailWithCriticalFlag[]; - return data?.filter((app) => app.isMarkedAsCritical); - }), - switchMap(async (data) => { - if (data) { - const dataWithCiphers = await this.reportService.identifyCiphers( - data, - this.organizationId, - ); - return dataWithCiphers; - } - return null; - }), - ) - .subscribe((applications) => { - if (applications) { - this.dataSource.data = applications; - this.applicationSummary = this.reportService.generateApplicationsSummary(applications); - this.enableRequestPasswordChange = this.applicationSummary.totalAtRiskMemberCount > 0; - this.allActivitiesService.setCriticalAppsReportSummary(this.applicationSummary); - this.allActivitiesService.setAllAppsReportDetails(applications); + this.dataService.criticalReportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (criticalReport) => { + this.dataSource.data = criticalReport?.reportData ?? []; + this.applicationSummary = criticalReport?.summaryData ?? createNewSummaryData(); + this.enableRequestPasswordChange = criticalReport?.summaryData?.totalAtRiskMemberCount > 0; + }, + error: () => { + this.dataSource.data = []; + this.applicationSummary = createNewSummaryData(); + this.enableRequestPasswordChange = false; + }, + }); + this.activatedRoute.paramMap + .pipe( + takeUntilDestroyed(this.destroyRef), + map((params) => params.get("organizationId")), + switchMap(async (orgId) => { + if (orgId) { + this.organizationId = orgId as OrganizationId; + } else { + return EMPTY; } - }); - } + }), + ) + .subscribe(); } goToAllAppsTab = async () => { @@ -117,26 +106,25 @@ export class CriticalApplicationsComponent implements OnInit { ); }; - unmarkAsCritical = async (hostname: string) => { - try { - await this.criticalAppsService.dropCriticalApp( - this.organizationId as OrganizationId, - hostname, - ); - } catch { - this.toastService.showToast({ - message: this.i18nService.t("unexpectedError"), - variant: "error", - title: this.i18nService.t("error"), + removeCriticalApplication = async (hostname: string) => { + this.dataService + .removeCriticalApplication(hostname) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showToast({ + message: this.i18nService.t("criticalApplicationUnmarkedSuccessfully"), + variant: "success", + }); + }, + error: () => { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + }, }); - return; - } - - this.toastService.showToast({ - message: this.i18nService.t("criticalApplicationUnmarkedSuccessfully"), - variant: "success", - }); - this.dataSource.data = this.dataSource.data.filter((app) => app.applicationName !== hostname); }; async requestPasswordChange() { @@ -167,42 +155,7 @@ export class CriticalApplicationsComponent implements OnInit { }); } } - - constructor( - protected activatedRoute: ActivatedRoute, - protected router: Router, - protected toastService: ToastService, - protected dataService: RiskInsightsDataService, - protected criticalAppsService: CriticalAppsService, - protected reportService: RiskInsightsReportService, - protected i18nService: I18nService, - private configService: ConfigService, - private adminTaskService: DefaultAdminTaskService, - private accountService: AccountService, - private allActivitiesService: AllActivitiesService, - ) { - this.searchControl.valueChanges - .pipe(debounceTime(200), takeUntilDestroyed()) - .subscribe((v) => (this.dataSource.filter = v)); - } - showAppAtRiskMembers = async (applicationName: string) => { - const data = { - members: - this.dataSource.data.find((app) => app.applicationName === applicationName) - ?.atRiskMemberDetails ?? [], - applicationName, - }; - this.dataService.setDrawerForAppAtRiskMembers(data, applicationName); - }; - - showOrgAtRiskMembers = async (invokerId: string) => { - const data = this.reportService.generateAtRiskMemberList(this.dataSource.data); - this.dataService.setDrawerForOrgAtRiskMembers(data, invokerId); - }; - - showOrgAtRiskApps = async (invokerId: string) => { - const data = this.reportService.generateAtRiskApplicationList(this.dataSource.data); - this.dataService.setDrawerForOrgAtRiskApps(data, invokerId); + await this.dataService.setDrawerForAppAtRiskMembers(applicationName); }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts index af61c9a35c..1d18ca3a03 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts @@ -4,7 +4,7 @@ import { Component } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @Component({ - selector: "tools-risk-insights-loading", + selector: "dirt-risk-insights-loading", imports: [CommonModule, JslibModule], templateUrl: "./risk-insights-loading.component.html", }) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 50af2c9e9a..49ccfb73c5 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -4,19 +4,23 @@ {{ "reviewAtRiskPasswords" | i18n }}
    - {{ - "dataLastUpdated" | i18n: (dataLastUpdated$ | async | date: "MMMM d, y 'at' h:mm a") - }} - + @if (dataLastUpdated) { + {{ + "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a") + }} + } @else { + {{ "noReportRan" | i18n }} + } + @let isRunningReport = dataService.isRunningReport$ | async; + @@ -38,18 +42,21 @@ @if (isRiskInsightsActivityTabFeatureEnabled) { - + } - + - {{ "criticalApplicationsWithCount" | i18n: (criticalApps$ | async)?.length ?? 0 }} + {{ + "criticalApplicationsWithCount" + | i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 + }} - + @@ -69,7 +76,9 @@ }}
    -
    {{ "email" | i18n }}
    +
    + {{ "email" | i18n }} +
    {{ "atRiskPasswords" | i18n }}
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 208ba59fb9..308cc351dc 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -2,21 +2,12 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { EMPTY, firstValueFrom, Observable } from "rxjs"; +import { EMPTY } from "rxjs"; import { map, switchMap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { - CriticalAppsService, - RiskInsightsDataService, -} from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { PasswordHealthReportApplicationsResponse } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/api-models.types"; -import { - ApplicationHealthReportDetail, - DrawerType, -} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -67,19 +58,13 @@ export class RiskInsightsComponent implements OnInit { tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps; isRiskInsightsActivityTabFeatureEnabled: boolean = false; - dataLastUpdated: Date = new Date(); - - criticalApps$: Observable = new Observable(); - appsCount: number = 0; - criticalAppsCount: number = 0; - notifiedMembersCount: number = 0; + // Leaving this commented because it's not used but seems important + // notifiedMembersCount: number = 0; private organizationId: OrganizationId = "" as OrganizationId; - isLoading$: Observable = new Observable(); - isRefreshing$: Observable = new Observable(); - dataLastUpdated$: Observable = new Observable(); + dataLastUpdated: Date | null = null; refetching: boolean = false; constructor( @@ -87,8 +72,6 @@ export class RiskInsightsComponent implements OnInit { private router: Router, private configService: ConfigService, protected dataService: RiskInsightsDataService, - private criticalAppsService: CriticalAppsService, - private accountService: AccountService, ) { this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps; @@ -104,39 +87,29 @@ export class RiskInsightsComponent implements OnInit { } async ngOnInit() { - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - this.route.paramMap .pipe( takeUntilDestroyed(this.destroyRef), map((params) => params.get("organizationId")), - switchMap((orgId) => { + switchMap(async (orgId) => { if (orgId) { + // Initialize Data Service + await this.dataService.initializeForOrganization(orgId as OrganizationId); + this.organizationId = orgId as OrganizationId; - this.dataService.fetchApplicationsReport(this.organizationId); - this.isLoading$ = this.dataService.isLoading$; - this.isRefreshing$ = this.dataService.isRefreshing$; - this.dataLastUpdated$ = this.dataService.dataLastUpdated$; - return this.dataService.applications$; } else { return EMPTY; } }), ) - .subscribe({ - next: (applications: ApplicationHealthReportDetail[] | null) => { - if (applications) { - this.appsCount = applications.length; - } + .subscribe(); - this.criticalAppsService.loadOrganizationContext( - this.organizationId as OrganizationId, - userId, - ); - this.criticalApps$ = this.criticalAppsService.getAppsListForOrg( - this.organizationId as OrganizationId, - ); - }, + // Subscribe to report result details + this.dataService.reportResults$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((report) => { + this.appsCount = report?.reportData.length ?? 0; + this.dataLastUpdated = report?.creationDate ?? null; }); // Subscribe to drawer state changes @@ -156,7 +129,7 @@ export class RiskInsightsComponent implements OnInit { */ refreshData(): void { if (this.organizationId) { - this.dataService.refreshApplicationsReport(this.organizationId); + this.dataService.triggerReport(); } }