diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts index 9fd6f3faa90..a2cd013d6be 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts @@ -183,7 +183,7 @@ export interface SaveRiskInsightsReportResponse { export interface GetRiskInsightsReportResponse { id: string; organizationId: OrganizationId; - date: string; + reportDate: string; reportData: string; totalMembers: number; totalAtRiskMembers: number; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts index edad5503e4a..b879ef94705 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts @@ -49,10 +49,6 @@ export class CriticalAppsService { // Get a list of critical apps for a given organization getAppsListForOrg(orgId: string): Observable { - if (this.criticalAppsList.value.length === 0) { - return of([]); - } - return this.criticalAppsList .asObservable() .pipe(map((apps) => apps.filter((app) => app.organizationId === orgId))); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts index 386c6fd6865..1e5e28f27c1 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts @@ -1,19 +1,27 @@ import { BehaviorSubject } from "rxjs"; -import { finalize } from "rxjs/operators"; +import { finalize, switchMap } from "rxjs/operators"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; import { AppAtRiskMembersDialogParams, ApplicationHealthReportDetail, + ApplicationHealthReportSummary, AtRiskApplicationDetail, AtRiskMemberDetail, DrawerType, } from "../models/password-health"; +import { RiskInsightsApiService } from "./risk-insights-api.service"; import { RiskInsightsReportService } from "./risk-insights-report.service"; export class RiskInsightsDataService { private applicationsSubject = new BehaviorSubject(null); + private appsSummarySubject = new BehaviorSubject(null); + private isReportFromArchiveSubject = new BehaviorSubject(true); // True by default applications$ = this.applicationsSubject.asObservable(); + appsSummary$ = this.appsSummarySubject.asObservable(); + isReportFromArchive$ = this.isReportFromArchiveSubject.asObservable(); private isLoadingSubject = new BehaviorSubject(false); isLoading$ = this.isLoadingSubject.asObservable(); @@ -34,7 +42,10 @@ export class RiskInsightsDataService { appAtRiskMembers: AppAtRiskMembersDialogParams | null = null; atRiskAppDetails: AtRiskApplicationDetail[] | null = null; - constructor(private reportService: RiskInsightsReportService) {} + constructor( + private reportService: RiskInsightsReportService, + private riskInsightsApiService: RiskInsightsApiService, + ) {} /** * Fetches the applications report and updates the applicationsSubject. @@ -52,13 +63,14 @@ export class RiskInsightsDataService { finalize(() => { this.isLoadingSubject.next(false); this.isRefreshingSubject.next(false); - this.dataLastUpdatedSubject.next(new Date()); }), ) .subscribe({ next: (reports: ApplicationHealthReportDetail[]) => { this.applicationsSubject.next(reports); this.errorSubject.next(null); + this.appsSummarySubject.next(this.reportService.generateApplicationsSummary(reports)); + this.dataLastUpdatedSubject.next(new Date()); }, error: () => { this.applicationsSubject.next([]); @@ -66,6 +78,57 @@ export class RiskInsightsDataService { }); } + fetchApplicationsReportFromCache(organizationId: string) { + return this.riskInsightsApiService + .getRiskInsightsReport(organizationId as OrganizationId) + .pipe( + switchMap(async (reportFromArchive) => { + if (!reportFromArchive || !reportFromArchive?.reportDate) { + this.fetchApplicationsReport(organizationId); + + return { + report: [], + summary: null, + fromDb: false, + lastUpdated: new Date(), + }; + } else { + const [report, summary] = await this.reportService.decryptRiskInsightsReport( + organizationId as OrganizationId, + reportFromArchive, + ); + + return { + report, + summary, + fromDb: true, + lastUpdated: new Date(reportFromArchive.reportDate), + }; + } + }), + ) + .subscribe({ + next: ({ report, summary, fromDb, lastUpdated }) => { + if (fromDb) { + this.applicationsSubject.next(report); + this.errorSubject.next(null); + this.appsSummarySubject.next(summary); + } + this.isReportFromArchiveSubject.next(fromDb); + this.dataLastUpdatedSubject.next(lastUpdated); + }, + error: (error: unknown) => { + this.errorSubject.next((error as Error).message); + this.applicationsSubject.next([]); + }, + }); + } + + isLoadingData(started: boolean): void { + this.isLoadingSubject.next(started); + this.isRefreshingSubject.next(started); + } + refreshApplicationsReport(organizationId: string): void { this.fetchApplicationsReport(organizationId, true); } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts index 07e45c1b0de..cbc617c0920 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe // @ts-strict-ignore -import { concatMap, first, firstValueFrom, from, map, Observable, takeWhile, zip } from "rxjs"; +import { concatMap, first, from, map, Observable, zip } from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -184,9 +184,12 @@ export class RiskInsightsReportService { throw new Error("Organization key not found"); } + const reportWithSummary = { details, summary }; + const reportContentEncryptionKey = await this.keyGeneratorService.createKey(512); + const reportEncrypted = await this.encryptService.encryptString( - JSON.stringify(details), + JSON.stringify(reportWithSummary), reportContentEncryptionKey, ); @@ -200,21 +203,15 @@ export class RiskInsightsReportService { key: wrappedReportContentEncryptionKey.encryptedString, }; - const criticalApps = await firstValueFrom( - this.criticalAppsService - .getAppsListForOrg(organizationId) - .pipe(takeWhile((apps) => apps !== null && apps.length > 0)), - ); - const riskInsightReport = { organizationId: organizationId, date: new Date().toISOString(), reportData: JSON.stringify(reportDataWithWrappedKey), - totalMembers: summary.totalMemberCount, - totalAtRiskMembers: summary.totalAtRiskMemberCount, - totalApplications: summary.totalApplicationCount, - totalAtRiskApplications: summary.totalAtRiskApplicationCount, - totalCriticalApplications: criticalApps.length, + totalMembers: 0, + totalAtRiskMembers: 0, + totalApplications: 0, + totalAtRiskApplications: 0, + totalCriticalApplications: 0, }; return riskInsightReport; @@ -244,16 +241,11 @@ export class RiskInsightsReportService { unwrappedReportContentEncryptionKey, ); - const reportJson: ApplicationHealthReportDetail[] = JSON.parse(reportUnencrypted); + const reportWithSummary = JSON.parse(reportUnencrypted); + const reportJson = reportWithSummary.details; + const reportSummary = reportWithSummary.summary; - const summary: ApplicationHealthReportSummary = { - totalMemberCount: riskInsightsReportResponse.totalMembers, - totalAtRiskMemberCount: riskInsightsReportResponse.totalAtRiskMembers, - totalApplicationCount: riskInsightsReportResponse.totalApplications, - totalAtRiskApplicationCount: riskInsightsReportResponse.totalAtRiskApplications, - }; - - return [reportJson, summary]; + return [reportJson, reportSummary]; } catch { return [null, null]; } 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 5a4f4cedd9d..4333178a0df 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 @@ -42,7 +42,7 @@ import { RiskInsightsComponent } from "./risk-insights.component"; }, { provide: RiskInsightsDataService, - deps: [RiskInsightsReportService], + deps: [RiskInsightsReportService, RiskInsightsApiService], }, safeProvider({ provide: CriticalAppsService, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts index 93c50984f65..815022faa99 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts @@ -101,6 +101,10 @@ export class AllApplicationsComponent implements OnInit { }); async ngOnInit() { + this.isLoading$ = this.dataService.isLoading$; + + this.dataService.isLoadingData(true); + this.isCriticalAppsFeatureEnabled = await this.configService.getFeatureFlag( FeatureFlag.CriticalApps, ); @@ -113,105 +117,47 @@ export class AllApplicationsComponent implements OnInit { .organizations$(userId) .pipe(getOrganizationById(organizationId)); + this.dataService.fetchApplicationsReportFromCache(organizationId as OrganizationId); + combineLatest([ - this.riskInsightsApiService.getRiskInsightsReport(organizationId as OrganizationId), - this.criticalAppsService.getAppsListForOrg(organizationId), - organization$, this.dataService.applications$, + this.dataService.appsSummary$, + this.dataService.isReportFromArchive$, + organization$, + this.criticalAppsService.getAppsListForOrg(organizationId as OrganizationId), ]) .pipe( - switchMap(async ([reportArchive, criticalApps, organization, appHealthReport]) => { - if (reportArchive == null) { - const report = await firstValueFrom(this.dataService.applications$); - - if (report == null) { - this.dataService.fetchApplicationsReport(organizationId as OrganizationId); - } - - return { - report, - criticalApps, - organization, - fromDb: false, - }; - } else { - const [report] = await this.reportService.decryptRiskInsightsReport( - organizationId as OrganizationId, - reportArchive, - ); - - return { - report, - criticalApps, - organization, - fromDb: true, - }; - } - }), - map(({ report, criticalApps, organization, fromDb }) => { - const criticalUrls = criticalApps.map((ca) => ca.uri); + map(([report, summary, isReportFromArchive, organization, criticalApps]) => { + const criticalUrls = criticalApps?.map((ca) => ca.uri); const data = report?.map((app) => ({ ...app, isMarkedAsCritical: criticalUrls.includes(app.applicationName), })) as ApplicationHealthReportDetailWithCriticalFlag[]; - return { data, organization, fromDb }; + + return { report: data, summary, criticalApps, isReportFromArchive, organization }; }), takeUntilDestroyed(this.destroyRef), ) - .subscribe(({ data, organization, fromDb }) => { - if (data) { - this.dataSource.data = data; - this.applicationSummary = this.reportService.generateApplicationsSummary(data); + .subscribe(({ report, summary, isReportFromArchive, organization }) => { + if (report) { + this.dataSource.data = report; + this.applicationSummary = summary; } + if (organization) { this.organization = organization; } - if (!fromDb && data && organization) { + if (!isReportFromArchive && report && organization) { this.atRiskInsightsReport.next({ - data, - organization, + data: report, + organization: organization, summary: this.applicationSummary, }); } }); - // combineLatest([ - // this.dataService.applications$, - // this.criticalAppsService.getAppsListForOrg(organizationId), - // organization$, - // ]) - // .pipe( - // takeUntilDestroyed(this.destroyRef), - // skipWhile(([_, __, organization]) => !organization), - // map(([applications, criticalApps, organization]) => { - // const criticalUrls = criticalApps.map((ca) => ca.uri); - // const data = applications?.map((app) => ({ - // ...app, - // isMarkedAsCritical: criticalUrls.includes(app.applicationName), - // })) as ApplicationHealthReportDetailWithCriticalFlag[]; - // return { data, organization }; - // }), - // ) - // .subscribe(({ data, organization }) => { - // if (data) { - // this.dataSource.data = data; - // this.applicationSummary = this.reportService.generateApplicationsSummary(data); - // } - // if (organization) { - // this.organization = organization; - // } - - // if (data && organization) { - // this.atRiskInsightsReport.next({ - // data, - // organization, - // summary: this.applicationSummary, - // }); - // } - // }); - - this.isLoading$ = this.dataService.isLoading$; + this.dataService.isLoadingData(false); } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 020b8c5380d..531ceaaad23 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -2,8 +2,8 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { EMPTY, Observable } from "rxjs"; -import { map, switchMap } from "rxjs/operators"; +import { combineLatest, Observable, of } from "rxjs"; +import { map } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -11,7 +11,6 @@ import { RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { - ApplicationHealthReportDetail, DrawerType, PasswordHealthReportApplicationsResponse, } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; @@ -80,9 +79,9 @@ export class RiskInsightsComponent implements OnInit { private organizationId: string | null = null; private destroyRef = inject(DestroyRef); - isLoading$: Observable = new Observable(); - isRefreshing$: Observable = new Observable(); - dataLastUpdated$: Observable = new Observable(); + isLoading$: Observable = this.dataService.isLoading$; + isRefreshing$: Observable = this.dataService.isRefreshing$; + dataLastUpdated$: Observable = this.dataService.dataLastUpdated$; refetching: boolean = false; constructor( @@ -106,30 +105,36 @@ export class RiskInsightsComponent implements OnInit { this.showDebugTabs = devFlagEnabled("showRiskInsightsDebug"); + this.isLoading$ = this.dataService.isLoading$; + this.isRefreshing$ = this.dataService.isRefreshing$; + this.dataLastUpdated$ = this.dataService.dataLastUpdated$; + this.route.paramMap .pipe( takeUntilDestroyed(this.destroyRef), map((params) => params.get("organizationId")), - switchMap((orgId: string | null) => { - if (orgId) { - this.organizationId = orgId; - // this.dataService.fetchApplicationsReport(orgId); - this.isLoading$ = this.dataService.isLoading$; - this.isRefreshing$ = this.dataService.isRefreshing$; - this.dataLastUpdated$ = this.dataService.dataLastUpdated$; - return this.dataService.applications$; - } else { - return EMPTY; - } - }), ) - .subscribe({ - next: (applications: ApplicationHealthReportDetail[] | null) => { - if (applications) { - this.appsCount = applications.length; - } - this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId); - }, + .subscribe((orgId: string | null) => { + if (orgId) { + this.criticalAppsService.setOrganizationId(orgId as OrganizationId); + this.organizationId = orgId; + } + }); + + combineLatest([ + this.dataService.applications$, + this.dataService.dataLastUpdated$, + this.criticalAppsService.getAppsListForOrg(this.organizationId as string), + ]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(([applications, dataLastUpdated, criticalApps]) => { + this.dataLastUpdated$ = of(dataLastUpdated); + + if (applications) { + this.appsCount = applications.length; + this.criticalAppsCount = criticalApps.length; + this.dataLastUpdated = new Date(dataLastUpdated); + } }); }