diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts index badd418ec3a..1ce21da4444 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts @@ -1,8 +1,14 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { OrganizationReportId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { MemberCipherDetailsResponse } from "../models"; import { + AtRiskApplicationDetail, + AtRiskMemberDetail, + MemberCipherDetailsResponse, +} from "../models"; +import { + ApplicationHealthReportDetail, MemberDetails, OrganizationReportSummary, RiskInsightsData, @@ -61,6 +67,7 @@ export function getUniqueMembers(orgMembers: MemberDetails[]): MemberDetails[] { */ export function createNewReportData(): RiskInsightsData { return { + id: "" as OrganizationReportId, creationDate: new Date(), reportData: [], summaryData: createNewSummaryData(), @@ -86,3 +93,60 @@ export function createNewSummaryData(): OrganizationReportSummary { newApplications: [], }; } +export function getAtRiskApplicationList( + cipherHealthReportDetails: ApplicationHealthReportDetail[], +): AtRiskApplicationDetail[] { + const applicationPasswordRiskMap = new Map(); + + cipherHealthReportDetails + .filter((app) => app.atRiskPasswordCount > 0) + .forEach((app) => { + const atRiskPasswordCount = applicationPasswordRiskMap.get(app.applicationName) ?? 0; + applicationPasswordRiskMap.set( + app.applicationName, + atRiskPasswordCount + app.atRiskPasswordCount, + ); + }); + + return Array.from(applicationPasswordRiskMap.entries()).map( + ([applicationName, atRiskPasswordCount]) => ({ + applicationName, + atRiskPasswordCount, + }), + ); +} +/** + * Generates a list of members with at-risk passwords along with the number of at-risk passwords. + */ +export function getAtRiskMemberList( + cipherHealthReportDetails: ApplicationHealthReportDetail[], +): AtRiskMemberDetail[] { + const memberRiskMap = new Map(); + + cipherHealthReportDetails.forEach((app) => { + app.atRiskMemberDetails.forEach((member) => { + const currentCount = memberRiskMap.get(member.email) ?? 0; + memberRiskMap.set(member.email, currentCount + 1); + }); + }); + + return Array.from(memberRiskMap.entries()).map(([email, atRiskPasswordCount]) => ({ + email, + atRiskPasswordCount, + })); +} + +/** + * Builds a map of passwords to the number of times they are used across ciphers + * + * @param ciphers List of ciphers to check for password reuse + * @returns A map where the key is the password and the value is the number of times it is used + */ +export function 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; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts new file mode 100644 index 00000000000..5824dede1f9 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts @@ -0,0 +1,44 @@ +import { MemberDetails } from "./report-models"; + +// -------------------- Drawer and UI Models -------------------- +// FIXME: update to use a const object instead of a typescript enum +// eslint-disable-next-line @bitwarden/platform/no-enums +export enum DrawerType { + None = 0, + AppAtRiskMembers = 1, + OrgAtRiskMembers = 2, + OrgAtRiskApps = 3, +} + +export type DrawerDetails = { + open: boolean; + invokerId: string; + activeDrawerType: DrawerType; + atRiskMemberDetails?: AtRiskMemberDetail[]; + appAtRiskMembers?: AppAtRiskMembersDialogParams | null; + atRiskAppDetails?: AtRiskApplicationDetail[] | null; +}; +export type AppAtRiskMembersDialogParams = { + members: MemberDetails[]; + applicationName: string; +}; + +/** + * Member email with the number of at risk passwords + * At risk member detail that contains the email + * and the count of at risk ciphers + */ + +export type AtRiskMemberDetail = { + email: string; + atRiskPasswordCount: number; +}; + +/* + * A list of applications and the count of + * at risk passwords for each application + */ +export type AtRiskApplicationDetail = { + applicationName: string; + atRiskPasswordCount: number; +}; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts index abe1f7200dc..c0364b12f87 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts @@ -3,3 +3,4 @@ export * from "./password-health"; export * from "./report-data-service.types"; export * from "./report-encryption.types"; export * from "./report-models"; +export * from "./drawer-models.types"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts index 47249eb583f..29016968a89 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts @@ -81,10 +81,12 @@ export const mockApplicationData: OrganizationReportApplication[] = [ { applicationName: "application1.com", isCritical: true, + reviewedDate: new Date(), }, { applicationName: "application2.com", isCritical: false, + reviewedDate: null, }, ]; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts index 09c4ae7f20b..2ec3ab0d6df 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts @@ -1,44 +1,12 @@ import { Opaque } from "type-fest"; +import { OrganizationReportId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant } from "@bitwarden/components"; import { ExposedPasswordDetail, WeakPasswordDetail } from "./password-health"; -// -------------------- Drawer and UI Models -------------------- -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum DrawerType { - None = 0, - AppAtRiskMembers = 1, - OrgAtRiskMembers = 2, - OrgAtRiskApps = 3, -} - -export type DrawerDetails = { - open: boolean; - invokerId: string; - activeDrawerType: DrawerType; - atRiskMemberDetails?: AtRiskMemberDetail[]; - appAtRiskMembers?: AppAtRiskMembersDialogParams | null; - atRiskAppDetails?: AtRiskApplicationDetail[] | null; -}; - -export type AppAtRiskMembersDialogParams = { - members: MemberDetails[]; - applicationName: string; -}; - // -------------------- Member Models -------------------- -/** - * Member email with the number of at risk passwords - * At risk member detail that contains the email - * and the count of at risk ciphers - */ -export type AtRiskMemberDetail = { - email: string; - atRiskPasswordCount: number; -}; /** * Flattened member details that associates an @@ -71,18 +39,6 @@ export type CipherHealthReport = { cipher: CipherView; }; -/** - * Breaks the cipher health info out by uri and passes - * along the password health and member info - */ -export type CipherApplicationView = { - cipherId: string; - cipher: CipherView; - cipherMembers: MemberDetails[]; - application: string; - healthData: PasswordHealthData; -}; - // -------------------- Application Health Report Models -------------------- /** * All applications report summary. The total members, @@ -91,21 +47,16 @@ export type CipherApplicationView = { */ export type OrganizationReportSummary = { totalMemberCount: number; - totalCriticalMemberCount: number; - totalAtRiskMemberCount: number; - totalCriticalAtRiskMemberCount: number; totalApplicationCount: number; - totalCriticalApplicationCount: number; + totalAtRiskMemberCount: number; totalAtRiskApplicationCount: number; + totalCriticalApplicationCount: number; + totalCriticalMemberCount: number; + totalCriticalAtRiskMemberCount: number; totalCriticalAtRiskApplicationCount: number; newApplications: string[]; }; -export type CriticalSummaryDetails = { - totalCriticalMembersCount: number; - totalCriticalApplicationsCount: number; -}; - /** * An entry for an organization application and if it is * marked as critical @@ -113,6 +64,7 @@ export type CriticalSummaryDetails = { export type OrganizationReportApplication = { applicationName: string; isCritical: boolean; + reviewedDate: Date | null; }; /** @@ -131,15 +83,6 @@ export type ApplicationHealthReportDetail = { cipherIds: string[]; }; -/* - * A list of applications and the count of - * at risk passwords for each application - */ -export type AtRiskApplicationDetail = { - applicationName: string; - atRiskPasswordCount: number; -}; - // -------------------- Password Health Report Models -------------------- export type PasswordHealthReportApplicationId = Opaque; @@ -152,6 +95,7 @@ export type ReportResult = CipherView & { }; export interface RiskInsightsData { + id: OrganizationReportId; creationDate: Date; reportData: ApplicationHealthReportDetail[]; summaryData: OrganizationReportSummary; @@ -163,3 +107,13 @@ export interface ReportState { error: string | null; data: RiskInsightsData | null; } + +// TODO Make Versioned models for structure changes +// export type VersionedRiskInsightsData = RiskInsightsDataV1 | RiskInsightsDataV2; +// export interface RiskInsightsDataV1 { +// version: 1; +// creationDate: Date; +// reportData: ApplicationHealthReportDetail[]; +// summaryData: OrganizationReportSummary; +// applicationData: OrganizationReportApplication[]; +// } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.spec.ts index 3bcd6b6ce09..d6e615ee58a 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.spec.ts @@ -228,11 +228,12 @@ describe("RiskInsightsApiService", () => { it("updateRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => { const reportId = "report123" as OrganizationReportId; - const mockApplication = mockApplicationData[0]; + // TODO Update to be encrypted test + const mockApplication = makeEncString("application-data"); mockApiService.send.mockResolvedValueOnce(undefined); const result = await firstValueFrom( - service.updateRiskInsightsApplicationData$(mockApplication, orgId, reportId), + service.updateRiskInsightsApplicationData$(mockApplication.encryptedString, orgId, reportId), ); expect(mockApiService.send).toHaveBeenCalledWith( "PATCH", diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.ts index 927a65c33d7..d71f84cc5b2 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.ts @@ -4,7 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; -import { EncryptedDataWithKey, OrganizationReportApplication } from "../../models"; +import { EncryptedDataWithKey } from "../../models"; import { GetRiskInsightsApplicationDataResponse, GetRiskInsightsReportResponse, @@ -102,7 +102,7 @@ export class RiskInsightsApiService { } updateRiskInsightsApplicationData$( - applicationData: OrganizationReportApplication, + applicationData: string, orgId: OrganizationId, reportId: OrganizationReportId, ): Observable { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.ts index a3ca3fc31e8..4186880236c 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.ts @@ -139,10 +139,11 @@ export class CriticalAppsService { return; } - await this.criticalAppsApiService.dropCriticalApp({ - organizationId: app.organizationId, - passwordHealthReportApplicationIds: [app.id], - }); + // TODO Uncomment when done testing that the migration is working + // await this.criticalAppsApiService.dropCriticalApp({ + // organizationId: app.organizationId, + // passwordHealthReportApplicationIds: [app.id], + // }); this.criticalAppsListSubject$.next( this.criticalAppsListSubject$.value.filter((f) => f.uri !== selectedUrl), diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts index 913347b9b1c..1ae37ea447b 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts @@ -3,7 +3,9 @@ import { of, throwError } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { LogService } from "@bitwarden/logging"; import { createNewSummaryData } from "../../helpers"; import { ReportState } from "../../models"; @@ -12,8 +14,12 @@ import { mockEnrichedReportData, mockSummaryData, } from "../../models/mocks/mock-data"; +import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service"; +import { RiskInsightsApiService } from "../api/risk-insights-api.service"; import { CriticalAppsService } from "./critical-apps.service"; +import { PasswordHealthService } from "./password-health.service"; +import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; import { RiskInsightsOrchestratorService } from "./risk-insights-orchestrator.service"; import { RiskInsightsReportService } from "./risk-insights-report.service"; @@ -24,6 +30,7 @@ describe("RiskInsightsOrchestratorService", () => { const mockOrgId = "org-789" as OrganizationId; const mockOrgName = "Test Org"; const mockUserId = "user-101" as UserId; + const mockReportId = "report-1" as OrganizationReportId; // Mock services const mockAccountService = mock({ @@ -33,14 +40,26 @@ describe("RiskInsightsOrchestratorService", () => { criticalAppsList$: of([]), }); const mockOrganizationService = mock(); + const mockCipherService = mock(); + const mockMemberCipherDetailsApiService = mock(); + const mockPasswordHealthService = mock(); + const mockReportApiService = mock(); const mockReportService = mock(); + const mockRiskInsightsEncryptionService = mock(); + const mockLogService = mock(); beforeEach(() => { service = new RiskInsightsOrchestratorService( mockAccountService, + mockCipherService, mockCriticalAppsService, + mockMemberCipherDetailsApiService, mockOrganizationService, + mockPasswordHealthService, + mockReportApiService, mockReportService, + mockRiskInsightsEncryptionService, + mockLogService, ); }); @@ -53,6 +72,7 @@ describe("RiskInsightsOrchestratorService", () => { loading: false, error: null, data: { + id: mockReportId, reportData: [], summaryData: createNewSummaryData(), applicationData: [], @@ -106,14 +126,14 @@ describe("RiskInsightsOrchestratorService", () => { }); describe("generateReport", () => { - it("should call reportService.generateApplicationsReport$ and saveRiskInsightsReport$ and emit ReportState", (done) => { + it("should call reportService.generateApplicationsReport and saveRiskInsightsReport$ and emit ReportState", (done) => { const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"]; const privateUserIdSubject = service["_userIdSubject"]; // Arrange - mockReportService.generateApplicationsReport$.mockReturnValueOnce(of(mockEnrichedReportData)); - mockReportService.generateApplicationsSummary.mockReturnValueOnce(mockSummaryData); - mockReportService.generateOrganizationApplications.mockReturnValueOnce(mockApplicationData); + mockReportService.generateApplicationsReport.mockReturnValueOnce(mockEnrichedReportData); + mockReportService.getApplicationsSummary.mockReturnValueOnce(mockSummaryData); + mockReportService.getOrganizationApplications.mockReturnValueOnce(mockApplicationData); mockReportService.saveRiskInsightsReport$.mockReturnValueOnce(of(null)); privateOrganizationDetailsSubject.next({ organizationId: mockOrgId, @@ -127,7 +147,7 @@ describe("RiskInsightsOrchestratorService", () => { // Assert service.rawReportData$.subscribe((state) => { if (!state.loading) { - expect(mockReportService.generateApplicationsReport$).toHaveBeenCalledWith(mockOrgId); + expect(mockReportService.generateApplicationsReport).toHaveBeenCalledWith(mockOrgId); expect(mockReportService.saveRiskInsightsReport$).toHaveBeenCalledWith( mockEnrichedReportData, mockSummaryData, @@ -142,35 +162,13 @@ describe("RiskInsightsOrchestratorService", () => { }); }); - it("should emit error ReportState when generateApplicationsReport$ throws", (done) => { - const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"]; - const privateUserIdSubject = service["_userIdSubject"]; - - mockReportService.generateApplicationsReport$.mockReturnValueOnce( - throwError(() => new Error("Generate error")), - ); - privateOrganizationDetailsSubject.next({ - organizationId: mockOrgId, - organizationName: mockOrgName, - }); - privateUserIdSubject.next(mockUserId); - service.generateReport(); - service.rawReportData$.subscribe((state) => { - if (!state.loading) { - expect(state.error).toBe("Failed to generate or save report"); - expect(state.data).toBeNull(); - done(); - } - }); - }); - it("should emit error ReportState when saveRiskInsightsReport$ throws", (done) => { const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"]; const privateUserIdSubject = service["_userIdSubject"]; - mockReportService.generateApplicationsReport$.mockReturnValueOnce(of(mockEnrichedReportData)); - mockReportService.generateApplicationsSummary.mockReturnValueOnce(mockSummaryData); - mockReportService.generateOrganizationApplications.mockReturnValueOnce(mockApplicationData); + mockReportService.generateApplicationsReport.mockReturnValueOnce(mockEnrichedReportData); + mockReportService.getApplicationsSummary.mockReturnValueOnce(mockSummaryData); + mockReportService.getOrganizationApplications.mockReturnValueOnce(mockApplicationData); mockReportService.saveRiskInsightsReport$.mockReturnValueOnce( throwError(() => new Error("Save error")), ); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts index bb9f65d6065..fa245d329e8 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts @@ -1,6 +1,7 @@ import { BehaviorSubject, combineLatest, + forkJoin, from, merge, Observable, @@ -30,13 +31,26 @@ import { } 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 { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LogService } from "@bitwarden/logging"; +import { buildPasswordUseMap, flattenMemberDetails, getTrimmedCipherUris } from "../../helpers"; import { ApplicationHealthReportDetailEnriched } from "../../models"; import { RiskInsightsEnrichedData } from "../../models/report-data-service.types"; -import { ReportState } from "../../models/report-models"; +import { + CipherHealthReport, + MemberDetails, + OrganizationReportApplication, + ReportState, +} from "../../models/report-models"; +import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service"; +import { RiskInsightsApiService } from "../api/risk-insights-api.service"; import { CriticalAppsService } from "./critical-apps.service"; +import { PasswordHealthService } from "./password-health.service"; +import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; import { RiskInsightsReportService } from "./risk-insights-report.service"; export class RiskInsightsOrchestratorService { @@ -54,6 +68,10 @@ export class RiskInsightsOrchestratorService { } | null>(null); organizationDetails$ = this._organizationDetailsSubject.asObservable(); + // ------------------------- Raw data ------------------------- + private _ciphersSubject = new BehaviorSubject(null); + private _ciphers$ = this._ciphersSubject.asObservable(); + // ------------------------- Report Variables ---------------- private _rawReportDataSubject = new BehaviorSubject({ loading: true, @@ -78,19 +96,28 @@ export class RiskInsightsOrchestratorService { constructor( private accountService: AccountService, + private cipherService: CipherService, private criticalAppsService: CriticalAppsService, + private memberCipherDetailsApiService: MemberCipherDetailsApiService, private organizationService: OrganizationService, + private passwordHealthService: PasswordHealthService, + private reportApiService: RiskInsightsApiService, private reportService: RiskInsightsReportService, + private riskInsightsEncryptionService: RiskInsightsEncryptionService, + private logService: LogService, ) { + this.logService.debug("[RiskInsightsOrchestratorService] Setting up"); this._setupCriticalApplicationContext(); this._setupCriticalApplicationReport(); this._setupEnrichedReportData(); this._setupInitializationPipeline(); + this._setupMigrationAndCleanup(); this._setupReportState(); this._setupUserId(); } destroy(): void { + this.logService.debug("[RiskInsightsOrchestratorService] Destroying"); if (this._reportStateSubscription) { this._reportStateSubscription.unsubscribe(); } @@ -98,19 +125,11 @@ export class RiskInsightsOrchestratorService { this._destroy$.complete(); } - /** - * Initializes the service context for a specific organization - * - * @param organizationId The ID of the organization to initialize context for - */ - initializeForOrganization(organizationId: OrganizationId) { - this._initializeOrganizationTriggerSubject.next(organizationId); - } - /** * Fetches the latest report for the current organization and user */ fetchReport(): void { + this.logService.debug("[RiskInsightsOrchestratorService] Fetch report triggered"); this._fetchReportTriggerSubject.next(); } @@ -118,16 +137,125 @@ export class RiskInsightsOrchestratorService { * Generates a new report for the current organization and user */ generateReport(): void { + this.logService.debug("[RiskInsightsOrchestratorService] Create new report triggered"); this._generateReportTriggerSubject.next(true); } + /** + * Initializes the service context for a specific organization + * + * @param organizationId The ID of the organization to initialize context for + */ + initializeForOrganization(organizationId: OrganizationId) { + this.logService.debug("[RiskInsightsOrchestratorService] Initializing for org", organizationId); + this._initializeOrganizationTriggerSubject.next(organizationId); + } + + setCriticalApplications$(criticalApplications: string[]): Observable { + return this.rawReportData$.pipe( + take(1), + withLatestFrom(this.organizationDetails$, this._userId$), + map(([reportState, organizationDetails, userId]) => { + if (!organizationDetails) { + this.logService.warning( + "[RiskInsightsOrchestratorService] No organization details available when setting critical applications.", + ); + return { + reportState, + organizationDetails: null, + updatedState: reportState, + }; // Return current state if no org details + } + + // Handle the case where there is no report data + if (!reportState?.data) { + this.logService.warning( + "[RiskInsightsOrchestratorService] Attempted to set critical applications with no report data.", + ); + return { + reportState, + organizationDetails, + updatedState: reportState, + }; + } + + // Create a set for quick lookup of the new critical apps + const newCriticalAppNamesSet = new Set(criticalApplications); + + const existingApplicationData = reportState.data.applicationData || []; + const updatedApplicationData = this._mergeApplicationData( + existingApplicationData, + newCriticalAppNamesSet, + ); + + const updatedState: ReportState = { + ...reportState, + data: { + ...reportState.data, + applicationData: updatedApplicationData, + }, + }; + + this.logService.debug( + "[RiskInsightsOrchestratorService] Updated applications data", + updatedState, + ); + return { reportState, organizationDetails, updatedState, userId }; + }), + switchMap(({ reportState, organizationDetails, updatedState, userId }) => { + return from( + this.riskInsightsEncryptionService.encryptRiskInsightsReport( + { + organizationId: organizationDetails!.organizationId, + userId, + }, + { + reportData: reportState.data.reportData, + summaryData: reportState.data.summaryData, + applicationData: updatedState.data.applicationData, + }, + ), + ).pipe( + map((encryptedData) => ({ + reportState, + organizationDetails, + updatedState, + encryptedData, + })), + ); + }), + switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => { + // Chain the save operation using switchMap + return this.reportApiService + .updateRiskInsightsApplicationData$( + encryptedData.encryptedApplicationData.encryptedString, + organizationDetails.organizationId, + reportState.data.id, + ) + .pipe( + // Map the result of the save operation to the updated state + map(() => updatedState), + // Use tap to push the updated state to the subject + tap((finalState) => this._rawReportDataSubject.next(finalState)), + // Handle errors from the save operation + catchError((error: unknown) => { + this.logService.error("Failed to save updated applicationData", error); + return of({ ...reportState, error: "Failed to save application data" }); + }), + ); + }), + ); + } + private _fetchReport$(organizationId: OrganizationId, userId: UserId): Observable { return this.reportService.getRiskInsightsReport$(organizationId, userId).pipe( + tap(() => this.logService.debug("[RiskInsightsOrchestratorService] Fetching report")), map( - ({ reportData, summaryData, applicationData, creationDate }): ReportState => ({ + ({ id, reportData, summaryData, applicationData, creationDate }): ReportState => ({ loading: false, error: null, data: { + id, reportData, summaryData, applicationData, @@ -140,16 +268,30 @@ export class RiskInsightsOrchestratorService { ); } - private _generateApplicationsReport$( + private _generateNewApplicationsReport$( organizationId: OrganizationId, userId: UserId, ): Observable { // Generate the report - return this.reportService.generateApplicationsReport$(organizationId).pipe( - map((enrichedReport) => ({ - report: enrichedReport, - summary: this.reportService.generateApplicationsSummary(enrichedReport), - applications: this.reportService.generateOrganizationApplications(enrichedReport), + const memberCiphers$ = from( + this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), + ).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers))); + + return forkJoin([this._ciphers$, memberCiphers$]).pipe( + tap(() => this.logService.debug("[RiskInsightsOrchestratorService] Generating new report")), + switchMap(([ciphers, memberCiphers]) => this._getCipherHealth(ciphers, memberCiphers)), + map((cipherHealthReports) => + this.reportService.generateApplicationsReport(cipherHealthReports), + ), + withLatestFrom(this.rawReportData$), + + map(([report, previousReport]) => ({ + report: report, + summary: this.reportService.getApplicationsSummary(report), + applications: this.reportService.getOrganizationApplications( + report, + previousReport.data.applicationData, + ), })), switchMap(({ report, summary, applications }) => // Save the report after enrichment @@ -172,6 +314,7 @@ export class RiskInsightsOrchestratorService { loading: false, error: null, data: { + id: "" as OrganizationReportId, reportData: report, summaryData: summary, applicationData: applications, @@ -186,6 +329,106 @@ export class RiskInsightsOrchestratorService { ); } + /** + * Associates the members with the ciphers they have access to. Calculates the password health. + * Finds the trimmed uris. + * @param ciphers Org ciphers + * @param memberDetails Org members + * @returns Cipher password health data with trimmed uris and associated members + */ + private _getCipherHealth( + ciphers: CipherView[], + memberDetails: MemberDetails[], + ): Observable { + const validCiphers = ciphers.filter((cipher) => + this.passwordHealthService.isValidCipher(cipher), + ); + const passwordUseMap = buildPasswordUseMap(validCiphers); + + // Check for exposed passwords and map to cipher health report + return this.passwordHealthService.auditPasswordLeaks$(validCiphers).pipe( + map((exposedDetails) => { + return validCiphers.map((cipher) => { + const exposedPasswordDetail = exposedDetails.find((x) => x?.cipherId === cipher.id); + const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id); + const applications = getTrimmedCipherUris(cipher); + const weakPasswordDetail = this.passwordHealthService.findWeakPasswordDetails(cipher); + const reusedPasswordCount = passwordUseMap.get(cipher.login.password!) ?? 0; + + return { + cipher, + cipherMembers, + applications, + healthData: { + weakPasswordDetail, + reusedPasswordCount, + exposedPasswordDetail, + }, + }; + }); + }), + ); + } + + private _runMigrationAndCleanup$(): Observable { + // Start with rawReportData$ to ensure it has a value + return this.rawReportData$.pipe( + // Ensure rawReportData has a data payload + filter((reportState) => !!reportState.data), + take(1), // Use the first valid report state + // Now switch to the migration logic + switchMap((rawReportData) => + this.criticalAppsService.criticalAppsList$.pipe( + take(1), + withLatestFrom(this.organizationDetails$), + switchMap(([savedCriticalApps, organizationDetails]) => { + // Check if there are any critical apps to migrate. + if (!savedCriticalApps || savedCriticalApps.length === 0) { + this.logService.debug( + "[RiskInsightsOrchestratorService] No critical apps to migrate.", + ); + return of([]); + } + + // Map the saved critical apps to the new format + const migratedApps = savedCriticalApps.map( + (app): OrganizationReportApplication => ({ + applicationName: app.uri, + isCritical: true, + reviewedDate: null, + }), + ); + + // Use the setCriticalApplications$ function to update and save the report + return this.setCriticalApplications$( + migratedApps.map((app) => app.applicationName), + ).pipe( + // After setCriticalApplications$ completes, trigger the deletion. + switchMap(() => { + const deleteObservables = savedCriticalApps.map( + (app) => of(null), + // this.criticalAppsService.dropCriticalApp( + // organizationDetails!.organizationId, + // app.id, + // ), + ); + return forkJoin(deleteObservables).pipe( + // After all deletes complete, map to the migrated apps. + map(() => { + this.logService.debug( + "[RiskInsightsOrchestratorService] Migrated and deleted critical applications.", + ); + return migratedApps; + }), + ); + }), + ); + }), + ), + ), + ); + } + // Setup the pipeline to load critical applications when organization or user changes private _setupCriticalApplicationContext() { this.organizationDetails$ @@ -194,6 +437,10 @@ export class RiskInsightsOrchestratorService { withLatestFrom(this._userId$), filter(([_, userId]) => !!userId), tap(([orgDetails, userId]) => { + this.logService.debug( + "[RiskInsightsOrchestratorService] Loading critical applications for org", + orgDetails!.organizationId, + ); this.criticalAppsService.loadOrganizationContext(orgDetails!.organizationId, userId!); }), takeUntil(this._destroy$), @@ -206,16 +453,22 @@ export class RiskInsightsOrchestratorService { const criticalReportResultsPipeline$ = this.enrichedReportData$.pipe( filter((state) => !!state), map((enrichedReports) => { + this.logService.debug( + "[RiskInsightsOrchestratorService] Generating critical applications report from", + enrichedReports, + ); const criticalApplications = enrichedReports!.reportData.filter( (app) => app.isMarkedAsCritical, ); - const summary = this.reportService.generateApplicationsSummary(criticalApplications); + // Generate a new summary based on just the critical applications + const summary = this.reportService.getApplicationsSummary(criticalApplications); return { ...enrichedReports, summaryData: summary, reportData: criticalApplications, }; }), + shareReplay({ bufferSize: 1, refCount: true }), ); this.criticalReportResults$ = criticalReportResultsPipeline$; @@ -229,27 +482,29 @@ export class RiskInsightsOrchestratorService { // Setup the enriched report data pipeline const enrichmentSubscription = combineLatest([ this.rawReportData$.pipe(filter((data) => !!data)), - this.organizationDetails$.pipe(filter((details) => !!details)), - this.criticalAppsService.criticalAppsList$.pipe(filter((list) => !!list)), + this._ciphers$.pipe(filter((data) => !!data)), ]).pipe( - switchMap(([rawReportData, orgDetails, criticalApps]) => { - const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.uri)); - const rawReports = rawReportData.data?.reportData || []; - return from( - this.reportService.getApplicationCipherMap(rawReports, orgDetails!.organizationId), - ).pipe( - map((cipherMap) => { - return rawReports.map((app) => ({ - ...app, - ciphers: cipherMap.get(app.applicationName) || [], - isMarkedAsCritical: criticalApplicationNames.has(app.applicationName), - })) as ApplicationHealthReportDetailEnriched[]; - }), - map((enrichedReportData) => ({ ...rawReportData.data, reportData: enrichedReportData })), - catchError(() => { - return of(null); - }), + switchMap(([rawReportData, ciphers]) => { + this.logService.debug( + "[RiskInsightsOrchestratorService] Enriching report data with ciphers and critical app status", ); + const criticalApps = rawReportData?.data?.applicationData.filter((app) => app.isCritical); + const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.applicationName)); + const rawReports = rawReportData.data?.reportData || []; + const cipherMap = this.reportService.getApplicationCipherMap(ciphers, rawReports); + + const enrichedReports: ApplicationHealthReportDetailEnriched[] = rawReports.map((app) => ({ + ...app, + ciphers: cipherMap.get(app.applicationName) || [], + isMarkedAsCritical: criticalApplicationNames.has(app.applicationName), + })); + + const enrichedData: RiskInsightsEnrichedData = { + ...rawReportData.data, + reportData: enrichedReports, + }; + + return of(enrichedData); }), shareReplay({ bufferSize: 1, refCount: true }), ); @@ -271,11 +526,50 @@ export class RiskInsightsOrchestratorService { map((org) => ({ organizationId: orgId, organizationName: org.name })), ), ), + tap(async (orgDetails) => { + this.logService.debug("[RiskInsightsOrchestratorService] Fetching organization ciphers"); + const ciphers = await this.cipherService.getAllFromApiForOrganization( + orgDetails.organizationId, + ); + this._ciphersSubject.next(ciphers); + }), takeUntil(this._destroy$), ) .subscribe((orgDetails) => this._organizationDetailsSubject.next(orgDetails)); } + private _setupMigrationAndCleanup() { + this.criticalAppsService.criticalAppsList$ + .pipe( + filter((criticalApps) => criticalApps.length > 0), + tap(() => { + this.logService.debug( + "[RiskInsightsOrchestratorService] Detected legacy critical apps, running migration and cleanup.", + ); + }), + switchMap(() => + this._runMigrationAndCleanup$().pipe( + tap((migratedApps) => { + if (migratedApps.length > 0) { + this.logService.debug( + "[RiskInsightsOrchestratorService] Migration and cleanup completed.", + migratedApps, + ); + } + }), + catchError((error: unknown) => { + this.logService.error( + "[RiskInsightsOrchestratorService] Migration and cleanup failed.", + error, + ); + return of([]); + }), + ), + ), + ) + .subscribe(); + } + // Setup the report state management pipeline private _setupReportState() { // Dependencies needed for report state @@ -285,14 +579,9 @@ export class RiskInsightsOrchestratorService { ]).pipe(shareReplay({ bufferSize: 1, refCount: true })); // A stream for the initial report fetch (triggered by critical apps loading) - const initialReportLoad$ = combineLatest([ - this.criticalAppsService.criticalAppsList$, - reportDependencies$, - ]).pipe( + const initialReportLoad$ = reportDependencies$.pipe( take(1), // Fetch only once on initial data load - exhaustMap(([_, [orgDetails, userId]]) => - this._fetchReport$(orgDetails!.organizationId, userId!), - ), + exhaustMap(([orgDetails, userId]) => this._fetchReport$(orgDetails!.organizationId, userId!)), ); // A stream for manually triggered fetches @@ -309,7 +598,7 @@ export class RiskInsightsOrchestratorService { filter((isRunning) => isRunning), withLatestFrom(reportDependencies$), exhaustMap(([_, [orgDetails, userId]]) => - this._generateApplicationsReport$(orgDetails!.organizationId, userId), + this._generateNewApplicationsReport$(orgDetails!.organizationId, userId), ), ); @@ -332,6 +621,7 @@ export class RiskInsightsOrchestratorService { this._reportStateSubscription = mergedReportState$ .pipe(takeUntil(this._destroy$)) .subscribe((state) => { + this.logService.debug("[RiskInsightsOrchestratorService] Updating report state", state); this._rawReportDataSubject.next(state); }); } @@ -343,4 +633,19 @@ export class RiskInsightsOrchestratorService { this._userIdSubject.next(userId); }); } + + private _mergeApplicationData( + existingApps: OrganizationReportApplication[], + newCriticalAppNamesSet: Set, + ): OrganizationReportApplication[] { + // First, iterate through the existing apps and update their isCritical flag + const updatedApps = existingApps.map((app) => { + return { + ...app, + isCritical: newCriticalAppNamesSet.has(app.applicationName) ?? app.isCritical, + }; + }); + + return updatedApps; + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts index 2e2601d0ace..d53c8f08f28 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts @@ -79,9 +79,6 @@ describe("RiskInsightsReportService", () => { }); service = new RiskInsightsReportService( - cipherService, - memberCipherDetailsService, - mockPasswordHealthService, mockRiskInsightsApiService, mockRiskInsightsEncryptionService, ); @@ -93,23 +90,21 @@ describe("RiskInsightsReportService", () => { }; }); - it("should group and aggregate application health reports correctly", (done) => { + it("should group and aggregate application health reports correctly", () => { // Mock the service methods cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCipherViews); memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberDetails); - service.generateApplicationsReport$("orgId" as any).subscribe((result) => { - expect(Array.isArray(result)).toBe(true); + const result = service.generateApplicationsReport("orgId" as any); + expect(Array.isArray(result)).toBe(true); - // Should group by application name (trimmedUris) - const appCom = result.find((r) => r.applicationName === "app.com"); - const otherCom = result.find((r) => r.applicationName === "other.com"); - expect(appCom).toBeTruthy(); - expect(appCom?.passwordCount).toBe(2); - expect(otherCom).toBeTruthy(); - expect(otherCom?.passwordCount).toBe(1); - done(); - }); + // Should group by application name (trimmedUris) + const appCom = result.find((r) => r.applicationName === "app.com"); + const otherCom = result.find((r) => r.applicationName === "other.com"); + expect(appCom).toBeTruthy(); + expect(appCom?.passwordCount).toBe(2); + expect(otherCom).toBeTruthy(); + expect(otherCom?.passwordCount).toBe(1); }); describe("saveRiskInsightsReport$", () => { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts index 317aa13c74a..27b3c1b496c 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts @@ -1,25 +1,9 @@ -import { - catchError, - EMPTY, - forkJoin, - from, - map, - Observable, - of, - switchMap, - throwError, -} from "rxjs"; +import { catchError, EMPTY, from, map, Observable, of, switchMap, throwError } from "rxjs"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { - createNewReportData, - flattenMemberDetails, - getTrimmedCipherUris, - getUniqueMembers, -} from "../../helpers/risk-insights-data-mappers"; +import { createNewReportData, getUniqueMembers } from "../../helpers/risk-insights-data-mappers"; import { isSaveRiskInsightsReportResponse, SaveRiskInsightsReportResponse, @@ -27,109 +11,34 @@ import { import { ApplicationHealthReportDetail, OrganizationReportSummary, - AtRiskApplicationDetail, - AtRiskMemberDetail, CipherHealthReport, - MemberDetails, PasswordHealthData, OrganizationReportApplication, RiskInsightsData, } from "../../models/report-models"; -import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service"; import { RiskInsightsApiService } from "../api/risk-insights-api.service"; -import { PasswordHealthService } from "./password-health.service"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; export class RiskInsightsReportService { - // [FIXME] CipherData - // Cipher data - // private _ciphersSubject = new BehaviorSubject(null); - // _ciphers$ = this._ciphersSubject.asObservable(); - constructor( - private cipherService: CipherService, - private memberCipherDetailsApiService: MemberCipherDetailsApiService, - private passwordHealthService: PasswordHealthService, private riskInsightsApiService: RiskInsightsApiService, private riskInsightsEncryptionService: RiskInsightsEncryptionService, ) {} - // [FIXME] CipherData - // async loadCiphersForOrganization(organizationId: OrganizationId): Promise { - // await this.cipherService.getAllFromApiForOrganization(organizationId).then((ciphers) => { - // this._ciphersSubject.next(ciphers); - // }); - // } - /** * Report data for the aggregation of uris to like uris and getting password/member counts, * members, and at risk statuses. * - * @param organizationId Id of the organization + * @param ciphers The list of ciphers to analyze + * @param memberCiphers The list of member cipher details to associate members to ciphers * @returns The all applications health report data */ - generateApplicationsReport$( - organizationId: OrganizationId, - ): Observable { - const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId)); - const memberCiphers$ = from( - this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), - ).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers))); + generateApplicationsReport(ciphers: CipherHealthReport[]): ApplicationHealthReportDetail[] { + const groupedByApplication = this._groupCiphersByApplication(ciphers); - 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), - ); - }), - ); - } - - /** - * Generates a list of members with at-risk passwords along with the number of at-risk passwords. - */ - generateAtRiskMemberList( - cipherHealthReportDetails: ApplicationHealthReportDetail[], - ): AtRiskMemberDetail[] { - const memberRiskMap = new Map(); - - cipherHealthReportDetails.forEach((app) => { - app.atRiskMemberDetails.forEach((member) => { - const currentCount = memberRiskMap.get(member.email) ?? 0; - memberRiskMap.set(member.email, currentCount + 1); - }); - }); - - return Array.from(memberRiskMap.entries()).map(([email, atRiskPasswordCount]) => ({ - email, - atRiskPasswordCount, - })); - } - - generateAtRiskApplicationList( - cipherHealthReportDetails: ApplicationHealthReportDetail[], - ): AtRiskApplicationDetail[] { - const applicationPasswordRiskMap = new Map(); - - cipherHealthReportDetails - .filter((app) => app.atRiskPasswordCount > 0) - .forEach((app) => { - const atRiskPasswordCount = applicationPasswordRiskMap.get(app.applicationName) ?? 0; - applicationPasswordRiskMap.set( - app.applicationName, - atRiskPasswordCount + app.atRiskPasswordCount, - ); - }); - - return Array.from(applicationPasswordRiskMap.entries()).map( - ([applicationName, atRiskPasswordCount]) => ({ - applicationName, - atRiskPasswordCount, - }), + return Array.from(groupedByApplication.entries()).map(([application, ciphers]) => + this._getApplicationHealthReport(application, ciphers), ); } @@ -139,7 +48,7 @@ export class RiskInsightsReportService { * @param reports The previously calculated application health report data * @returns A summary object containing report totals */ - generateApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary { + getApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary { const totalMembers = reports.flatMap((x) => x.memberDetails); const uniqueMembers = getUniqueMembers(totalMembers); @@ -182,13 +91,32 @@ export class RiskInsightsReportService { * @param reports * @returns A list of applications with a critical marking flag */ - generateOrganizationApplications( + getOrganizationApplications( reports: ApplicationHealthReportDetail[], + previousApplications: OrganizationReportApplication[] = [], ): OrganizationReportApplication[] { - return reports.map((report) => ({ - applicationName: report.applicationName, - isCritical: false, - })); + if (previousApplications.length > 0) { + // Preserve existing critical application markings and dates + return reports.map((report) => { + const existingApp = previousApplications.find( + (app) => app.applicationName === report.applicationName, + ); + return { + applicationName: report.applicationName, + isCritical: existingApp ? existingApp.isCritical : false, + reviewedDate: existingApp ? existingApp.reviewedDate : null, + }; + }); + } + + // No previous applications, return all as non-critical with current date + return reports.map( + (report): OrganizationReportApplication => ({ + applicationName: report.applicationName, + isCritical: false, + reviewedDate: null, + }), + ); } /** @@ -236,6 +164,7 @@ export class RiskInsightsReportService { ), ).pipe( map((decryptedData) => ({ + id: response.id as OrganizationReportId, reportData: decryptedData.reportData, summaryData: decryptedData.summaryData, applicationData: decryptedData.applicationData, @@ -319,28 +248,12 @@ export class RiskInsightsReportService { ); } - 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) => { - // Warning: Currently does not show ciphers with NO Application - // if (cipher.applications.length === 0) { - // const existingApplication = applicationMap.get("None") || []; - // existingApplication.push(cipher); - // applicationMap.set("None", existingApplication); - // } - cipher.applications.forEach((application) => { const existingApplication = applicationMap.get(application) || []; existingApplication.push(cipher); @@ -357,19 +270,13 @@ export class RiskInsightsReportService { * @param organizationId * @returns */ - async getApplicationCipherMap( + getApplicationCipherMap( + ciphers: CipherView[], applications: ApplicationHealthReportDetail[], - organizationId: OrganizationId, - ): Promise> { - // [FIXME] CipherData - // This call is made multiple times. We can optimize this - // by loading the ciphers once via a load method to avoid multiple API calls - // for the same organization - const allCiphers = await this.cipherService.getAllFromApiForOrganization(organizationId); + ): Map { const cipherMap = new Map(); - applications.forEach((app) => { - const filteredCiphers = allCiphers.filter((c) => app.cipherIds.includes(c.id)); + const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id)); cipherMap.set(app.applicationName, filteredCiphers); }); return cipherMap; @@ -465,42 +372,4 @@ export class RiskInsightsReportService { healthData.reusedPasswordCount > 1 ); } - /** - * Associates the members with the ciphers they have access to. Calculates the password health. - * Finds the trimmed uris. - * @param ciphers Org ciphers - * @param memberDetails Org members - * @returns Cipher password health data with trimmed uris and associated members - */ - private _getCipherDetails( - ciphers: CipherView[], - memberDetails: MemberDetails[], - ): Observable { - const validCiphers = ciphers.filter((cipher) => - this.passwordHealthService.isValidCipher(cipher), - ); - // Build password use map - const passwordUseMap = this._buildPasswordUseMap(validCiphers); - - 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); - - 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; - }); - }), - ); - } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts index 6f8174e16f2..814062ba83f 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts @@ -3,8 +3,8 @@ import { catchError, distinctUntilChanged, exhaustMap, map } from "rxjs/operator import { OrganizationId } from "@bitwarden/common/types/guid"; -import { RiskInsightsEnrichedData } from "../../models/report-data-service.types"; -import { DrawerType, DrawerDetails, ReportState } from "../../models/report-models"; +import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers"; +import { ReportState, DrawerDetails, DrawerType, RiskInsightsEnrichedData } from "../../models"; import { CriticalAppsService } from "../domain/critical-apps.service"; import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service"; import { RiskInsightsReportService } from "../domain/risk-insights-report.service"; @@ -113,9 +113,7 @@ export class RiskInsightsDataService { return; } - const atRiskMemberDetails = this.reportService.generateAtRiskMemberList( - reportResults.reportData, - ); + const atRiskMemberDetails = getAtRiskMemberList(reportResults.reportData); this.drawerDetailsSubject.next({ open: true, @@ -170,9 +168,7 @@ export class RiskInsightsDataService { if (!reportResults) { return; } - const atRiskAppDetails = this.reportService.generateAtRiskApplicationList( - reportResults.reportData, - ); + const atRiskAppDetails = getAtRiskApplicationList(reportResults.reportData); this.drawerDetailsSubject.next({ open: true, @@ -187,6 +183,7 @@ export class RiskInsightsDataService { // ------------------------------ Critical application methods -------------- saveCriticalApplications(selectedUrls: string[]) { + this.orchestrator.setCriticalApplications$(selectedUrls); return this.organizationDetails$.pipe( exhaustMap((organizationDetails) => { if (!organizationDetails?.organizationId) { 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 67cad64f711..1913f5e8001 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 @@ -23,6 +23,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { KeyService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module"; import { RiskInsightsComponent } from "./risk-insights.component"; @@ -48,21 +49,21 @@ import { RiskInsightsComponent } from "./risk-insights.component"; safeProvider({ provide: RiskInsightsReportService, useClass: RiskInsightsReportService, - deps: [ - CipherService, - MemberCipherDetailsApiService, - PasswordHealthService, - RiskInsightsApiService, - RiskInsightsEncryptionService, - ], + deps: [RiskInsightsApiService, RiskInsightsEncryptionService], }), safeProvider({ provide: RiskInsightsOrchestratorService, deps: [ AccountServiceAbstraction, + CipherService, CriticalAppsService, + MemberCipherDetailsApiService, OrganizationService, + PasswordHealthService, + RiskInsightsApiService, RiskInsightsReportService, + RiskInsightsEncryptionService, + LogService, ], }), safeProvider({ 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 fcbf8c05e1f..849a195a395 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 @@ -6,8 +6,10 @@ import { EMPTY } from "rxjs"; import { map, tap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; +import { + DrawerType, + RiskInsightsDataService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { OrganizationId } from "@bitwarden/common/types/guid";