1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-03 18:23:57 +00:00

Separate data service and orchestration of data to make the data service a facade

This commit is contained in:
Leslie Tilton
2025-10-09 07:02:50 -05:00
parent ea0270c29c
commit 6e56fd2e87
9 changed files with 628 additions and 261 deletions

View File

@@ -157,3 +157,9 @@ export interface RiskInsightsData {
summaryData: OrganizationReportSummary;
applicationData: OrganizationReportApplication[];
}
export interface ReportState {
loading: boolean;
error: string | null;
data: RiskInsightsData | null;
}

View File

@@ -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<AccountService>({
activeAccount$: of(mock<Account>({ id: mockUserId })),
});
const mockCriticalAppsService = mock<CriticalAppsService>({
criticalAppsList$: of([]),
});
const mockOrganizationService = mock<OrganizationService>();
const mockReportService = mock<RiskInsightsReportService>();
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();
},
});
});
});
});

View File

@@ -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<void>();
// -------------------------- Context state --------------------------
// Current user viewing risk insights
private _userIdSubject = new BehaviorSubject<UserId | null>(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<ReportState>({
loading: true,
error: null,
data: null,
});
rawReportData$ = this._rawReportDataSubject.asObservable();
private _enrichedReportDataSubject = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
enrichedReportData$ = this._enrichedReportDataSubject.asObservable();
// Generate report trigger and state
private _generateReportTriggerSubject = new BehaviorSubject<boolean>(false);
generatingReport$ = this._generateReportTriggerSubject.asObservable();
// --------------------------- Critical Application data ---------------------
criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
// --------------------------- Trigger subjects ---------------------
private _initializeOrganizationTriggerSubject = new Subject<OrganizationId>();
private _fetchReportTriggerSubject = new Subject<void>();
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<ReportState> {
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<ReportState> {
// 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);
});
}
}

View File

@@ -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);

View File

@@ -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<UserId | null>(null);
userId$ = this.userIdSubject.asObservable();
private _destroy$ = new Subject<void>();
// -------------------------- 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<boolean>(false);
isLoading$ = this.isLoadingSubject.asObservable();
private isRefreshingSubject = new BehaviorSubject<boolean>(false);
isRefreshing$ = this.isRefreshingSubject.asObservable();
private errorSubject = new BehaviorSubject<string | null>(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<ReportState>;
readonly isLoading$: Observable<boolean> = of(false);
readonly enrichedReportData$: Observable<RiskInsightsEnrichedData | null> = of(null);
readonly isGeneratingReport$: Observable<boolean> = of(false);
readonly criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
// ------------------------- Drawer Variables ---------------------
// Drawer variables unified into a single BehaviorSubject
private drawerDetailsSubject = new BehaviorSubject<DrawerDetails>({
open: false,
@@ -65,124 +43,41 @@ export class RiskInsightsDataService {
});
drawerDetails$ = this.drawerDetailsSubject.asObservable();
// ------------------------- Report Variables ----------------
// The last run report details
private reportResultsSubject = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
reportResults$ = this.reportResultsSubject.asObservable();
// Is a report being generated
private isRunningReportSubject = new BehaviorSubject<boolean>(false);
isRunningReport$ = this.isRunningReportSubject.asObservable();
// --------------------------- Critical Application data ---------------------
criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = 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<ApplicationHealthReportDetailEnriched[]> {
// 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) => {

View File

@@ -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,

View File

@@ -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 ?? [];

View File

@@ -17,7 +17,7 @@
} @else {
<span class="tw-mx-4">{{ "noReportRan" | i18n }}</span>
}
@let isRunningReport = dataService.isRunningReport$ | async;
@let isRunningReport = dataService.isGeneratingReport$ | async;
<span class="tw-flex tw-justify-center">
<button
*ngIf="!isRunningReport"

View File

@@ -1,9 +1,9 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, inject } from "@angular/core";
import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { EMPTY } from "rxjs";
import { map, switchMap } from "rxjs/operators";
import { map, tap } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
@@ -43,7 +43,7 @@ import { RiskInsightsTabType } from "./models/risk-insights.models";
AllActivityComponent,
],
})
export class RiskInsightsComponent implements OnInit {
export class RiskInsightsComponent implements OnInit, OnDestroy {
private destroyRef = inject(DestroyRef);
private _isDrawerOpen: boolean = false;
@@ -83,11 +83,10 @@ export class RiskInsightsComponent implements OnInit {
.pipe(
takeUntilDestroyed(this.destroyRef),
map((params) => params.get("organizationId")),
switchMap(async (orgId) => {
tap((orgId) => {
if (orgId) {
// Initialize Data Service
await this.dataService.initializeForOrganization(orgId as OrganizationId);
this.dataService.initializeForOrganization(orgId as OrganizationId);
this.organizationId = orgId as OrganizationId;
} else {
return EMPTY;
@@ -97,7 +96,7 @@ export class RiskInsightsComponent implements OnInit {
.subscribe();
// Subscribe to report result details
this.dataService.reportResults$
this.dataService.enrichedReportData$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((report) => {
this.appsCount = report?.reportData.length ?? 0;
@@ -111,6 +110,12 @@ export class RiskInsightsComponent implements OnInit {
this._isDrawerOpen = details.open;
});
}
ngOnDestroy(): void {
// The component tells the facade when to stop
this.dataService.destroy();
}
runReport = () => {
this.dataService.triggerReport();
};