From 9545197b41cee4fff902ef048f382fe90c9f3598 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:12:53 -0500 Subject: [PATCH] Risk insight service updates, initial persistence updates --- .../critical-apps-api.service.spec.ts | 4 +- .../services/critical-apps-api.service.ts | 2 +- .../services/critical-apps.service.spec.ts | 4 +- .../services/critical-apps.service.ts | 55 +- .../services/password-health.service.spec.ts | 10 - .../services/password-health.service.ts | 249 +++--- .../risk-insights-api.service.spec.ts | 16 +- .../services/risk-insights-api.service.ts | 8 +- .../services/risk-insights-data.service.ts | 357 +++++++-- .../risk-insights-encryption.service.ts | 25 +- .../risk-insights-report.service.spec.ts | 374 +++------ .../services/risk-insights-report.service.ts | 750 +++++++----------- .../access-intelligence.module.ts | 47 +- .../all-applications.component.html | 162 ++-- .../all-applications.component.ts | 158 ++-- .../app-table-row-scrollable.component.html | 25 +- .../app-table-row-scrollable.component.ts | 6 +- .../critical-applications.component.html | 105 +-- .../critical-applications.component.ts | 77 +- .../risk-insights.component.html | 203 ++--- .../risk-insights.component.ts | 101 +-- 21 files changed, 1252 insertions(+), 1486 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.spec.ts index 5ed88c4cac9..f53bf92c47f 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.spec.ts @@ -5,10 +5,10 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { PasswordHealthReportApplicationDropRequest, - PasswordHealthReportApplicationId, PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/password-health"; +} from "../models/api-models.types"; +import { PasswordHealthReportApplicationId } from "../models/report-models"; import { CriticalAppsApiService } from "./critical-apps-api.service"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.ts index c02a3686dfd..29d2364f302 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.ts @@ -7,7 +7,7 @@ import { PasswordHealthReportApplicationDropRequest, PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/password-health"; +} from "../models/api-models.types"; export class CriticalAppsApiService { constructor(private apiService: ApiService) {} 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 b3e8e11f4f7..289b4c4803f 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 @@ -13,10 +13,10 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; import { - PasswordHealthReportApplicationId, PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/password-health"; +} from "../models/api-models.types"; +import { PasswordHealthReportApplicationId } from "../models/report-models"; import { CriticalAppsApiService } from "./critical-apps-api.service"; import { CriticalAppsService } from "./critical-apps.service"; 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 6ad1cb71051..4a7b8177033 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 @@ -4,12 +4,9 @@ import { firstValueFrom, forkJoin, from, - map, Observable, of, - Subject, switchMap, - takeUntil, zip, } from "rxjs"; @@ -22,7 +19,7 @@ import { KeyService } from "@bitwarden/key-management"; import { PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/password-health"; +} from "../models/api-models.types"; import { CriticalAppsApiService } from "./critical-apps-api.service"; @@ -30,16 +27,14 @@ import { CriticalAppsApiService } from "./critical-apps-api.service"; * Encrypts and saves data for a given organization */ export class CriticalAppsService { - private orgId = new BehaviorSubject(null); - private criticalAppsList = new BehaviorSubject([]); - private teardown = new Subject(); + // The organization ID of the organization the user is currently viewing + private organizationId = new BehaviorSubject(null); + organizationId$ = this.organizationId.asObservable(); - private fetchOrg$ = this.orgId - .pipe( - switchMap((orgId) => this.retrieveCriticalApps(orgId)), - takeUntil(this.teardown), - ) - .subscribe((apps) => this.criticalAppsList.next(apps)); + private criticalAppsListSubject = new BehaviorSubject( + [], + ); + criticalAppsList$ = this.criticalAppsListSubject.asObservable(); constructor( private keyService: KeyService, @@ -47,16 +42,23 @@ export class CriticalAppsService { private criticalAppsApiService: CriticalAppsApiService, ) {} - // Get a list of critical apps for a given organization - getAppsListForOrg(orgId: string): Observable { - return this.criticalAppsList - .asObservable() - .pipe(map((apps) => apps.filter((app) => app.organizationId === orgId))); + async initialize(organizationId: OrganizationId) { + this.organizationId.next(organizationId); + if (organizationId) { + this.retrieveCriticalApps(organizationId).subscribe({ + next: (result) => { + this.criticalAppsListSubject.next(result); + }, + error: (error: unknown) => { + throw error; + }, + }); + } } // Reset the critical apps list setAppsInListForOrg(apps: PasswordHealthReportApplicationsResponse[]) { - this.criticalAppsList.next(apps); + this.criticalAppsListSubject.next(apps); } // Save the selected critical apps for a given organization @@ -79,7 +81,7 @@ export class CriticalAppsService { ); // add the new entries to the criticalAppsList - const updatedList = [...this.criticalAppsList.value]; + const updatedList = [...this.criticalAppsListSubject.value]; for (const responseItem of dbResponse) { const decryptedUrl = await this.encryptService.decryptString( new EncString(responseItem.uri), @@ -93,18 +95,19 @@ export class CriticalAppsService { } as PasswordHealthReportApplicationsResponse); } } - this.criticalAppsList.next(updatedList); + + this.criticalAppsListSubject.next(updatedList); } // Get the critical apps for a given organization setOrganizationId(orgId: OrganizationId) { - this.orgId.next(orgId); + this.organizationId.next(orgId); } // Drop a critical app for a given organization // Only one app may be dropped at a time async dropCriticalApp(orgId: OrganizationId, selectedUrl: string) { - const app = this.criticalAppsList.value.find( + const app = this.criticalAppsListSubject.value.find( (f) => f.organizationId === orgId && f.uri === selectedUrl, ); @@ -117,7 +120,9 @@ export class CriticalAppsService { passwordHealthReportApplicationIds: [app.id], }); - this.criticalAppsList.next(this.criticalAppsList.value.filter((f) => f.uri !== selectedUrl)); + this.criticalAppsListSubject.next( + this.criticalAppsListSubject.value.filter((f) => f.uri !== selectedUrl), + ); } private retrieveCriticalApps( @@ -155,7 +160,7 @@ export class CriticalAppsService { } private async filterNewEntries(orgId: OrganizationId, selectedUrls: string[]): Promise { - return await firstValueFrom(this.criticalAppsList).then((criticalApps) => { + return await firstValueFrom(this.criticalAppsListSubject).then((criticalApps) => { const criticalAppsUri = criticalApps .filter((f) => f.organizationId === orgId) .map((f) => f.uri); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts index b81acb09bed..5fbdf136fb9 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts @@ -9,7 +9,6 @@ import { MemberCipherDetailsApiService } from "./member-cipher-details-api.servi import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec"; import { PasswordHealthService } from "./password-health.service"; -// FIXME: Remove password-health report service after PR-15498 completion describe("PasswordHealthService", () => { let service: PasswordHealthService; beforeEach(() => { @@ -53,13 +52,4 @@ describe("PasswordHealthService", () => { it("should be created", () => { expect(service).toBeTruthy(); }); - - it("should initialize properties", () => { - expect(service.reportCiphers).toEqual([]); - expect(service.reportCipherIds).toEqual([]); - expect(service.passwordStrengthMap.size).toBe(0); - expect(service.passwordUseMap.size).toBe(0); - expect(service.exposedPasswordMap.size).toBe(0); - expect(service.totalMembersMap.size).toBe(0); - }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts index f2f9a9868f7..a8f1aaa7657 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts @@ -1,186 +1,137 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Inject, Injectable } from "@angular/core"; +import { filter, from, map, mergeMap, Observable, toArray } from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; -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 { BadgeVariant } from "@bitwarden/components"; -import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; +import { + ExposedPasswordDetail, + WeakPasswordDetail, + WeakPasswordScore, +} from "../models/password-health"; -@Injectable() export class PasswordHealthService { - reportCiphers: CipherView[] = []; - - reportCipherIds: string[] = []; - - passwordStrengthMap = new Map(); - - passwordUseMap = new Map(); - - exposedPasswordMap = new Map(); - - totalMembersMap = new Map(); - constructor( private passwordStrengthService: PasswordStrengthServiceAbstraction, private auditService: AuditService, - private cipherService: CipherService, - private memberCipherDetailsApiService: MemberCipherDetailsApiService, - @Inject("organizationId") private organizationId: string, ) {} - async generateReport() { - const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organizationId); - allCiphers.forEach(async (cipher) => { - this.findWeakPassword(cipher); - this.findReusedPassword(cipher); - await this.findExposedPassword(cipher); - }); - - const memberCipherDetails = await this.memberCipherDetailsApiService.getMemberCipherDetails( - this.organizationId, + /** + * Finds exposed passwords in a list of ciphers. + * + * @param ciphers The list of ciphers to check. + * @returns An observable that emits an array of ExposedPasswordDetail. + */ + auditPasswordLeaks$(ciphers: CipherView[]): Observable { + return from(ciphers).pipe( + filter((cipher) => this.isValidCipher(cipher)), + mergeMap((cipher) => + this.auditService + .passwordLeaked(cipher.login.password) + .then((exposedCount) => ({ cipher, exposedCount })), + ), + filter(({ exposedCount }) => exposedCount > 0), + map(({ cipher, exposedCount }) => ({ + exposedXTimes: exposedCount, + cipherId: cipher.id, + })), + toArray(), ); - - memberCipherDetails.forEach((user) => { - user.cipherIds.forEach((cipherId: string) => { - if (this.totalMembersMap.has(cipherId)) { - this.totalMembersMap.set(cipherId, (this.totalMembersMap.get(cipherId) || 0) + 1); - } else { - this.totalMembersMap.set(cipherId, 1); - } - }); - }); } - async findExposedPassword(cipher: CipherView) { - const { type, login, isDeleted, viewPassword, id } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - !viewPassword - ) { - return; - } + /** + * Extracts username parts from the cipher's username. + * This is used to help determine password strength. + * + * @param cipherUsername The username from the cipher. + * @returns An array of username parts. + */ + extractUsernameParts(cipherUsername: string) { + const atPosition = cipherUsername.indexOf("@"); + const userNameToProcess = + atPosition > -1 ? cipherUsername.substring(0, atPosition) : cipherUsername; - const exposedCount = await this.auditService.passwordLeaked(login.password); - if (exposedCount > 0) { - this.exposedPasswordMap.set(id, exposedCount); - this.checkForExistingCipher(cipher); - } + return userNameToProcess + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/) + .filter((i) => i.length >= 3); } - findReusedPassword(cipher: CipherView) { - const { type, login, isDeleted, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - !viewPassword - ) { - return; + /** + * Checks if the cipher has a weak password based on the password strength score. + * + * @param cipher + * @returns + */ + findWeakPasswordDetails(cipher: CipherView): WeakPasswordDetail | null { + // Validate the cipher + if (!this.isValidCipher(cipher)) { + return null; } - if (this.passwordUseMap.has(login.password)) { - this.passwordUseMap.set(login.password, (this.passwordUseMap.get(login.password) || 0) + 1); - } else { - this.passwordUseMap.set(login.password, 1); - } + // Check the username + const userInput = this.isUserNameNotEmpty(cipher) + ? this.extractUsernameParts(cipher.login.username) + : null; - this.checkForExistingCipher(cipher); - } - - findWeakPassword(cipher: CipherView): void { - const { type, login, isDeleted, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - !viewPassword - ) { - return; - } - - const hasUserName = this.isUserNameNotEmpty(cipher); - let userInput: string[] = []; - if (hasUserName) { - const atPosition = login.username.indexOf("@"); - if (atPosition > -1) { - userInput = userInput - .concat( - login.username - .substring(0, atPosition) - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/), - ) - .filter((i) => i.length >= 3); - } else { - userInput = login.username - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/) - .filter((i) => i.length >= 3); - } - } const { score } = this.passwordStrengthService.getPasswordStrength( - login.password, + cipher.login.password, null, - userInput.length > 0 ? userInput : null, + userInput, ); + // If a score is not found or a score is less than 3, it's weak if (score != null && score <= 2) { - this.passwordStrengthMap.set(cipher.id, this.scoreKey(score)); - this.checkForExistingCipher(cipher); + return { score: score, detailValue: this.getPasswordScoreInfo(score) }; + } + return null; + } + + /** + * Gets the password score information based on the score. + * + * @param score + * @returns An object containing the label and badge variant for the password score. + */ + getPasswordScoreInfo(score: number): WeakPasswordScore { + switch (score) { + case 4: + return { label: "strong", badgeVariant: "success" }; + case 3: + return { label: "good", badgeVariant: "primary" }; + case 2: + return { label: "weak", badgeVariant: "warning" }; + default: + return { label: "veryWeak", badgeVariant: "danger" }; } } - private isUserNameNotEmpty(c: CipherView): boolean { + /** + * Checks if the username on the cipher is not empty. + */ + isUserNameNotEmpty(c: CipherView): boolean { return !Utils.isNullOrWhitespace(c.login.username); } - private scoreKey(score: number): [string, BadgeVariant] { - switch (score) { - case 4: - return ["strong", "success"]; - case 3: - return ["good", "primary"]; - case 2: - return ["weak", "warning"]; - default: - return ["veryWeak", "danger"]; + /** + * Validates that the cipher is a login item, has a password + * is not deleted, and the user can view the password + * @param c the input cipher + */ + isValidCipher(c: CipherView): boolean { + const { type, login, isDeleted, viewPassword } = c; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + !viewPassword + ) { + return false; } - } - - checkForExistingCipher(ciph: CipherView) { - if (!this.reportCipherIds.includes(ciph.id)) { - this.reportCipherIds.push(ciph.id); - this.reportCiphers.push(ciph); - } - } - - groupCiphersByLoginUri(): CipherView[] { - const cipherViews: CipherView[] = []; - const cipherUris: string[] = []; - const ciphers = this.reportCiphers; - - ciphers.forEach((ciph) => { - const uris = ciph.login?.uris ?? []; - uris.map((u: { uri: string }) => { - const uri = Utils.getHostname(u.uri).replace("www.", ""); - cipherUris.push(uri); - cipherViews.push({ ...ciph, hostURI: uri } as CipherView & { hostURI: string }); - }); - }); - - return cipherViews; + return true; } } 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 3b00560d4a1..c1596f17a07 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 @@ -4,7 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { makeEncString } from "@bitwarden/common/spec"; import { OrganizationId } from "@bitwarden/common/types/guid"; -import { SaveRiskInsightsReportRequest } from "../models/password-health"; +import { SaveRiskInsightsReportRequest } from "../models/api-models.types"; import { RiskInsightsApiService } from "./risk-insights-api.service"; @@ -47,7 +47,7 @@ describe("RiskInsightsApiService", () => { it("should call apiService.send with correct parameters for saveRiskInsightsReport", (done) => { apiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportResponse)); - service.saveRiskInsightsReport(saveRiskInsightsReportRequest).subscribe((result) => { + service.saveRiskInsightsReport$(saveRiskInsightsReportRequest).subscribe((result) => { expect(result).toEqual(saveRiskInsightsReportResponse); expect(apiService.send).toHaveBeenCalledWith( "POST", @@ -63,7 +63,7 @@ describe("RiskInsightsApiService", () => { it("should call apiService.send with correct parameters and return the response for saveRiskInsightsReport ", (done) => { apiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportResponse)); - service.saveRiskInsightsReport(saveRiskInsightsReportRequest).subscribe((result) => { + service.saveRiskInsightsReport$(saveRiskInsightsReportRequest).subscribe((result) => { expect(result).toEqual(saveRiskInsightsReportResponse); expect(apiService.send).toHaveBeenCalledWith( "POST", @@ -80,7 +80,7 @@ describe("RiskInsightsApiService", () => { const error = { statusCode: 500, message: "Internal Server Error" }; apiService.send.mockReturnValue(Promise.reject(error)); - service.saveRiskInsightsReport(saveRiskInsightsReportRequest).subscribe({ + service.saveRiskInsightsReport$(saveRiskInsightsReportRequest).subscribe({ next: () => { fail("Expected error to be thrown"); }, @@ -104,7 +104,7 @@ describe("RiskInsightsApiService", () => { const error = new Error("Network error"); apiService.send.mockReturnValue(Promise.reject(error)); - service.saveRiskInsightsReport(saveRiskInsightsReportRequest).subscribe({ + service.saveRiskInsightsReport$(saveRiskInsightsReportRequest).subscribe({ next: () => { fail("Expected error to be thrown"); }, @@ -127,7 +127,7 @@ describe("RiskInsightsApiService", () => { it("should call apiService.send with correct parameters and return the response for getRiskInsightsReport ", (done) => { apiService.send.mockReturnValue(Promise.resolve(getRiskInsightsReportResponse)); - service.getRiskInsightsReport(orgId).subscribe((result) => { + service.getRiskInsightsReport$(orgId).subscribe((result) => { expect(result).toEqual(getRiskInsightsReportResponse); expect(apiService.send).toHaveBeenCalledWith( "GET", @@ -144,7 +144,7 @@ describe("RiskInsightsApiService", () => { const error = { statusCode: 404 }; apiService.send.mockReturnValue(Promise.reject(error)); - service.getRiskInsightsReport(orgId).subscribe((result) => { + service.getRiskInsightsReport$(orgId).subscribe((result) => { expect(result).toBeNull(); done(); }); @@ -154,7 +154,7 @@ describe("RiskInsightsApiService", () => { const error = { statusCode: 500, message: "Server error" }; apiService.send.mockReturnValue(Promise.reject(error)); - service.getRiskInsightsReport(orgId).subscribe({ + service.getRiskInsightsReport$(orgId).subscribe({ next: () => { // Should not reach here fail("Expected error to be thrown"); 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 07b5062eec5..f007e23fdd8 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 @@ -1,3 +1,4 @@ +import { Injectable } from "@angular/core"; import { from, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -7,12 +8,13 @@ import { GetRiskInsightsReportResponse, SaveRiskInsightsReportRequest, SaveRiskInsightsReportResponse, -} from "../models/password-health"; +} from "../models/api-models.types"; +@Injectable() export class RiskInsightsApiService { constructor(private apiService: ApiService) {} - saveRiskInsightsReport( + saveRiskInsightsReport$( request: SaveRiskInsightsReportRequest, ): Observable { const dbResponse = this.apiService.send( @@ -26,7 +28,7 @@ export class RiskInsightsApiService { return from(dbResponse as Promise); } - getRiskInsightsReport(orgId: OrganizationId): Observable { + getRiskInsightsReport$(orgId: OrganizationId): Observable { const dbResponse = this.apiService .send("GET", `/reports/organization-reports/latest/${orgId.toString()}`, null, true, true) .catch((error: any): any => { 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 5e6dbcd54b5..779351aaa85 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,127 +1,356 @@ -import { BehaviorSubject } from "rxjs"; -import { finalize } from "rxjs/operators"; - -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { BehaviorSubject, firstValueFrom, from, Observable, of } from "rxjs"; +import { + distinctUntilChanged, + exhaustMap, + filter, + finalize, + map, + switchMap, + tap, + withLatestFrom, +} from "rxjs/operators"; import { + getOrganizationById, + OrganizationService, +} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; + +import { + DrawerDetails, AppAtRiskMembersDialogParams, ApplicationHealthReportDetail, AtRiskApplicationDetail, AtRiskMemberDetail, DrawerType, -} from "../models/password-health"; + ApplicationHealthReportDetailEnriched, + ReportDetailsAndSummary, +} from "../models/report-models"; +import { CriticalAppsService } from "./critical-apps.service"; import { RiskInsightsReportService } from "./risk-insights-report.service"; -export class RiskInsightsDataService { - private applicationsSubject = new BehaviorSubject(null); - applications$ = this.applicationsSubject.asObservable(); +/** + * Service for managing risk insights data, including applications and critical applications. + * Handles logic for drawer management and data fetching. + */ +export class RiskInsightsDataService { + // Current user viewing risk insights + private userIdSubject = new BehaviorSubject(null); + userId$ = this.userIdSubject.asObservable(); + + // Organization the user is currently viewing + private organizationDetailsSubject = new BehaviorSubject<{ + organizationId: OrganizationId; + organizationName: string; + } | null>(null); + organizationDetails$ = this.organizationDetailsSubject.asObservable(); private isLoadingSubject = new BehaviorSubject(false); isLoading$ = this.isLoadingSubject.asObservable(); - private isRefreshingSubject = new BehaviorSubject(false); - isRefreshing$ = this.isRefreshingSubject.asObservable(); + criticalApps$ = this.criticalAppsService.criticalAppsList$; + // ------------------------- Drawer Variables ---------------- + // Drawer variables + private drawerDetailsSubject = new BehaviorSubject({ + open: false, + invokerId: "", + activeDrawerType: DrawerType.None, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }); + drawerDetails$ = this.drawerDetailsSubject.asObservable(); + + // ------------------------- Report Variables ---------------- + // The last run report details + 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 private errorSubject = new BehaviorSubject(null); error$ = this.errorSubject.asObservable(); - private dataLastUpdatedSubject = new BehaviorSubject(null); - dataLastUpdated$ = this.dataLastUpdatedSubject.asObservable(); + constructor( + private accountService: AccountService, + private criticalAppsService: CriticalAppsService, + private organizationService: OrganizationService, + private reportService: RiskInsightsReportService, + ) {} - openDrawer = false; - drawerInvokerId: string = ""; - activeDrawerType: DrawerType = DrawerType.None; - atRiskMemberDetails: AtRiskMemberDetail[] = []; - appAtRiskMembers: AppAtRiskMembersDialogParams | null = null; - atRiskAppDetails: AtRiskApplicationDetail[] | null = null; + async initialize(organizationId: OrganizationId) { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + if (userId) { + this.userIdSubject.next(userId); + } - constructor(private reportService: RiskInsightsReportService) {} + // Fetch organization details + const org = await firstValueFrom( + this.organizationService.organizations$(userId).pipe(getOrganizationById(organizationId)), + ); + if (org) { + this.organizationDetailsSubject.next({ + organizationId: organizationId, + organizationName: org.name, + }); + } + + // Load critical applications + await this.criticalAppsService.initialize(organizationId); + + // Load existing report + this.fetchLastReport(organizationId, userId); + + // Setup new report generation + this._runApplicationsReport().subscribe({ + next: (result) => { + this.isRunningReportSubject.next(false); + }, + error: () => { + this.errorSubject.next("Failed to save report"); + }, + }); + } + + filterReportByCritical( + report$: Observable, + ): Observable { + return report$.pipe( + filter((report) => !!report), + map((r) => ({ + ...r, + data: r.data.filter((application) => application.isMarkedAsCritical), + })), + ); + } + + generateCriticalDetails$( + report$: Observable, + ): Observable { + return this.filterReportByCritical(report$); + } + + enrichWithCriticalMarking$( + applications: ApplicationHealthReportDetail[], + ): Observable { + return this.criticalAppsService.criticalAppsList$.pipe( + map((criticalApps) => { + const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.uri)); + return applications.map((app) => ({ + ...app, + isMarkedAsCritical: criticalApplicationNames.has(app.applicationName), + })) as ApplicationHealthReportDetailEnriched[]; + }), + ); + } + + enrichReportData$( + applications: ApplicationHealthReportDetail[], + ): Observable { + return of(applications).pipe( + withLatestFrom(this.organizationDetails$, this.criticalAppsService.criticalAppsList$), + switchMap(async ([apps, orgDetails, criticalApps]) => { + // Get ciphers for application + const cipherMap = await this.reportService.getApplicationCipherMap( + apps, + orgDetails.organizationId, + ); + + // Find critical apps + const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.uri)); + + // Update application to be enriched type + return apps.map((app) => ({ + ...app, + ciphers: cipherMap.get(app.applicationName) || [], + isMarkedAsCritical: criticalApplicationNames.has(app.applicationName), + })) as ApplicationHealthReportDetailEnriched[]; + }), + ); + } + + getCriticalReport$(report$: Observable) { + const filteredReports$ = this.filterReportByCritical(report$); + return this.generateCriticalDetails$(filteredReports$); + } /** * Fetches the applications report and updates the applicationsSubject. * @param organizationId The ID of the organization. */ - fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void { - if (isRefresh) { - this.isRefreshingSubject.next(true); - } else { - this.isLoadingSubject.next(true); - } + fetchLastReport(organizationId: OrganizationId, userId: UserId): void { + this.isLoadingSubject.next(true); + this.reportService - .generateApplicationsReport$(organizationId) + .getRiskInsightsReport$(organizationId, userId) .pipe( + switchMap((report) => { + return this.enrichReportData$(report.data).pipe( + map((enrichedReport) => ({ + data: enrichedReport, + summary: report.summary, + })), + ); + }), finalize(() => { this.isLoadingSubject.next(false); - this.isRefreshingSubject.next(false); - this.dataLastUpdatedSubject.next(new Date()); }), ) .subscribe({ - next: (reports: ApplicationHealthReportDetail[]) => { - this.applicationsSubject.next(reports); + next: ({ data, summary }) => { + this.reportResultsSubject.next({ + data, + summary, + dateCreated: new Date(), + }); this.errorSubject.next(null); + this.isLoadingSubject.next(false); }, error: () => { - this.applicationsSubject.next([]); + this.errorSubject.next("Failed to fetch report"); + this.reportResultsSubject.next(null); + this.isLoadingSubject.next(false); }, }); } - refreshApplicationsReport(organizationId: OrganizationId): void { - this.fetchApplicationsReport(organizationId, true); + /** Trigger generating a report based on the current applications */ + triggerReport(): void { + this.isRunningReportSubject.next(true); } - isActiveDrawerType = (drawerType: DrawerType): boolean => { - return this.activeDrawerType === drawerType; + private _runApplicationsReport() { + return this.isRunningReport$.pipe( + distinctUntilChanged(), + filter((isRunning) => isRunning), + withLatestFrom(this.organizationDetails$, this.userId$), + exhaustMap(([_, { organizationId }, userId]) => { + if (!organizationId || !userId) { + return; + } + + // Generate the report + return this.reportService.generateApplicationsReport$(organizationId).pipe( + map((data) => ({ + data, + summary: this.reportService.generateApplicationsSummary(data), + })), + switchMap(({ data, summary }) => + this.enrichReportData$(data).pipe( + map((enrichedData) => ({ data: enrichedData, summary })), + ), + ), + tap(({ data, summary }) => { + this.reportResultsSubject.next({ data, summary, dateCreated: new Date() }); + this.errorSubject.next(null); + }), + switchMap(({ data, summary }) => { + // Just returns ID + return this.reportService.saveReport$(data, summary, { organizationId, userId }); + }), + ); + }), + ); + } + + // ------------------------- Drawer functions ----------------------------- + + isActiveDrawerType$ = (drawerType: DrawerType): Observable => { + return this.drawerDetails$.pipe(map((details) => details.activeDrawerType === drawerType)); + }; + isActiveDrawerType = (drawerType: DrawerType): Observable => { + return this.drawerDetails$.pipe(map((details) => details.activeDrawerType === drawerType)); + }; + + isDrawerOpenForInvoker$ = (applicationName: string) => { + return this.drawerDetails$.pipe(map((details) => details.invokerId === applicationName)); + }; + isDrawerOpenForInvoker = (applicationName: string) => { + return this.drawerDetails$.pipe(map((details) => details.invokerId === applicationName)); + }; + + closeDrawer = (): void => { + this.drawerDetailsSubject.next({ + open: false, + invokerId: "", + activeDrawerType: DrawerType.None, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }); }; setDrawerForOrgAtRiskMembers = ( atRiskMemberDetails: AtRiskMemberDetail[], invokerId: string = "", ): void => { - this.resetDrawer(DrawerType.OrgAtRiskMembers); - this.activeDrawerType = DrawerType.OrgAtRiskMembers; - this.drawerInvokerId = invokerId; - this.atRiskMemberDetails = atRiskMemberDetails; - this.openDrawer = !this.openDrawer; + this.drawerDetailsSubject.next({ + open: true, + invokerId, + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails, + appAtRiskMembers: null, + atRiskAppDetails: null, + }); }; setDrawerForAppAtRiskMembers = ( atRiskMembersDialogParams: AppAtRiskMembersDialogParams, invokerId: string = "", ): void => { - this.resetDrawer(DrawerType.None); - this.activeDrawerType = DrawerType.AppAtRiskMembers; - this.drawerInvokerId = invokerId; - this.appAtRiskMembers = atRiskMembersDialogParams; - this.openDrawer = !this.openDrawer; + this.drawerDetailsSubject.next({ + open: true, + invokerId, + activeDrawerType: DrawerType.AppAtRiskMembers, + atRiskMemberDetails: [], + appAtRiskMembers: atRiskMembersDialogParams, + atRiskAppDetails: null, + }); }; setDrawerForOrgAtRiskApps = ( atRiskApps: AtRiskApplicationDetail[], invokerId: string = "", ): void => { - this.resetDrawer(DrawerType.OrgAtRiskApps); - this.activeDrawerType = DrawerType.OrgAtRiskApps; - this.drawerInvokerId = invokerId; - this.atRiskAppDetails = atRiskApps; - this.openDrawer = !this.openDrawer; + this.drawerDetailsSubject.next({ + open: true, + invokerId, + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: atRiskApps, + }); }; - closeDrawer = (): void => { - this.resetDrawer(DrawerType.None); - }; + // ------------------------- Critical Application functions ----------------------------- + /** + * Calls the critical apps service with the organization and selected applications + */ + saveCriticalApps = (applications: string[]) => + this.organizationDetails$.pipe( + exhaustMap(({ organizationId }) => { + return from(this.criticalAppsService.setCriticalApps(organizationId, applications)); + }), + ); - private resetDrawer = (drawerType: DrawerType): void => { - if (this.activeDrawerType !== drawerType) { - this.openDrawer = false; - } - - this.activeDrawerType = DrawerType.None; - this.atRiskMemberDetails = []; - this.appAtRiskMembers = null; - this.atRiskAppDetails = null; - this.drawerInvokerId = ""; - }; + /** + * Removes a specified application from the organization's list of critical applications + * + * @param applicationName + * @returns + */ + dropCriticalApp(applicationName: string) { + return of(applicationName).pipe( + withLatestFrom(this.organizationDetails$), + exhaustMap(async ([hostname, { organizationId }]) => { + const result = await this.criticalAppsService.dropCriticalApp(organizationId, hostname); + return result; + }), + ); + } } 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 c01b86964f5..c3d8e6f3ab5 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 @@ -9,6 +9,9 @@ import { KeyService } from "@bitwarden/key-management"; import { EncryptedDataWithKey } from "../models/password-health"; +/** + * Service for encrypting and decrypting risk insights report data. + */ export class RiskInsightsEncryptionService { constructor( private keyService: KeyService, @@ -16,6 +19,13 @@ export class RiskInsightsEncryptionService { private keyGeneratorService: KeyGenerationService, ) {} + /** + * Encrypts the risk insights report data for a specific organization. + * @param organizationId The ID of the organization. + * @param userId The ID of the user. + * @param data The data to encrypt. + * @returns A promise that resolves to the encrypted data with the encryption key. + */ async encryptRiskInsightsReport( organizationId: OrganizationId, userId: UserId, @@ -55,14 +65,23 @@ export class RiskInsightsEncryptionService { const encryptionKey = wrappedEncryptionKey.encryptedString; const encryptedDataPacket = { - organizationId: organizationId, - encryptedData: encryptedData, - encryptionKey: encryptionKey, + organizationId, + encryptedData, + contentEncryptionKey: encryptionKey, }; return encryptedDataPacket; } + /** + * Decrypts the risk insights report data for a specific organization. + * @param organizationId The ID of the organization. + * @param userId The ID of the user. + * @param encryptedData The encrypted data to decrypt. + * @param wrappedKey The wrapped encryption key. + * @param parser A function to parse the decrypted JSON data. + * @returns A promise that resolves to the decrypted data or null if decryption fails. + */ async decryptRiskInsightsReport( organizationId: OrganizationId, userId: UserId, 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 00561d7b04d..60eee4a270e 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,52 +1,91 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; -import { ZXCVBNResult } from "zxcvbn"; -import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string"; -import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { GetRiskInsightsReportResponse } from "../models/password-health"; +import { ApplicationHealthReportDetail } from "../models/report-models"; import { mockCiphers } from "./ciphers.mock"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec"; +import { PasswordHealthService } from "./password-health.service"; import { RiskInsightsApiService } from "./risk-insights-api.service"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; import { RiskInsightsReportService } from "./risk-insights-report.service"; describe("RiskInsightsReportService", () => { let service: RiskInsightsReportService; - const pwdStrengthService = mock(); - const auditService = mock(); + const ENCRYPTED_TEXT = "This data has been encrypted"; + const ENCRYPTED_KEY = "Re-encrypted Cipher Key"; + const passwordHealthService = mock(); const cipherService = mock(); const memberCipherDetailsService = mock(); const mockRiskInsightsApiService = mock(); - const mockRiskInsightsEncryptionService = mock({ - encryptRiskInsightsReport: jest.fn().mockResolvedValue("encryptedReportData"), - decryptRiskInsightsReport: jest.fn().mockResolvedValue("decryptedReportData"), - }); - const orgId = "orgId" as OrganizationId; + const mockRiskInsightsEncryptionService = mock(); + + const mockReportId = "report-id"; + const mockOrganizationId = "org-123" as OrganizationId; + const mockUserId = "user-456" as UserId; + const mockEncryptedText = new EncString(ENCRYPTED_TEXT); + const mockEncryptedKey = new EncString(ENCRYPTED_KEY); + const mockReportDate = new Date().toISOString(); beforeEach(() => { - pwdStrengthService.getPasswordStrength.mockImplementation((password: string) => { - const score = password.length < 4 ? 1 : 4; - return { score } as ZXCVBNResult; + // Mock the password health service methods + passwordHealthService.isValidCipher.mockImplementation( + (cipher) => + cipher.type === 1 && cipher.login?.password && !cipher.isDeleted && cipher.viewPassword, + ); + + passwordHealthService.findWeakPasswordDetails.mockImplementation((cipher) => { + const score = cipher.login.password.length < 4 ? 1 : 4; + return score <= 2 ? { score, detailValue: { label: "weak", badgeVariant: "warning" } } : null; }); - auditService.passwordLeaked.mockImplementation((password: string) => - Promise.resolve(password === "123" ? 100 : 0), + passwordHealthService.auditPasswordLeaks$.mockImplementation((ciphers) => + of( + ciphers + .filter((cipher) => cipher.login.password === "123") + .map((cipher) => ({ cipherId: cipher.id, exposedXTimes: 100 })), + ), ); cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers); - memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberCipherDetails); + // Mock encryption/decryption + mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue({ + organizationId: mockOrganizationId, + encryptedData: mockEncryptedText.encryptedString, + contentEncryptionKey: mockEncryptedKey.encryptedString, + }); + + mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockResolvedValue({ + data: [], + summary: { + totalMemberCount: 0, + totalAtRiskMemberCount: 0, + totalApplicationCount: 0, + totalAtRiskApplicationCount: 0, + }, + }); + + // Mock API calls + mockRiskInsightsApiService.saveRiskInsightsReport$.mockReturnValue(of({ id: mockReportId })); + mockRiskInsightsApiService.getRiskInsightsReport$.mockReturnValue( + of({ + id: mockReportId, + organizationId: mockOrganizationId, + date: mockReportDate, + reportData: mockEncryptedText.encryptedString, + contentEncryptionKey: mockEncryptedKey.encryptedString, + }), + ); + service = new RiskInsightsReportService( - pwdStrengthService, - auditService, + passwordHealthService, cipherService, memberCipherDetailsService, mockRiskInsightsApiService, @@ -54,98 +93,36 @@ describe("RiskInsightsReportService", () => { ); }); - it("should generate the raw data report correctly", async () => { - const result = await firstValueFrom(service.generateRawDataReport$(orgId)); - - expect(result).toHaveLength(6); - - let testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001228ab1"); - expect(testCaseResults).toHaveLength(1); - let testCase = testCaseResults[0]; - expect(testCase).toBeTruthy(); - expect(testCase.cipherMembers).toHaveLength(2); - expect(testCase.trimmedUris).toHaveLength(5); - expect(testCase.weakPasswordDetail).toBeTruthy(); - expect(testCase.exposedPasswordDetail).toBeTruthy(); - expect(testCase.reusedPasswordCount).toEqual(2); - - testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001227tt1"); - expect(testCaseResults).toHaveLength(1); - testCase = testCaseResults[0]; - expect(testCase).toBeTruthy(); - expect(testCase.cipherMembers).toHaveLength(1); - expect(testCase.trimmedUris).toHaveLength(1); - expect(testCase.weakPasswordDetail).toBeFalsy(); - expect(testCase.exposedPasswordDetail).toBeFalsy(); - expect(testCase.reusedPasswordCount).toEqual(1); - }); - - it("should generate the raw data + uri report correctly", async () => { - const result = await firstValueFrom(service.generateRawDataUriReport$(orgId)); - - expect(result).toHaveLength(11); - - // Two ciphers that have google.com as their uri. There should be 2 results - const googleResults = result.filter((x) => x.trimmedUri === "google.com"); - expect(googleResults).toHaveLength(2); - - // There is an invalid uri and it should not be trimmed - const invalidUriResults = result.filter((x) => x.trimmedUri === "this_is-not|a-valid-uri123@+"); - expect(invalidUriResults).toHaveLength(1); - - // Verify the details for one of the googles matches the password health info - // expected - const firstGoogle = googleResults.filter( - (x) => x.cipherId === "cbea34a8-bde4-46ad-9d19-b05001228ab1" && x.trimmedUri === "google.com", - )[0]; - expect(firstGoogle.weakPasswordDetail).toBeTruthy(); - expect(firstGoogle.exposedPasswordDetail).toBeTruthy(); - expect(firstGoogle.reusedPasswordCount).toEqual(2); - }); - it("should generate applications health report data correctly", async () => { - const result = await firstValueFrom(service.generateApplicationsReport$(orgId)); + const result = await firstValueFrom(service.generateApplicationsReport$(mockOrganizationId)); expect(result).toHaveLength(8); - // Two ciphers have google.com associated with them. The first cipher - // has 2 members and the second has 4. However, the 2 members in the first - // cipher are also associated with the second. The total amount of members - // should be 4 not 6 + // Two ciphers have google.com associated with them const googleTestResults = result.filter((x) => x.applicationName === "google.com"); expect(googleTestResults).toHaveLength(1); const googleTest = googleTestResults[0]; + + // Verify member count (should be unique across ciphers) expect(googleTest.memberCount).toEqual(4); - - // Both ciphers have at risk passwords expect(googleTest.passwordCount).toEqual(2); - - // All members are at risk since both ciphers are at risk expect(googleTest.atRiskMemberDetails).toHaveLength(4); expect(googleTest.atRiskPasswordCount).toEqual(2); - // There are 2 ciphers associated with 101domain.com + // Test 101domain.com aggregation const domain101TestResults = result.filter((x) => x.applicationName === "101domain.com"); expect(domain101TestResults).toHaveLength(1); const domain101Test = domain101TestResults[0]; expect(domain101Test.passwordCount).toEqual(2); - - // The first cipher is at risk. The second cipher is not at risk expect(domain101Test.atRiskPasswordCount).toEqual(1); - - // The first cipher has 2 members. The second cipher the second - // cipher has 4. One of the members in the first cipher is associated - // with the second. So there should be 5 members total. expect(domain101Test.memberCount).toEqual(5); - - // The first cipher is at risk. The total at risk members is 2 and - // at risk password count is 1. expect(domain101Test.atRiskMemberDetails).toHaveLength(2); - expect(domain101Test.atRiskPasswordCount).toEqual(1); }); it("should generate applications summary data correctly", async () => { - const reportResult = await firstValueFrom(service.generateApplicationsReport$(orgId)); + const reportResult = await firstValueFrom( + service.generateApplicationsReport$(mockOrganizationId), + ); const reportSummary = service.generateApplicationsSummary(reportResult); expect(reportSummary.totalMemberCount).toEqual(7); @@ -154,190 +131,65 @@ describe("RiskInsightsReportService", () => { expect(reportSummary.totalAtRiskApplicationCount).toEqual(7); }); - describe("saveRiskInsightsReport", () => { - it("should encrypt and save the report, then update subjects if response has id", async () => { - const organizationId = "orgId" as OrganizationId; - const userId = "userId" as UserId; - const report = [{ applicationName: "app1" }] as any; - const summary = { - totalMemberCount: 1, - totalAtRiskMemberCount: 1, - totalApplicationCount: 1, - totalAtRiskApplicationCount: 1, - }; + it("should save report correctly", async () => { + const mockReport: ApplicationHealthReportDetail[] = []; // Your mock application health report + const mockSummary = { + totalMemberCount: 10, + totalAtRiskMemberCount: 5, + totalApplicationCount: 3, + totalAtRiskApplicationCount: 2, + }; - const encryptedReport = { - organizationId: organizationId as OrganizationId, - encryptedData: "encryptedData" as EncryptedString, - encryptionKey: "encryptionKey" as EncryptedString, - }; + const result = await firstValueFrom( + service.saveReport$(mockReport, mockSummary, { + organizationId: mockOrganizationId, + userId: mockUserId, + }), + ); - mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue( - encryptedReport, - ); - - const saveResponse = { id: "reportId" }; - mockRiskInsightsApiService.saveRiskInsightsReport.mockReturnValue(of(saveResponse)); - - const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next"); - const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next"); - - await service.saveRiskInsightsReport(organizationId, userId, report, summary); - - expect(mockRiskInsightsEncryptionService.encryptRiskInsightsReport).toHaveBeenCalledWith( - organizationId, - userId, - { data: report, summary }, - ); - - expect(mockRiskInsightsApiService.saveRiskInsightsReport).toHaveBeenCalledWith({ - data: expect.objectContaining({ - organizationId, - date: expect.any(String), // Date should be generated in the service - reportData: encryptedReport.encryptedData, - reportKey: encryptedReport.encryptionKey, - }), - }); - expect(reportSubjectSpy).toHaveBeenCalledWith(report); - expect(summarySubjectSpy).toHaveBeenCalledWith(summary); - }); - - it("should not update subjects if save response does not have id", async () => { - const organizationId = "orgId" as OrganizationId; - const userId = "userId" as UserId; - const report = [{ applicationName: "app1" }] as any; - const summary = { - totalMemberCount: 1, - totalAtRiskMemberCount: 1, - totalApplicationCount: 1, - totalAtRiskApplicationCount: 1, - }; - - const encryptedReport = { - organizationId: organizationId as OrganizationId, - encryptedData: "encryptedData" as EncryptedString, - encryptionKey: "encryptionKey" as EncryptedString, - }; - - mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue( - encryptedReport, - ); - - const saveResponse = { id: "" }; // Simulating no ID in response - mockRiskInsightsApiService.saveRiskInsightsReport.mockReturnValue(of(saveResponse)); - - const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next"); - const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next"); - - await service.saveRiskInsightsReport(organizationId, userId, report, summary); - - expect(reportSubjectSpy).not.toHaveBeenCalled(); - expect(summarySubjectSpy).not.toHaveBeenCalled(); - }); + expect(mockRiskInsightsEncryptionService.encryptRiskInsightsReport).toHaveBeenCalledWith( + mockOrganizationId, + mockUserId, + { data: mockReport, summary: mockSummary }, + ); + expect(mockRiskInsightsApiService.saveRiskInsightsReport$).toHaveBeenCalled(); + expect(result).toStrictEqual({ id: "report-id" }); }); - describe("getRiskInsightsReport", () => { - beforeEach(() => { - // Reset the mocks before each test - jest.clearAllMocks(); - }); + it("should get risk insights report correctly", async () => { + const result = await firstValueFrom( + service.getRiskInsightsReport$(mockOrganizationId, mockUserId), + ); - it("should call riskInsightsApiService.getRiskInsightsReport with the correct organizationId", () => { - // we need to ensure that the api is invoked with the specified organizationId - // here it doesn't matter what the Api returns - const apiResponse = { - id: "reportId", - date: new Date().toISOString(), - organizationId: "orgId", - reportData: "encryptedReportData", - reportKey: "encryptionKey", - } as GetRiskInsightsReportResponse; - - const organizationId = "orgId" as OrganizationId; - const userId = "userId" as UserId; - mockRiskInsightsApiService.getRiskInsightsReport.mockReturnValue(of(apiResponse)); - service.getRiskInsightsReport(organizationId, userId); - expect(mockRiskInsightsApiService.getRiskInsightsReport).toHaveBeenCalledWith(organizationId); - expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith( - organizationId, - userId, - expect.anything(), // encryptedData - expect.anything(), // wrappedKey - expect.any(Function), // parser - ); - }); - - it("should set empty report and summary if response is falsy", async () => { - // arrange: Api service returns undefined or null - const organizationId = "orgId" as OrganizationId; - const userId = "userId" as UserId; - - // Simulate a falsy response from the API (undefined) - mockRiskInsightsApiService.getRiskInsightsReport.mockReturnValue(of(null)); - const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next"); - const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next"); - - // act: call the service method - service.getRiskInsightsReport(organizationId, userId); - - // wait for the observable to emit and microtasks to complete - await Promise.resolve(); - - // assert: verify that the report and summary subjects are updated with empty values - expect(reportSubjectSpy).toHaveBeenCalledWith([]); - expect(summarySubjectSpy).toHaveBeenCalledWith({ + expect(mockRiskInsightsApiService.getRiskInsightsReport$).toHaveBeenCalledWith( + mockOrganizationId, + ); + expect(result).toEqual({ + data: [], + summary: { totalMemberCount: 0, totalAtRiskMemberCount: 0, totalApplicationCount: 0, totalAtRiskApplicationCount: 0, - }); + }, }); + }); - it("should decrypt report and update subjects if response is present", async () => { - // Arrange: setup a mock response from the API - // and ensure the decryption service is called with the correct parameters - const organizationId = "orgId" as OrganizationId; - const userId = "userId" as UserId; + it("should handle empty risk insights report response", async () => { + mockRiskInsightsApiService.getRiskInsightsReport$.mockReturnValue(of(null)); - const mockResponse = { - id: "reportId", - date: new Date().toISOString(), - organizationId: organizationId as OrganizationId, - reportData: "encryptedReportData", - reportKey: "encryptionKey", - } as GetRiskInsightsReportResponse; + const result = await firstValueFrom( + service.getRiskInsightsReport$(mockOrganizationId, mockUserId), + ); - const decryptedReport = { - data: [{ foo: "bar" }], - summary: { - totalMemberCount: 1, - totalAtRiskMemberCount: 1, - totalApplicationCount: 1, - totalAtRiskApplicationCount: 1, - }, - }; - mockRiskInsightsApiService.getRiskInsightsReport.mockReturnValue(of(mockResponse)); - mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockResolvedValue( - decryptedReport, - ); - - const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next"); - const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next"); - - service.getRiskInsightsReport(organizationId, userId); - - // Wait for all microtasks to complete - await Promise.resolve(); - - expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith( - organizationId, - userId, - expect.anything(), - expect.anything(), - expect.any(Function), - ); - expect(reportSubjectSpy).toHaveBeenCalledWith(decryptedReport.data); - expect(summarySubjectSpy).toHaveBeenCalledWith(decryptedReport.summary); + expect(result).toEqual({ + data: [], + summary: { + totalMemberCount: 0, + totalAtRiskMemberCount: 0, + totalApplicationCount: 0, + totalAtRiskApplicationCount: 0, + }, }); }); }); 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 2cde5b4733c..17d36406c7d 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,114 +1,39 @@ // FIXME: Update this file to be type safe // @ts-strict-ignore -import { - BehaviorSubject, - concatMap, - first, - firstValueFrom, - from, - map, - Observable, - of, - switchMap, - zip, -} from "rxjs"; +import { from, map, switchMap, of, Observable, forkJoin } from "rxjs"; -import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; -import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { flattenMemberDetails, getTrimmedCipherUris, getUniqueMembers } from "../helpers"; +import { SaveRiskInsightsReportResponse } from "../models/api-models.types"; import { ApplicationHealthReportDetail, - ApplicationHealthReportSummary, AtRiskMemberDetail, AtRiskApplicationDetail, - CipherHealthReportDetail, - CipherHealthReportUriDetail, - ExposedPasswordDetail, - MemberDetailsFlat, - WeakPasswordDetail, - WeakPasswordScore, - ApplicationHealthReportDetailWithCriticalFlagAndCipher, - ReportInsightsReportData, -} from "../models/password-health"; + CipherHealthReport, + MemberDetails, + ApplicationHealthReportSummary, + RiskInsightsReportData, + PasswordHealthData, +} from "../models/report-models"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; +import { PasswordHealthService } from "./password-health.service"; import { RiskInsightsApiService } from "./risk-insights-api.service"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; export class RiskInsightsReportService { constructor( - private passwordStrengthService: PasswordStrengthServiceAbstraction, - private auditService: AuditService, private cipherService: CipherService, private memberCipherDetailsApiService: MemberCipherDetailsApiService, + private passwordHealthService: PasswordHealthService, private riskInsightsApiService: RiskInsightsApiService, private riskInsightsEncryptionService: RiskInsightsEncryptionService, ) {} - private riskInsightsReportSubject = new BehaviorSubject([]); - riskInsightsReport$ = this.riskInsightsReportSubject.asObservable(); - - private riskInsightsSummarySubject = new BehaviorSubject({ - totalMemberCount: 0, - totalAtRiskMemberCount: 0, - totalApplicationCount: 0, - totalAtRiskApplicationCount: 0, - }); - riskInsightsSummary$ = this.riskInsightsSummarySubject.asObservable(); - - /** - * Report data from raw cipher health data. - * Can be used in the Raw Data diagnostic tab (just exclude the members in the view) - * and can be used in the raw data + members tab when including the members in the view - * @param organizationId - * @returns Cipher health report data with members and trimmed uris - */ - generateRawDataReport$(organizationId: OrganizationId): Observable { - const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId)); - const memberCiphers$ = from( - this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), - ); - - const results$ = zip(allCiphers$, memberCiphers$).pipe( - map(([allCiphers, memberCiphers]) => { - const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) => - dtl.cipherIds.map((c) => - this.getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c), - ), - ); - return [allCiphers, details] as const; - }), - concatMap(([ciphers, flattenedDetails]) => this.getCipherDetails(ciphers, flattenedDetails)), - first(), - ); - - return results$; - } - - /** - * Report data for raw cipher health broken out into the uris - * Can be used in the raw data + members + uri diagnostic report - * @param organizationId Id of the organization - * @returns Cipher health report data flattened to the uris - */ - generateRawDataUriReport$( - organizationId: OrganizationId, - ): Observable { - const cipherHealthDetails$ = this.generateRawDataReport$(organizationId); - const results$ = cipherHealthDetails$.pipe( - map((healthDetails) => this.getCipherUriDetails(healthDetails)), - first(), - ); - - return results$; - } - /** * Report data for the aggregation of uris to like uris and getting password/member counts, * members, and at risk statuses. @@ -118,13 +43,124 @@ export class RiskInsightsReportService { generateApplicationsReport$( organizationId: OrganizationId, ): Observable { - const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId); - const results$ = cipherHealthUriReport$.pipe( - map((uriDetails) => this.getApplicationHealthReport(uriDetails)), - first(), - ); + const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId)); + const memberCiphers$ = from( + this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), + ).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers))); - return results$; + return forkJoin([allCiphers$, memberCiphers$]).pipe( + switchMap(([ciphers, memberCiphers]) => this._getCipherDetails(ciphers, memberCiphers)), + map((cipherApplications) => { + const groupedByApplication = this._groupCiphersByApplication(cipherApplications); + + return Array.from(groupedByApplication.entries()).map(([application, ciphers]) => + this._getApplicationHealthReport(application, ciphers), + ); + }), + ); + } + + /** + * Gets the risk insights report for a specific organization and user. + * + * @param organizationId + * @param userId + * @returns An observable that emits the decrypted risk insights report data. + */ + getRiskInsightsReport$( + organizationId: OrganizationId, + userId: UserId, + ): Observable { + return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe( + switchMap((response) => { + if (!response) { + // Return an empty report and summary if response is falsy + return of({ + data: [], + summary: { + totalMemberCount: 0, + totalAtRiskMemberCount: 0, + totalApplicationCount: 0, + totalAtRiskApplicationCount: 0, + }, + }); + } + if (!response.contentEncryptionKey || response.contentEncryptionKey == "") { + throw new Error("Report key not found"); + } + return from( + this.riskInsightsEncryptionService.decryptRiskInsightsReport( + organizationId, + userId, + new EncString(response.reportData), + new EncString(response.contentEncryptionKey), + (data) => data as RiskInsightsReportData, + ), + ); + }), + ); + } + + /** + * Encrypts the risk insights report data for a specific organization. + * @param organizationId The ID of the organization. + * @param userId The ID of the user. + * @param report The report data to encrypt. + * @returns A promise that resolves to an object containing the encrypted data and encryption key. + */ + saveReport$( + report: ApplicationHealthReportDetail[], + summary: ApplicationHealthReportSummary, + encryptionParameters: { + organizationId: OrganizationId; + userId: UserId; + }, + ): Observable { + return from( + this.riskInsightsEncryptionService.encryptRiskInsightsReport( + encryptionParameters.organizationId, + encryptionParameters.userId, + { + data: report, + summary: summary, + }, + ), + ).pipe( + map((encryptedReport) => ({ + data: { + organizationId: encryptionParameters.organizationId, + date: new Date().toISOString(), + reportData: encryptedReport.encryptedData, + reportKey: encryptedReport.contentEncryptionKey, + }, + })), + switchMap((encryptedReport) => + this.riskInsightsApiService.saveRiskInsightsReport$(encryptedReport), + ), + ); + } + + /** + * Gets the summary from the application health report. Returns total members and applications as well + * as the total at risk members and at risk applications + * @param reports The previously calculated application health report data + * @returns A summary object containing report totals + */ + generateApplicationsSummary( + reports: ApplicationHealthReportDetail[], + ): ApplicationHealthReportSummary { + const totalMembers = reports.flatMap((x) => x.memberDetails); + const uniqueMembers = getUniqueMembers(totalMembers); + + const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails); + const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers); + + return { + totalMemberCount: uniqueMembers.length, + totalAtRiskMemberCount: uniqueAtRiskMembers.length, + totalApplicationCount: reports.length, + totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length, + }; } /** @@ -175,115 +211,132 @@ export class RiskInsightsReportService { })); } - /** - * Gets the summary from the application health report. Returns total members and applications as well - * as the total at risk members and at risk applications - * @param reports The previously calculated application health report data - * @returns A summary object containing report totals - */ - generateApplicationsSummary( - reports: ApplicationHealthReportDetail[], - ): ApplicationHealthReportSummary { - const totalMembers = reports.flatMap((x) => x.memberDetails); - const uniqueMembers = this.getUniqueMembers(totalMembers); - - const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails); - const uniqueAtRiskMembers = this.getUniqueMembers(atRiskMembers); - - return { - totalMemberCount: uniqueMembers.length, - totalAtRiskMemberCount: uniqueAtRiskMembers.length, - totalApplicationCount: reports.length, - totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length, - }; - } - - async identifyCiphers( - data: ApplicationHealthReportDetail[], + async getApplicationCipherMap( + applications: ApplicationHealthReportDetail[], organizationId: OrganizationId, - ): Promise { - const cipherViews = await this.cipherService.getAllFromApiForOrganization(organizationId); + ): Promise> { + const allCiphers = await this.cipherService.getAllFromApiForOrganization(organizationId); + const cipherMap = new Map(); - const dataWithCiphers = data.map( - (app, index) => - ({ - ...app, - ciphers: cipherViews.filter((c) => app.cipherIds.some((a) => a === c.id)), - }) as ApplicationHealthReportDetailWithCriticalFlagAndCipher, - ); - return dataWithCiphers; + applications.forEach((app) => { + const filteredCiphers = allCiphers.filter((c) => app.cipherIds.includes(c.id)); + cipherMap.set(app.applicationName, filteredCiphers); + }); + return cipherMap; } - getRiskInsightsReport(organizationId: OrganizationId, userId: UserId): void { - this.riskInsightsApiService - .getRiskInsightsReport(organizationId) - .pipe( - switchMap((response) => { - if (!response) { - // Return an empty report and summary if response is falsy - return of({ - data: [], - summary: { - totalMemberCount: 0, - totalAtRiskMemberCount: 0, - totalApplicationCount: 0, - totalAtRiskApplicationCount: 0, - }, - }); - } - return from( - this.riskInsightsEncryptionService.decryptRiskInsightsReport( - organizationId, - userId, - new EncString(response.reportData), - new EncString(response.reportKey), - (data) => data as ReportInsightsReportData, - ), - ); - }), - ) - .subscribe({ - next: (decryptRiskInsightsReport) => { - this.riskInsightsReportSubject.next(decryptRiskInsightsReport.data); - this.riskInsightsSummarySubject.next(decryptRiskInsightsReport.summary); - }, + private _buildPasswordUseMap(ciphers: CipherView[]): Map { + const passwordUseMap = new Map(); + ciphers.forEach((cipher) => { + const password = cipher.login.password; + passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1); + }); + return passwordUseMap; + } + + private _groupCiphersByApplication( + cipherHealthData: CipherHealthReport[], + ): Map { + const applicationMap = new Map(); + + cipherHealthData.forEach((cipher: CipherHealthReport) => { + cipher.applications.forEach((application) => { + const existingApplication = applicationMap.get(application) || []; + existingApplication.push(cipher); + applicationMap.set(application, existingApplication); }); + }); + + return applicationMap; } - async saveRiskInsightsReport( - organizationId: OrganizationId, - userId: UserId, - report: ApplicationHealthReportDetail[], - summary: ApplicationHealthReportSummary, - ): Promise { - const reportWithSummary = { - data: report, - summary: summary, - }; + /** + * Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item. + * If the item is new, create and add the object with the flattened details + * @param cipherHealthReport Cipher and password health info broken out into their uris + * @returns Application health reports + */ + private _getApplicationHealthReport( + application: string, + ciphers: CipherHealthReport[], + ): ApplicationHealthReportDetail { + let aggregatedReport: ApplicationHealthReportDetail | undefined; - const encryptedReport = await this.riskInsightsEncryptionService.encryptRiskInsightsReport( - organizationId, - userId, - reportWithSummary, - ); + ciphers.forEach((cipher) => { + const isAtRisk = this._isPasswordAtRisk(cipher.healthData); + aggregatedReport = this._aggregateReport(application, cipher, isAtRisk, aggregatedReport); + }); - const saveRequest = { - data: { - organizationId: organizationId, - date: new Date().toISOString(), - reportData: encryptedReport.encryptedData, - reportKey: encryptedReport.encryptionKey, - }, - }; + return aggregatedReport!; + } - const response = await firstValueFrom( - this.riskInsightsApiService.saveRiskInsightsReport(saveRequest), - ); - - if (response && response.id) { - this.riskInsightsReportSubject.next(report); - this.riskInsightsSummarySubject.next(summary); + private _aggregateReport( + application: string, + newCipherReport: CipherHealthReport, + isAtRisk: boolean, + existingReport?: ApplicationHealthReportDetail, + ): ApplicationHealthReportDetail { + let baseReport = existingReport + ? this._updateExistingReport(existingReport, newCipherReport) + : this._createNewReport(application, newCipherReport); + if (isAtRisk) { + baseReport = { ...baseReport, ...this._getAtRiskData(baseReport, newCipherReport) }; } + + baseReport.memberCount = baseReport.memberDetails.length; + baseReport.atRiskMemberCount = baseReport.atRiskMemberDetails.length; + + return baseReport; + } + private _createNewReport( + application: string, + cipherReport: CipherHealthReport, + ): ApplicationHealthReportDetail { + return { + applicationName: application, + cipherIds: [cipherReport.cipher.id], + passwordCount: 1, + memberDetails: [...cipherReport.cipherMembers], + memberCount: cipherReport.cipherMembers.length, + atRiskCipherIds: [], + atRiskMemberCount: 0, + atRiskMemberDetails: [], + atRiskPasswordCount: 0, + }; + } + + private _updateExistingReport( + existingReport: ApplicationHealthReportDetail, + newCipherReport: CipherHealthReport, + ): ApplicationHealthReportDetail { + return { + ...existingReport, + passwordCount: existingReport.passwordCount + 1, + memberDetails: getUniqueMembers( + existingReport.memberDetails.concat(newCipherReport.cipherMembers), + ), + cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id), + }; + } + + private _getAtRiskData(report: ApplicationHealthReportDetail, cipherReport: CipherHealthReport) { + const atRiskMemberDetails = getUniqueMembers( + report.atRiskMemberDetails.concat(cipherReport.cipherMembers), + ); + return { + atRiskPasswordCount: report.atRiskPasswordCount + 1, + atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id), + atRiskMemberDetails, + atRiskMemberCount: atRiskMemberDetails.length, + }; + } + + private _isPasswordAtRisk(healthData: PasswordHealthData): boolean { + return !!( + healthData.exposedPasswordDetail || + healthData.weakPasswordDetail || + healthData.reusedPasswordCount > 1 + ); } /** @@ -293,302 +346,35 @@ export class RiskInsightsReportService { * @param memberDetails Org members * @returns Cipher password health data with trimmed uris and associated members */ - private async getCipherDetails( + private _getCipherDetails( ciphers: CipherView[], - memberDetails: MemberDetailsFlat[], - ): Promise { - const cipherHealthReports: CipherHealthReportDetail[] = []; - const passwordUseMap = new Map(); - const exposedDetails = await this.findExposedPasswords(ciphers); - for (const cipher of ciphers) { - if (this.validateCipher(cipher)) { - const weakPassword = this.findWeakPassword(cipher); - // Looping over all ciphers needs to happen first to determine reused passwords over all ciphers. - // Store in the set and evaluate later - if (passwordUseMap.has(cipher.login.password)) { - passwordUseMap.set( - cipher.login.password, - (passwordUseMap.get(cipher.login.password) || 0) + 1, - ); - } else { - passwordUseMap.set(cipher.login.password, 1); - } - - const exposedPassword = exposedDetails.find((x) => x.cipherId === cipher.id); - - // Get the cipher members - const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id); - - // Trim uris to host name and create the cipher health report - const cipherTrimmedUris = this.getTrimmedCipherUris(cipher); - const cipherHealth = { - ...cipher, - weakPasswordDetail: weakPassword, - exposedPasswordDetail: exposedPassword, - cipherMembers: cipherMembers, - trimmedUris: cipherTrimmedUris, - } as CipherHealthReportDetail; - - cipherHealthReports.push(cipherHealth); - } - } - - // loop for reused passwords - cipherHealthReports.forEach((detail) => { - detail.reusedPasswordCount = passwordUseMap.get(detail.login.password) ?? 0; - }); - return cipherHealthReports; - } - - /** - * Flattens the cipher to trimmed uris. Used for the raw data + uri - * @param cipherHealthReport Cipher health report with uris and members - * @returns Flattened cipher health details to uri - */ - private getCipherUriDetails( - cipherHealthReport: CipherHealthReportDetail[], - ): CipherHealthReportUriDetail[] { - return cipherHealthReport.flatMap((rpt) => - rpt.trimmedUris.map((u) => this.getFlattenedCipherDetails(rpt, u)), + memberDetails: MemberDetails[], + ): Observable { + const validCiphers = ciphers.filter((cipher) => + this.passwordHealthService.isValidCipher(cipher), ); - } + // Build password use map + const passwordUseMap = this._buildPasswordUseMap(validCiphers); - /** - * Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item. - * If the item is new, create and add the object with the flattened details - * @param cipherHealthUriReport Cipher and password health info broken out into their uris - * @returns Application health reports - */ - private getApplicationHealthReport( - cipherHealthUriReport: CipherHealthReportUriDetail[], - ): ApplicationHealthReportDetail[] { - const appReports: ApplicationHealthReportDetail[] = []; - cipherHealthUriReport.forEach((uri) => { - const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri); + return this.passwordHealthService.auditPasswordLeaks$(validCiphers).pipe( + map((exposedDetails) => { + return validCiphers.map((cipher) => { + const exposedPassword = exposedDetails.find((x) => x.cipherId === cipher.id); + const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id); - let atRisk: boolean = false; - if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) { - atRisk = true; - } - - if (index === -1) { - appReports.push(this.getApplicationReportDetail(uri, atRisk)); - } else { - appReports[index] = this.getApplicationReportDetail(uri, atRisk, appReports[index]); - } - }); - return appReports; - } - - private async findExposedPasswords(ciphers: CipherView[]): Promise { - const exposedDetails: ExposedPasswordDetail[] = []; - const promises: Promise[] = []; - - ciphers.forEach((ciph) => { - if (this.validateCipher(ciph)) { - const promise = this.auditService - .passwordLeaked(ciph.login.password) - .then((exposedCount) => { - if (exposedCount > 0) { - const detail = { - exposedXTimes: exposedCount, - cipherId: ciph.id, - } as ExposedPasswordDetail; - exposedDetails.push(detail); - } - }); - promises.push(promise); - } - }); - await Promise.all(promises); - - return exposedDetails; - } - - private findWeakPassword(cipher: CipherView): WeakPasswordDetail { - const hasUserName = this.isUserNameNotEmpty(cipher); - let userInput: string[] = []; - if (hasUserName) { - const atPosition = cipher.login.username.indexOf("@"); - if (atPosition > -1) { - userInput = userInput - .concat( - cipher.login.username - .substring(0, atPosition) - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/), - ) - .filter((i) => i.length >= 3); - } else { - userInput = cipher.login.username - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/) - .filter((i) => i.length >= 3); - } - } - const { score } = this.passwordStrengthService.getPasswordStrength( - cipher.login.password, - null, - userInput.length > 0 ? userInput : null, + const result = { + cipher: cipher, + cipherMembers, + healthData: { + weakPasswordDetail: this.passwordHealthService.findWeakPasswordDetails(cipher), + exposedPasswordDetail: exposedPassword, + reusedPasswordCount: passwordUseMap.get(cipher.login.password) ?? 0, + }, + applications: getTrimmedCipherUris(cipher), + } as CipherHealthReport; + return result; + }); + }), ); - - if (score != null && score <= 2) { - const scoreValue = this.weakPasswordScore(score); - const weakPasswordDetail = { score: score, detailValue: scoreValue } as WeakPasswordDetail; - return weakPasswordDetail; - } - return null; - } - - private weakPasswordScore(score: number): WeakPasswordScore { - switch (score) { - case 4: - return { label: "strong", badgeVariant: "success" }; - case 3: - return { label: "good", badgeVariant: "primary" }; - case 2: - return { label: "weak", badgeVariant: "warning" }; - default: - return { label: "veryWeak", badgeVariant: "danger" }; - } - } - - /** - * Create the new application health report detail object with the details from the cipher health report uri detail object - * update or create the at risk values if the item is at risk. - * @param newUriDetail New cipher uri detail - * @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk - * @param existingUriDetail The previously processed Uri item - * @returns The new or updated application health report detail - */ - private getApplicationReportDetail( - newUriDetail: CipherHealthReportUriDetail, - isAtRisk: boolean, - existingUriDetail?: ApplicationHealthReportDetail, - ): ApplicationHealthReportDetail { - const reportDetail = { - applicationName: existingUriDetail - ? existingUriDetail.applicationName - : newUriDetail.trimmedUri, - passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1, - memberDetails: existingUriDetail - ? this.getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers)) - : newUriDetail.cipherMembers, - atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [], - atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0, - atRiskCipherIds: existingUriDetail ? existingUriDetail.atRiskCipherIds : [], - atRiskMemberCount: existingUriDetail ? existingUriDetail.atRiskMemberDetails.length : 0, - cipherIds: existingUriDetail - ? existingUriDetail.cipherIds.concat(newUriDetail.cipherId) - : [newUriDetail.cipherId], - } as ApplicationHealthReportDetail; - - if (isAtRisk) { - reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1; - reportDetail.atRiskCipherIds.push(newUriDetail.cipherId); - - reportDetail.atRiskMemberDetails = this.getUniqueMembers( - reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers), - ); - reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length; - } - - reportDetail.memberCount = reportDetail.memberDetails.length; - - return reportDetail; - } - - /** - * Get a distinct array of members from a combined list. Input list may contain - * duplicate members. - * @param orgMembers Input list of members - * @returns Distinct array of members - */ - private getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] { - const existingEmails = new Set(); - const distinctUsers = orgMembers.filter((member) => { - if (existingEmails.has(member.email)) { - return false; - } - existingEmails.add(member.email); - return true; - }); - return distinctUsers; - } - - private getFlattenedCipherDetails( - detail: CipherHealthReportDetail, - uri: string, - ): CipherHealthReportUriDetail { - return { - cipherId: detail.id, - reusedPasswordCount: detail.reusedPasswordCount, - weakPasswordDetail: detail.weakPasswordDetail, - exposedPasswordDetail: detail.exposedPasswordDetail, - cipherMembers: detail.cipherMembers, - trimmedUri: uri, - cipher: detail as CipherView, - }; - } - - private getMemberDetailsFlat( - userGuid: string, - userName: string, - email: string, - cipherId: string, - ): MemberDetailsFlat { - return { - userGuid: userGuid, - userName: userName, - email: email, - cipherId: cipherId, - }; - } - - /** - * Trim the cipher uris down to get the password health application. - * The uri should only exist once after being trimmed. No duplication. - * Example: - * - Untrimmed Uris: https://gmail.com, gmail.com/login - * - Both would trim to gmail.com - * - The cipher trimmed uri list should only return on instance in the list - * @param cipher - * @returns distinct list of trimmed cipher uris - */ - private getTrimmedCipherUris(cipher: CipherView): string[] { - const cipherUris: string[] = []; - const uris = cipher.login?.uris ?? []; - uris.map((u: { uri: string }) => { - const uri = Utils.getDomain(u.uri) ?? u.uri; - if (!cipherUris.includes(uri)) { - cipherUris.push(uri); - } - }); - return cipherUris; - } - - private isUserNameNotEmpty(c: CipherView): boolean { - return !Utils.isNullOrWhitespace(c.login.username); - } - - /** - * Validates that the cipher is a login item, has a password - * is not deleted, and the user can view the password - * @param c the input cipher - */ - private validateCipher(c: CipherView): boolean { - const { type, login, isDeleted, viewPassword } = c; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - !viewPassword - ) { - return false; - } - return true; } } 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 8569e7b2f89..8a9fc915bf3 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 @@ -1,16 +1,20 @@ import { NgModule } from "@angular/core"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; -import { CriticalAppsService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CriticalAppsApiService, MemberCipherDetailsApiService, + PasswordHealthService, RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights/services"; +import { CriticalAppsService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/critical-apps.service"; +import { RiskInsightsApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-api.service"; import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction"; @@ -27,24 +31,6 @@ import { RiskInsightsComponent } from "./risk-insights.component"; provide: MemberCipherDetailsApiService, deps: [ApiService], }, - { - provide: RiskInsightsReportService, - deps: [ - PasswordStrengthServiceAbstraction, - AuditService, - CipherService, - MemberCipherDetailsApiService, - ], - }, - { - provide: RiskInsightsDataService, - deps: [RiskInsightsReportService], - }, - { - provide: RiskInsightsEncryptionService, - useClass: RiskInsightsEncryptionService, - deps: [KeyService, EncryptService, KeyGenerationService], - }, safeProvider({ provide: CriticalAppsService, useClass: CriticalAppsService, @@ -55,6 +41,29 @@ import { RiskInsightsComponent } from "./risk-insights.component"; useClass: CriticalAppsApiService, deps: [ApiService], }), + { provide: PasswordHealthService, deps: [PasswordStrengthServiceAbstraction, AuditService] }, + { + provide: RiskInsightsApiService, + }, + { + provide: RiskInsightsReportService, + deps: [ + CipherService, + MemberCipherDetailsApiService, + PasswordHealthService, + RiskInsightsApiService, + RiskInsightsEncryptionService, + ], + }, + { + provide: RiskInsightsDataService, + deps: [AccountService, CriticalAppsService, OrganizationService, RiskInsightsReportService], + }, + { + provide: RiskInsightsEncryptionService, + useClass: RiskInsightsEncryptionService, + deps: [KeyService, EncryptService, KeyGenerationService], + }, ], }) export class AccessIntelligenceModule {} 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 d383d1153c7..5cd7e700ce6 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,80 +1,88 @@ -
+@if (dataService.isLoading$ | async) { -
-
- - -

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

-
- -
- - {{ "noAppsInOrgDescription" | i18n }} - - {{ "learnMore" | i18n }} +} @else { + @if (dataService.drawerDetails$ | async; as drawerDetails) { +
+ + +

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

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

{{ "allApplications" | i18n }}

+
+ + + + +
+
+ +
- - - - - -
-
-

{{ "allApplications" | 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 cc773f936ee..d3461490b7c 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,7 +2,7 @@ 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 { catchError, debounceTime, exhaustMap, finalize, of, tap } from "rxjs"; import { CriticalAppsService, @@ -10,22 +10,13 @@ import { RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { - ApplicationHealthReportDetail, - ApplicationHealthReportDetailWithCriticalFlag, - ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ApplicationHealthReportDetailEnriched, ApplicationHealthReportSummary, -} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; +} 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 { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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, @@ -59,12 +50,9 @@ 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 = Icons.Security; protected markingAsCritical = false; protected applicationSummary: ApplicationHealthReportSummary = { @@ -75,65 +63,6 @@ export class AllApplicationsComponent implements OnInit { }; 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), - 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 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); - } - if (organization) { - this.organization = organization; - } - }); - - this.isLoading$ = this.dataService.isLoading$; - } - } constructor( protected cipherService: CipherService, @@ -144,7 +73,6 @@ export class AllApplicationsComponent implements OnInit { protected dataService: RiskInsightsDataService, protected organizationService: OrganizationService, protected reportService: RiskInsightsReportService, - private accountService: AccountService, protected criticalAppsService: CriticalAppsService, protected riskInsightsEncryptionService: RiskInsightsEncryptionService, ) { @@ -153,6 +81,21 @@ export class AllApplicationsComponent implements OnInit { .subscribe((v) => (this.dataSource.filter = v)); } + async ngOnInit() { + const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); + + if (organizationId) { + this.dataService.reportResults$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((report) => { + if (report) { + this.dataSource.data = report.data; + // this.applicationSummary = this.reportService.generateApplicationsSummary(report.data); + } + }); + } + } + goToCreateNewLoginItem = async () => { // TODO: implement this.toastService.showToast({ @@ -162,34 +105,37 @@ export class AllApplicationsComponent implements OnInit { }); }; - isMarkedAsCriticalItem(applicationName: string) { - return this.selectedUrls.has(applicationName); - } - - markAppsAsCritical = async () => { - this.markingAsCritical = true; - - try { - await this.criticalAppsService.setCriticalApps( - this.organization.id, - Array.from(this.selectedUrls), - ); - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"), - }); - } finally { - this.selectedUrls.clear(); - this.markingAsCritical = false; - } + markAppsAsCritical = () => { + of(Array.from(this.selectedUrls)) + .pipe( + takeUntilDestroyed(this.destroyRef), + exhaustMap((urls) => + this.dataService.saveCriticalApps(urls).pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("applicationsMarkedAsCriticalFail"), + }); + throw error; + }), + ), + ), + tap(() => { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"), + }); + }), + finalize(() => { + this.selectedUrls.clear(); + this.markingAsCritical = false; + }), + ) + .subscribe(); }; - trackByFunction(_: number, item: ApplicationHealthReportDetail) { - return item.applicationName; - } - showAppAtRiskMembers = async (applicationName: string) => { const info = { members: @@ -218,10 +164,4 @@ export class AllApplicationsComponent implements OnInit { this.selectedUrls.delete(applicationName); } }; - - getSelectedUrls = () => Array.from(this.selectedUrls); - - isDrawerOpenForTableRow = (applicationName: string): boolean => { - return this.dataService.drawerInvokerId === applicationName; - }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html index 97c5b187ef9..d36e006edfe 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html @@ -13,7 +13,7 @@ @@ -36,33 +36,24 @@ {{ row.applicationName }} - + {{ row.atRiskPasswordCount }} - + {{ row.passwordCount }} - + {{ row.atRiskMemberCount }} @@ -70,14 +61,14 @@ {{ row.memberCount }} -
-
-
-
+@if (!dataSource.data.length) { +
+ + +

+ {{ "noCriticalAppsTitle" | i18n }} +

+
+ +

+ {{ "noCriticalAppsDescription" | i18n }} +

+
+ + + +
+
+} +

{{ "criticalApplications" | i18n }}

-
- - - - -
+ @if (summary) { +
+ + + + +
+ }
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 54c42b5c136..7a5eca09487 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,19 +4,16 @@ 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, map, switchMap } from "rxjs"; +import { debounceTime } from "rxjs"; import { - CriticalAppsService, RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { - ApplicationHealthReportDetailWithCriticalFlag, - ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ApplicationHealthReportDetailEnriched, ApplicationHealthReportSummary, -} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; @@ -53,14 +50,14 @@ 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 dataSource = new TableDataSource(); + + protected searchControl = new FormControl("", { nonNullable: true }); protected organizationId: OrganizationId; - protected applicationSummary = {} as ApplicationHealthReportSummary; + protected summary = {} as ApplicationHealthReportSummary; + noItemsIcon = Icons.Security; enableRequestPasswordChange = false; @@ -69,10 +66,8 @@ export class CriticalApplicationsComponent implements OnInit { protected router: Router, protected toastService: ToastService, protected dataService: RiskInsightsDataService, - protected criticalAppsService: CriticalAppsService, protected reportService: RiskInsightsReportService, protected i18nService: I18nService, - private configService: ConfigService, private adminTaskService: DefaultAdminTaskService, ) { this.searchControl.valueChanges @@ -84,37 +79,13 @@ export class CriticalApplicationsComponent implements OnInit { this.organizationId = this.activatedRoute.snapshot.paramMap.get( "organizationId", ) as OrganizationId; - - combineLatest([ - this.dataService.applications$, - this.criticalAppsService.getAppsListForOrg(this.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 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.dataService + .getCriticalReport$(this.dataService.reportResults$) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((criticalReport) => { + if (criticalReport) { + this.summary = this.reportService.generateApplicationsSummary(criticalReport.data); + this.dataSource.data = criticalReport.data; } }); } @@ -129,12 +100,9 @@ export class CriticalApplicationsComponent implements OnInit { ); }; - unmarkAsCriticalApp = async (hostname: string) => { + removeCriticalApp = async (hostname: string) => { try { - await this.criticalAppsService.dropCriticalApp( - this.organizationId as OrganizationId, - hostname, - ); + await this.dataService.dropCriticalApp(hostname); } catch { this.toastService.showToast({ message: this.i18nService.t("unexpectedError"), @@ -181,6 +149,7 @@ export class CriticalApplicationsComponent implements OnInit { } } + // Open side drawer to show at risk members for an application showAppAtRiskMembers = async (applicationName: string) => { const data = { members: @@ -191,6 +160,7 @@ export class CriticalApplicationsComponent implements OnInit { this.dataService.setDrawerForAppAtRiskMembers(data, applicationName); }; + // Open side drawer to show at risk members for the entire organization showOrgAtRiskMembers = async (invokerId: string) => { const data = this.reportService.generateAtRiskMemberList(this.dataSource.data); this.dataService.setDrawerForOrgAtRiskMembers(data, invokerId); @@ -200,11 +170,4 @@ export class CriticalApplicationsComponent implements OnInit { const data = this.reportService.generateAtRiskApplicationList(this.dataSource.data); this.dataService.setDrawerForOrgAtRiskApps(data, invokerId); }; - - trackByFunction(_: number, item: ApplicationHealthReportDetailWithCriticalFlag) { - return item.applicationName; - } - isDrawerOpenForTableRow = (applicationName: string) => { - return this.dataService.drawerInvokerId === applicationName; - }; } 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 627db269097..2349043e528 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 @@ -5,29 +5,30 @@ {{ "reviewAtRiskPasswords" | i18n }}
- - {{ - "dataLastUpdated" | i18n: (dataLastUpdated$ | async | date: "MMMM d, y 'at' h:mm a") - }} + @if (reportResults?.dateCreated) { + + {{ + "dataLastUpdated" | i18n: (reportResults?.dateCreated | date: "MMMM d, y 'at' h:mm a") + }} + } - {{ "refresh" | i18n }} + {{ "runRiskInsightsReport" | i18n }} @@ -35,106 +36,114 @@
- + - {{ "criticalApplicationsWithCount" | i18n: (criticalApps$ | async)?.length ?? 0 }} + {{ + "criticalApplicationsWithCount" | i18n: (dataService.criticalApps$ | async)?.length ?? 0 + }} - - - - - - {{ - (dataService.atRiskMemberDetails.length > 0 - ? "atRiskMembersDescription" - : "atRiskMembersDescriptionNone" - ) | i18n - }} - -
-
{{ "email" | i18n }}
-
- {{ "atRiskPasswords" | i18n }} + @if (dataService.drawerDetails$ | async; as drawerDetails) { + + + + + + {{ + (drawerDetails.atRiskMemberDetails.length > 0 + ? "atRiskMembersDescription" + : "atRiskMembersDescriptionNone" + ) | i18n + }} + +
+
+ {{ "email" | i18n }} +
+
+ {{ "atRiskPasswords" | i18n }} +
+ +
+
{{ member.email }}
+
{{ member.atRiskPasswordCount }}
+
+
+
+
+
+ + @if (dataService.isActiveDrawerType$(drawerTypes.AppAtRiskMembers) | async) { + + + +
+ {{ "atRiskMembersWithCount" | i18n: drawerDetails.appAtRiskMembers.members.length }}
- -
+
+ {{ + (drawerDetails.appAtRiskMembers.members.length > 0 + ? "atRiskMembersDescriptionWithApp" + : "atRiskMembersDescriptionWithAppNone" + ) | i18n: drawerDetails.appAtRiskMembers.applicationName + }} +
+
+
{{ member.email }}
-
{{ member.atRiskPasswordCount }}
-
- - - - - - - - - -
- {{ "atRiskMembersWithCount" | i18n: dataService.appAtRiskMembers.members.length }} -
-
- {{ - (dataService.appAtRiskMembers.members.length > 0 - ? "atRiskMembersDescriptionWithApp" - : "atRiskMembersDescriptionWithAppNone" - ) | i18n: dataService.appAtRiskMembers.applicationName - }} -
-
- -
{{ member.email }}
-
-
-
-
- - - - - - - {{ - (dataService.atRiskAppDetails.length > 0 - ? "atRiskApplicationsDescription" - : "atRiskApplicationsDescriptionNone" - ) | i18n - }} - -
-
- {{ "application" | i18n }} -
-
- {{ "atRiskPasswords" | i18n }} -
+
- -
-
{{ app.applicationName }}
-
{{ app.atRiskPasswordCount }}
+ + } + + @if (dataService.isActiveDrawerType$(drawerTypes.OrgAtRiskApps) | async) { + + + + + {{ + (drawerDetails.atRiskAppDetails.length > 0 + ? "atRiskApplicationsDescription" + : "atRiskApplicationsDescriptionNone" + ) | i18n + }} + +
+
+ {{ "application" | i18n }} +
+
+ {{ "atRiskPasswords" | i18n }} +
+ +
+
{{ app.applicationName }}
+
{{ app.atRiskPasswordCount }}
+
+
- -
- - + + } + + } 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 366c0875d97..528abed17a3 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,20 +2,15 @@ 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, Observable } from "rxjs"; +import { EMPTY } from "rxjs"; import { map, switchMap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { - CriticalAppsService, - RiskInsightsDataService, -} from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { - ApplicationHealthReportDetail, DrawerType, - PasswordHealthReportApplicationsResponse, -} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + ReportDetailsAndSummary, +} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, @@ -57,72 +52,80 @@ export enum RiskInsightsTabType { ], }) export class RiskInsightsComponent implements OnInit { + private destroyRef = inject(DestroyRef); + private _isDrawerOpen: boolean = false; + tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps; - dataLastUpdated: Date = new Date(); - - criticalApps$: Observable = new Observable(); - - appsCount: number = 0; - criticalAppsCount: number = 0; - notifiedMembersCount: number = 0; - - private organizationId: OrganizationId = "" as OrganizationId; - private destroyRef = inject(DestroyRef); - isLoading$: Observable = new Observable(); - isRefreshing$: Observable = new Observable(); - dataLastUpdated$: Observable = new Observable(); - refetching: boolean = false; + reportResults: ReportDetailsAndSummary | null = null; + isRunningReport: boolean = false; constructor( private route: ActivatedRoute, private router: Router, - private configService: ConfigService, protected dataService: RiskInsightsDataService, - private criticalAppsService: CriticalAppsService, - ) { - this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { - this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps; - }); - const orgId = this.route.snapshot.paramMap.get("organizationId") ?? ""; - this.criticalApps$ = this.criticalAppsService.getAppsListForOrg(orgId); - } + ) {} async ngOnInit() { + // Setup tabs from route + this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => { + this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps; + }); + + // Setup organization id from route this.route.paramMap .pipe( takeUntilDestroyed(this.destroyRef), map((params) => params.get("organizationId")), - switchMap((orgId) => { + switchMap(async (orgId) => { if (orgId) { - this.organizationId = orgId as OrganizationId; - this.dataService.fetchApplicationsReport(this.organizationId); - this.isLoading$ = this.dataService.isLoading$; - this.isRefreshing$ = this.dataService.isRefreshing$; - this.dataLastUpdated$ = this.dataService.dataLastUpdated$; - return this.dataService.applications$; + // Initialize the data service with the organization id + await this.dataService.initialize(orgId as OrganizationId); } else { return EMPTY; } }), ) - .subscribe({ - next: (applications: ApplicationHealthReportDetail[] | null) => { - if (applications) { - this.appsCount = applications.length; - } - this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId); - }, + .subscribe(); + + // Subscribe to drawer changes + this.dataService.drawerDetails$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((details) => { + this._isDrawerOpen = details.open; }); + + // Subscribe to report details + this.dataService.reportResults$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((reportResults) => (this.reportResults = reportResults)); + + // Subscribe to is running report flag + this.dataService.isRunningReport$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((isRunning) => (this.isRunningReport = isRunning)); } /** * Refreshes the data by re-fetching the applications report. * This will automatically notify child components subscribed to the RiskInsightsDataService observables. */ - refreshData(): void { - if (this.organizationId) { - this.dataService.refreshApplicationsReport(this.organizationId); + runReport = () => { + this.dataService.triggerReport(); + }; + + get isDrawerOpen() { + return this._isDrawerOpen; + } + + set isDrawerOpen(value: boolean) { + if (this._isDrawerOpen !== value) { + this._isDrawerOpen = value; + + // Close the drawer in the service if the drawer component closed the drawer + if (!value) { + this.dataService.closeDrawer(); + } } }