From 6e56fd2e87b1a0100af16ccf2ded591211c6cf41 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Thu, 9 Oct 2025 07:02:50 -0500 Subject: [PATCH] Separate data service and orchestration of data to make the data service a facade --- .../risk-insights/models/report-models.ts | 6 + ...risk-insights-orchestrator.service.spec.ts | 212 +++++++++++ .../risk-insights-orchestrator.service.ts | 346 ++++++++++++++++++ .../services/view/all-activities.service.ts | 2 +- .../view/risk-insights-data.service.ts | 293 +++------------ .../access-intelligence.module.ts | 7 +- .../all-applications.component.ts | 2 +- .../risk-insights.component.html | 2 +- .../risk-insights.component.ts | 19 +- 9 files changed, 628 insertions(+), 261 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts 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 564f483813a..09c4ae7f20b 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 @@ -157,3 +157,9 @@ export interface RiskInsightsData { summaryData: OrganizationReportSummary; applicationData: OrganizationReportApplication[]; } + +export interface ReportState { + loading: boolean; + error: string | null; + data: RiskInsightsData | null; +} 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 new file mode 100644 index 00000000000..913347b9b1c --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts @@ -0,0 +1,212 @@ +import { mock } from "jest-mock-extended"; +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 { createNewSummaryData } from "../../helpers"; +import { ReportState } from "../../models"; +import { + mockApplicationData, + mockEnrichedReportData, + mockSummaryData, +} from "../../models/mocks/mock-data"; + +import { CriticalAppsService } from "./critical-apps.service"; +import { RiskInsightsOrchestratorService } from "./risk-insights-orchestrator.service"; +import { RiskInsightsReportService } from "./risk-insights-report.service"; + +describe("RiskInsightsOrchestratorService", () => { + let service: RiskInsightsOrchestratorService; + + // Non changing mock data + const mockOrgId = "org-789" as OrganizationId; + const mockOrgName = "Test Org"; + const mockUserId = "user-101" as UserId; + + // Mock services + const mockAccountService = mock({ + activeAccount$: of(mock({ id: mockUserId })), + }); + const mockCriticalAppsService = mock({ + criticalAppsList$: of([]), + }); + const mockOrganizationService = mock(); + const mockReportService = mock(); + + beforeEach(() => { + service = new RiskInsightsOrchestratorService( + mockAccountService, + mockCriticalAppsService, + mockOrganizationService, + mockReportService, + ); + }); + + describe("fetchReport", () => { + it("should call reportService.getRiskInsightsReport$ with correct org and user IDs and emit ReportState", (done) => { + const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"]; + const privateUserIdSubject = service["_userIdSubject"]; + // Arrange + const reportState: ReportState = { + loading: false, + error: null, + data: { + reportData: [], + summaryData: createNewSummaryData(), + applicationData: [], + creationDate: new Date(), + }, + }; + mockReportService.getRiskInsightsReport$.mockReturnValueOnce(of(reportState.data)); + // Set up organization and user context + privateOrganizationDetailsSubject.next({ + organizationId: mockOrgId, + organizationName: mockOrgName, + }); + privateUserIdSubject.next(mockUserId); + + // Act + service.fetchReport(); + + // Assert + service.rawReportData$.subscribe((state) => { + if (!state.loading) { + expect(mockReportService.getRiskInsightsReport$).toHaveBeenCalledWith( + mockOrgId, + mockUserId, + ); + expect(state.data).toEqual(reportState.data); + done(); + } + }); + }); + + it("should emit error ReportState when getRiskInsightsReport$ throws", (done) => { + const { _organizationDetailsSubject, _userIdSubject } = service as any; + mockReportService.getRiskInsightsReport$.mockReturnValueOnce( + // Simulate error + throwError(() => new Error("API error")), + ); + _organizationDetailsSubject.next({ + organizationId: mockOrgId, + organizationName: mockOrgName, + }); + _userIdSubject.next(mockUserId); + service.fetchReport(); + service.rawReportData$.subscribe((state) => { + if (!state.loading) { + expect(state.error).toBe("Failed to fetch report"); + expect(state.data).toBeNull(); + done(); + } + }); + }); + }); + + describe("generateReport", () => { + 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.saveRiskInsightsReport$.mockReturnValueOnce(of(null)); + privateOrganizationDetailsSubject.next({ + organizationId: mockOrgId, + organizationName: mockOrgName, + }); + privateUserIdSubject.next(mockUserId); + + // Act + service.generateReport(); + + // Assert + service.rawReportData$.subscribe((state) => { + if (!state.loading) { + expect(mockReportService.generateApplicationsReport$).toHaveBeenCalledWith(mockOrgId); + expect(mockReportService.saveRiskInsightsReport$).toHaveBeenCalledWith( + mockEnrichedReportData, + mockSummaryData, + mockApplicationData, + { organizationId: mockOrgId, userId: mockUserId }, + ); + expect(state.data.reportData).toEqual(mockEnrichedReportData); + expect(state.data.summaryData).toEqual(mockSummaryData); + expect(state.data.applicationData).toEqual(mockApplicationData); + done(); + } + }); + }); + + 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.saveRiskInsightsReport$.mockReturnValueOnce( + throwError(() => new Error("Save 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(); + } + }); + }); + }); + + describe("destroy", () => { + it("should complete destroy$ subject and unsubscribe reportStateSubscription", (done) => { + const privateDestroy = service["_destroy$"]; + const privateReportStateSubscription = service["_reportStateSubscription"]; + const unsubscribeSpy = jest.spyOn(privateReportStateSubscription, "unsubscribe"); + + service.destroy(); + + expect(unsubscribeSpy).toHaveBeenCalled(); + privateDestroy.subscribe({ + error: (err: unknown) => { + done.fail("Should not error: " + err); + }, + complete: () => { + done(); + }, + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..bb9f65d6065 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts @@ -0,0 +1,346 @@ +import { + BehaviorSubject, + combineLatest, + from, + merge, + Observable, + of, + Subject, + Subscription, +} from "rxjs"; +import { + catchError, + distinctUntilChanged, + exhaustMap, + filter, + map, + scan, + shareReplay, + startWith, + switchMap, + take, + takeUntil, + 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 { ApplicationHealthReportDetailEnriched } from "../../models"; +import { RiskInsightsEnrichedData } from "../../models/report-data-service.types"; +import { ReportState } from "../../models/report-models"; + +import { CriticalAppsService } from "./critical-apps.service"; +import { RiskInsightsReportService } from "./risk-insights-report.service"; + +export class RiskInsightsOrchestratorService { + private _destroy$ = new Subject(); + + // -------------------------- Context state -------------------------- + // Current user viewing risk insights + private _userIdSubject = new BehaviorSubject(null); + private _userId$ = this._userIdSubject.asObservable(); + + // Organization the user is currently viewing + private _organizationDetailsSubject = new BehaviorSubject<{ + organizationId: OrganizationId; + organizationName: string; + } | null>(null); + organizationDetails$ = this._organizationDetailsSubject.asObservable(); + + // ------------------------- Report Variables ---------------- + private _rawReportDataSubject = new BehaviorSubject({ + loading: true, + error: null, + data: null, + }); + rawReportData$ = this._rawReportDataSubject.asObservable(); + private _enrichedReportDataSubject = new BehaviorSubject(null); + enrichedReportData$ = this._enrichedReportDataSubject.asObservable(); + + // Generate report trigger and state + private _generateReportTriggerSubject = new BehaviorSubject(false); + generatingReport$ = this._generateReportTriggerSubject.asObservable(); + + // --------------------------- Critical Application data --------------------- + criticalReportResults$: Observable = of(null); + + // --------------------------- Trigger subjects --------------------- + private _initializeOrganizationTriggerSubject = new Subject(); + private _fetchReportTriggerSubject = new Subject(); + private _reportStateSubscription: Subscription; + + constructor( + private accountService: AccountService, + private criticalAppsService: CriticalAppsService, + private organizationService: OrganizationService, + private reportService: RiskInsightsReportService, + ) { + this._setupCriticalApplicationContext(); + this._setupCriticalApplicationReport(); + this._setupEnrichedReportData(); + this._setupInitializationPipeline(); + this._setupReportState(); + this._setupUserId(); + } + + destroy(): void { + if (this._reportStateSubscription) { + this._reportStateSubscription.unsubscribe(); + } + this._destroy$.next(); + 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._fetchReportTriggerSubject.next(); + } + + /** + * Generates a new report for the current organization and user + */ + generateReport(): void { + this._generateReportTriggerSubject.next(true); + } + + private _fetchReport$(organizationId: OrganizationId, userId: UserId): Observable { + return this.reportService.getRiskInsightsReport$(organizationId, userId).pipe( + map( + ({ reportData, summaryData, applicationData, creationDate }): ReportState => ({ + loading: false, + error: null, + data: { + reportData, + summaryData, + applicationData, + creationDate, + }, + }), + ), + catchError(() => of({ loading: false, error: "Failed to fetch report", data: null })), + startWith({ loading: true, error: null, data: null }), + ); + } + + private _generateApplicationsReport$( + 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), + })), + switchMap(({ report, summary, applications }) => + // Save the report after enrichment + this.reportService + .saveRiskInsightsReport$(report, summary, applications, { + organizationId, + userId, + }) + .pipe( + map(() => ({ + report, + summary, + applications, + })), + ), + ), + // Update the running state + map( + ({ report, summary, applications }): ReportState => ({ + loading: false, + error: null, + data: { + reportData: report, + summaryData: summary, + applicationData: applications, + creationDate: new Date(), + }, + }), + ), + catchError(() => { + return of({ loading: false, error: "Failed to generate or save report", data: null }); + }), + startWith({ loading: true, error: null, data: null }), + ); + } + + // Setup the pipeline to load critical applications when organization or user changes + private _setupCriticalApplicationContext() { + this.organizationDetails$ + .pipe( + filter((orgDetails) => !!orgDetails), + withLatestFrom(this._userId$), + filter(([_, userId]) => !!userId), + tap(([orgDetails, userId]) => { + this.criticalAppsService.loadOrganizationContext(orgDetails!.organizationId, userId!); + }), + takeUntil(this._destroy$), + ) + .subscribe(); + } + + // Setup the pipeline to create a report view filtered to only critical applications + private _setupCriticalApplicationReport() { + const criticalReportResultsPipeline$ = this.enrichedReportData$.pipe( + filter((state) => !!state), + map((enrichedReports) => { + const criticalApplications = enrichedReports!.reportData.filter( + (app) => app.isMarkedAsCritical, + ); + const summary = this.reportService.generateApplicationsSummary(criticalApplications); + return { + ...enrichedReports, + summaryData: summary, + reportData: criticalApplications, + }; + }), + ); + + this.criticalReportResults$ = criticalReportResultsPipeline$; + } + + /** + * Takes the basic application health report details and enriches them to include + * critical app status and associated ciphers. + */ + private _setupEnrichedReportData() { + // 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)), + ]).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); + }), + ); + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + enrichmentSubscription.pipe(takeUntil(this._destroy$)).subscribe((enrichedData) => { + this._enrichedReportDataSubject.next(enrichedData); + }); + } + + // Setup the pipeline to initialize organization context + private _setupInitializationPipeline() { + this._initializeOrganizationTriggerSubject + .pipe( + withLatestFrom(this._userId$), + filter(([orgId, userId]) => !!orgId && !!userId), + exhaustMap(([orgId, userId]) => + this.organizationService.organizations$(userId!).pipe( + getOrganizationById(orgId), + map((org) => ({ organizationId: orgId, organizationName: org.name })), + ), + ), + takeUntil(this._destroy$), + ) + .subscribe((orgDetails) => this._organizationDetailsSubject.next(orgDetails)); + } + + // Setup the report state management pipeline + private _setupReportState() { + // Dependencies needed for report state + const reportDependencies$ = combineLatest([ + this.organizationDetails$.pipe(filter((org) => !!org)), + this._userId$.pipe(filter((user) => !!user)), + ]).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( + take(1), // Fetch only once on initial data load + exhaustMap(([_, [orgDetails, userId]]) => + this._fetchReport$(orgDetails!.organizationId, userId!), + ), + ); + + // A stream for manually triggered fetches + const manualReportFetch$ = this._fetchReportTriggerSubject.pipe( + withLatestFrom(reportDependencies$), + exhaustMap(([_, [orgDetails, userId]]) => + this._fetchReport$(orgDetails!.organizationId, userId!), + ), + ); + + // A stream for generating a new report + const newReportGeneration$ = this.generatingReport$.pipe( + distinctUntilChanged(), + filter((isRunning) => isRunning), + withLatestFrom(reportDependencies$), + exhaustMap(([_, [orgDetails, userId]]) => + this._generateApplicationsReport$(orgDetails!.organizationId, userId), + ), + ); + + // Combine all triggers and update the single report state + const mergedReportState$ = merge( + initialReportLoad$, + manualReportFetch$, + newReportGeneration$, + ).pipe( + scan((prevState: ReportState, currState: ReportState) => ({ + ...prevState, + ...currState, + data: currState.data !== null ? currState.data : prevState.data, + })), + startWith({ loading: false, error: null, data: null }), + shareReplay({ bufferSize: 1, refCount: true }), + takeUntil(this._destroy$), + ); + + this._reportStateSubscription = mergedReportState$ + .pipe(takeUntil(this._destroy$)) + .subscribe((state) => { + this._rawReportDataSubject.next(state); + }); + } + + // Setup the user ID observable to track the current user + private _setupUserId() { + // Watch userId changes + this.accountService.activeAccount$.pipe(getUserId).subscribe((userId) => { + this._userIdSubject.next(userId); + }); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts index 01efd94bd18..a166046680a 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts @@ -37,7 +37,7 @@ export class AllActivitiesService { constructor(private dataService: RiskInsightsDataService) { // All application summary changes - this.dataService.reportResults$.subscribe((report) => { + this.dataService.enrichedReportData$.subscribe((report) => { if (report) { this.setAllAppsReportSummary(report.summaryData); this.setAllAppsReportDetails(report.reportData); 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 8b30fd6bf4a..6f8174e16f2 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 @@ -1,59 +1,37 @@ -import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of, throwError } from "rxjs"; -import { - catchError, - distinctUntilChanged, - exhaustMap, - filter, - finalize, - map, - shareReplay, - switchMap, - tap, - withLatestFrom, -} from "rxjs/operators"; +import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of, Subject, throwError } from "rxjs"; +import { catchError, distinctUntilChanged, exhaustMap, map } 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 { OrganizationId } from "@bitwarden/common/types/guid"; -import { ApplicationHealthReportDetailEnriched } from "../../models"; import { RiskInsightsEnrichedData } from "../../models/report-data-service.types"; -import { - DrawerType, - DrawerDetails, - ApplicationHealthReportDetail, -} from "../../models/report-models"; +import { DrawerType, DrawerDetails, ReportState } from "../../models/report-models"; import { CriticalAppsService } from "../domain/critical-apps.service"; +import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service"; import { RiskInsightsReportService } from "../domain/risk-insights-report.service"; export class RiskInsightsDataService { - // -------------------------- Context state -------------------------- - // Current user viewing risk insights - private userIdSubject = new BehaviorSubject(null); - userId$ = this.userIdSubject.asObservable(); + private _destroy$ = new Subject(); + // -------------------------- Context state -------------------------- // Organization the user is currently viewing - private organizationDetailsSubject = new BehaviorSubject<{ + readonly organizationDetails$: Observable<{ organizationId: OrganizationId; organizationName: string; - } | null>(null); - organizationDetails$ = this.organizationDetailsSubject.asObservable(); + } | null> = of(null); // --------------------------- UI State ------------------------------------ - private isLoadingSubject = new BehaviorSubject(false); - isLoading$ = this.isLoadingSubject.asObservable(); - - private isRefreshingSubject = new BehaviorSubject(false); - isRefreshing$ = this.isRefreshingSubject.asObservable(); - private errorSubject = new BehaviorSubject(null); error$ = this.errorSubject.asObservable(); - // ------------------------- Drawer Variables ---------------- + // -------------------------- Orchestrator-driven state ------------- + // The full report state (for internal facade use or complex components) + private readonly reportState$: Observable; + readonly isLoading$: Observable = of(false); + readonly enrichedReportData$: Observable = of(null); + readonly isGeneratingReport$: Observable = of(false); + readonly criticalReportResults$: Observable = of(null); + + // ------------------------- Drawer Variables --------------------- // Drawer variables unified into a single BehaviorSubject private drawerDetailsSubject = new BehaviorSubject({ open: false, @@ -65,124 +43,41 @@ export class RiskInsightsDataService { }); 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(); - // --------------------------- Critical Application data --------------------- - criticalReportResults$: Observable = of(null); - constructor( - private accountService: AccountService, private criticalAppsService: CriticalAppsService, - private organizationService: OrganizationService, private reportService: RiskInsightsReportService, + private orchestrator: RiskInsightsOrchestratorService, ) { - // Reload report if critical applications change - // This also handles the original report load - this.criticalAppsService.criticalAppsList$ - .pipe(withLatestFrom(this.organizationDetails$, this.userId$)) - .subscribe({ - next: ([_criticalApps, organizationDetails, userId]) => { - if (organizationDetails?.organizationId && userId) { - this.fetchLastReport(organizationDetails?.organizationId, userId); - } - }, - }); + this.reportState$ = this.orchestrator.rawReportData$; + this.isGeneratingReport$ = this.orchestrator.generatingReport$; + this.organizationDetails$ = this.orchestrator.organizationDetails$; + this.enrichedReportData$ = this.orchestrator.enrichedReportData$; + this.criticalReportResults$ = this.orchestrator.criticalReportResults$; - // Setup critical application data and summary generation for live critical application usage - this.criticalReportResults$ = this.reportResults$.pipe( - filter((report) => !!report), - map((r) => { - const criticalApplications = r.reportData.filter( - (application) => application.isMarkedAsCritical, - ); - const summary = this.reportService.generateApplicationsSummary(criticalApplications); - - return { - ...r, - summaryData: summary, - reportData: criticalApplications, - }; - }), - shareReplay({ bufferSize: 1, refCount: true }), + // Expose the loading state + this.isLoading$ = this.reportState$.pipe( + map((state) => state.loading), + distinctUntilChanged(), // Prevent unnecessary component re-renders ); } - async initializeForOrganization(organizationId: OrganizationId) { - // Fetch current user - const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - if (userId) { - this.userIdSubject.next(userId); - } - - // [FIXME] getOrganizationById is now deprecated - replace with appropriate method - // 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 for organization - await this.criticalAppsService.loadOrganizationContext(organizationId, userId); - - // Setup new report generation - this._runApplicationsReport().subscribe({ - next: (result) => { - this.isRunningReportSubject.next(false); - }, - error: () => { - this.errorSubject.next("Failed to save report"); - }, - }); + destroy(): void { + this._destroy$.next(); + this._destroy$.complete(); } - // ------------------------------- Enrichment methods ------------------------------- - /** - * Takes the basic application health report details and enriches them to include - * critical app status and associated ciphers. - * - * @param applications The list of application health report details to enrich - * @returns The enriched application health report details with critical app status and ciphers - */ - enrichReportData$( - applications: ApplicationHealthReportDetail[], - ): Observable { - // TODO Compare applications on report to updated critical applications - // TODO Compare applications on report to any new applications - return of(applications).pipe( - withLatestFrom(this.organizationDetails$, this.criticalAppsService.criticalAppsList$), - switchMap(async ([apps, orgDetails, criticalApps]) => { - if (!orgDetails) { - return []; - } + // ----- UI-triggered methods (delegate to orchestrator) ----- + initializeForOrganization(organizationId: OrganizationId) { + this.orchestrator.initializeForOrganization(organizationId); + } - // Get ciphers for application - const cipherMap = await this.reportService.getApplicationCipherMap( - apps, - orgDetails.organizationId, - ); + triggerReport(): void { + this.orchestrator.generateReport(); + } - // Find critical apps - const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.uri)); - - // Return enriched application data - return apps.map((app) => ({ - ...app, - ciphers: cipherMap.get(app.applicationName) || [], - isMarkedAsCritical: criticalApplicationNames.has(app.applicationName), - })) as ApplicationHealthReportDetailEnriched[]; - }), - ); + fetchReport(): void { + this.orchestrator.fetchReport(); } // ------------------------- Drawer functions ----------------------------- @@ -213,7 +108,7 @@ export class RiskInsightsDataService { if (shouldClose) { this.closeDrawer(); } else { - const reportResults = await firstValueFrom(this.reportResults$); + const reportResults = await firstValueFrom(this.enrichedReportData$); if (!reportResults) { return; } @@ -241,7 +136,7 @@ export class RiskInsightsDataService { if (shouldClose) { this.closeDrawer(); } else { - const reportResults = await firstValueFrom(this.reportResults$); + const reportResults = await firstValueFrom(this.enrichedReportData$); if (!reportResults) { return; } @@ -271,7 +166,7 @@ export class RiskInsightsDataService { if (shouldClose) { this.closeDrawer(); } else { - const reportResults = await firstValueFrom(this.reportResults$); + const reportResults = await firstValueFrom(this.enrichedReportData$); if (!reportResults) { return; } @@ -290,109 +185,7 @@ export class RiskInsightsDataService { } }; - // ------------------- Trigger Report Generation ------------------- - /** Trigger generating a report based on the current applications */ - triggerReport(): void { - this.isRunningReportSubject.next(true); - } - - /** - * Fetches the applications report and updates the applicationsSubject. - * @param organizationId The ID of the organization. - */ - fetchLastReport(organizationId: OrganizationId, userId: UserId): void { - this.isLoadingSubject.next(true); - - this.reportService - .getRiskInsightsReport$(organizationId, userId) - .pipe( - switchMap((report) => { - // Take fetched report data and merge with critical applications - return this.enrichReportData$(report.reportData).pipe( - map((enrichedReport) => ({ - report: enrichedReport, - summary: report.summaryData, - applications: report.applicationData, - creationDate: report.creationDate, - })), - ); - }), - catchError((error: unknown) => { - // console.error("An error occurred when fetching the last report", error); - return EMPTY; - }), - finalize(() => { - this.isLoadingSubject.next(false); - }), - ) - .subscribe({ - next: ({ report, summary, applications, creationDate }) => { - this.reportResultsSubject.next({ - reportData: report, - summaryData: summary, - applicationData: applications, - creationDate: creationDate, - }); - this.errorSubject.next(null); - this.isLoadingSubject.next(false); - }, - error: () => { - this.errorSubject.next("Failed to fetch report"); - this.reportResultsSubject.next(null); - this.isLoadingSubject.next(false); - }, - }); - } - - private _runApplicationsReport() { - return this.isRunningReport$.pipe( - distinctUntilChanged(), - // Only run this report if the flag for running is true - filter((isRunning) => isRunning), - withLatestFrom(this.organizationDetails$, this.userId$), - exhaustMap(([_, organizationDetails, userId]) => { - const organizationId = organizationDetails?.organizationId; - if (!organizationId || !userId) { - return EMPTY; - } - - // Generate the report - return this.reportService.generateApplicationsReport$(organizationId).pipe( - map((report) => ({ - report, - summary: this.reportService.generateApplicationsSummary(report), - applications: this.reportService.generateOrganizationApplications(report), - })), - // Enrich report with critical markings - switchMap(({ report, summary, applications }) => - this.enrichReportData$(report).pipe( - map((enrichedReport) => ({ report: enrichedReport, summary, applications })), - ), - ), - // Load the updated data into the UI - tap(({ report, summary, applications }) => { - this.reportResultsSubject.next({ - reportData: report, - summaryData: summary, - applicationData: applications, - creationDate: new Date(), - }); - this.errorSubject.next(null); - }), - switchMap(({ report, summary, applications }) => { - // Save the generated data - return this.reportService.saveRiskInsightsReport$(report, summary, applications, { - organizationId, - userId, - }); - }), - ); - }), - ); - } - // ------------------------------ Critical application methods -------------- - saveCriticalApplications(selectedUrls: string[]) { return this.organizationDetails$.pipe( exhaustMap((organizationDetails) => { 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 e4343015d72..67cad64f711 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 @@ -13,6 +13,7 @@ import { SecurityTasksApiService, } from "@bitwarden/bit-common/dirt/reports/risk-insights/services"; import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service"; +import { RiskInsightsOrchestratorService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.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"; @@ -56,7 +57,7 @@ import { RiskInsightsComponent } from "./risk-insights.component"; ], }), safeProvider({ - provide: RiskInsightsDataService, + provide: RiskInsightsOrchestratorService, deps: [ AccountServiceAbstraction, CriticalAppsService, @@ -64,6 +65,10 @@ import { RiskInsightsComponent } from "./risk-insights.component"; RiskInsightsReportService, ], }), + safeProvider({ + provide: RiskInsightsDataService, + deps: [CriticalAppsService, RiskInsightsReportService, RiskInsightsOrchestratorService], + }), { provide: RiskInsightsEncryptionService, useClass: RiskInsightsEncryptionService, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index 27394ab717d..57ee0b20360 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -67,7 +67,7 @@ export class AllApplicationsComponent implements OnInit { } async ngOnInit() { - this.dataService.reportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (report) => { this.applicationSummary = report?.summaryData ?? createNewSummaryData(); this.dataSource.data = report?.reportData ?? []; 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 49ccfb73c5d..f3488298726 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 @@ -17,7 +17,7 @@ } @else { {{ "noReportRan" | i18n }} } - @let isRunningReport = dataService.isRunningReport$ | async; + @let isRunningReport = dataService.isGeneratingReport$ | async;