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:
@@ -157,3 +157,9 @@ export interface RiskInsightsData {
|
||||
summaryData: OrganizationReportSummary;
|
||||
applicationData: OrganizationReportApplication[];
|
||||
}
|
||||
|
||||
export interface ReportState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
data: RiskInsightsData | null;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user