diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts index 3fe40be7e1f..acbec1592a0 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts @@ -117,7 +117,7 @@ export type OrganizationReportApplication = { }; /** - * All applications report detail. Application is the cipher + * Report details for an application * uri. Has the at risk, password, and member information */ export type ApplicationHealthReportDetail = { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts index 067d3f887ea..72d7e88fcab 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts @@ -70,7 +70,7 @@ describe("CriticalAppsService", () => { const orgKey$ = new BehaviorSubject(OrgRecords); keyService.orgKeys$.mockReturnValue(orgKey$); - service.setOrganizationId(SomeOrganization, SomeUser); + service.loadOrganizationContext(SomeOrganization, SomeUser); // act await service.setCriticalApps(SomeOrganization, criticalApps); @@ -112,7 +112,7 @@ describe("CriticalAppsService", () => { const orgKey$ = new BehaviorSubject(OrgRecords); keyService.orgKeys$.mockReturnValue(orgKey$); - service.setOrganizationId(SomeOrganization, SomeUser); + service.loadOrganizationContext(SomeOrganization, SomeUser); // act await service.setCriticalApps(SomeOrganization, selectedUrls); @@ -136,7 +136,7 @@ describe("CriticalAppsService", () => { const orgKey$ = new BehaviorSubject(OrgRecords); keyService.orgKeys$.mockReturnValue(orgKey$); - service.setOrganizationId(SomeOrganization, SomeUser); + service.loadOrganizationContext(SomeOrganization, SomeUser); expect(keyService.orgKeys$).toHaveBeenCalledWith(SomeUser); expect(encryptService.decryptString).toHaveBeenCalledTimes(2); @@ -154,7 +154,7 @@ describe("CriticalAppsService", () => { const orgKey$ = new BehaviorSubject(OrgRecords); keyService.orgKeys$.mockReturnValue(orgKey$); - service.setOrganizationId(SomeOrganization, SomeUser); + service.loadOrganizationContext(SomeOrganization, SomeUser); service.setAppsInListForOrg(response); service.getAppsListForOrg(orgId as OrganizationId).subscribe((res) => { expect(res).toHaveLength(2); @@ -173,7 +173,7 @@ describe("CriticalAppsService", () => { const orgKey$ = new BehaviorSubject(OrgRecords); keyService.orgKeys$.mockReturnValue(orgKey$); - service.setOrganizationId(SomeOrganization, SomeUser); + service.loadOrganizationContext(SomeOrganization, SomeUser); service.setAppsInListForOrg(initialList); @@ -204,7 +204,7 @@ describe("CriticalAppsService", () => { const orgKey$ = new BehaviorSubject(OrgRecords); keyService.orgKeys$.mockReturnValue(orgKey$); - service.setOrganizationId(SomeOrganization, SomeUser); + service.loadOrganizationContext(SomeOrganization, SomeUser); service.setAppsInListForOrg(initialList); 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 be17bb2c0a5..82001387bbd 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 @@ -7,9 +7,7 @@ import { map, Observable, of, - Subject, switchMap, - takeUntil, zip, } from "rxjs"; @@ -30,17 +28,16 @@ import { CriticalAppsApiService } from "./critical-apps-api.service"; * Encrypts and saves data for a given organization */ export class CriticalAppsService { - private orgId = new BehaviorSubject(null); + // -------------------------- Context state -------------------------- + // The organization ID of the organization the user is currently viewing + private organizationId = new BehaviorSubject(null); private orgKey$ = new Observable(); - private criticalAppsList = new BehaviorSubject([]); - private teardown = new Subject(); - private fetchOrg$ = this.orgId - .pipe( - switchMap((orgId) => this.retrieveCriticalApps(orgId)), - takeUntil(this.teardown), - ) - .subscribe((apps) => this.criticalAppsList.next(apps)); + // -------------------------- Data ------------------------------------ + private criticalAppsListSubject$ = new BehaviorSubject< + PasswordHealthReportApplicationsResponse[] + >([]); + criticalAppsList$ = this.criticalAppsListSubject$.asObservable(); constructor( private keyService: KeyService, @@ -48,25 +45,52 @@ export class CriticalAppsService { private criticalAppsApiService: CriticalAppsApiService, ) {} + // Set context for the service for a specific organization + loadOrganizationContext(orgId: OrganizationId, userId: UserId) { + // Fetch the organization key for the user + this.orgKey$ = this.keyService.orgKeys$(userId).pipe( + filter((OrgKeys) => !!OrgKeys), + map((organizationKeysById) => organizationKeysById[orgId as OrganizationId]), + ); + + // Store organization id for service context + this.organizationId.next(orgId); + + // Setup the critical apps fetching for the organization + if (orgId) { + this.retrieveCriticalApps(orgId).subscribe({ + next: (result) => { + this.criticalAppsListSubject$.next(result); + }, + error: (error: unknown) => { + throw error; + }, + }); + } + } + // Get a list of critical apps for a given organization getAppsListForOrg(orgId: OrganizationId): Observable { - if (orgId != this.orgId.value) { - throw new Error("Organization ID mismatch"); + // [FIXME] Get organization id from context for all functions in this file + if (orgId != this.organizationId.value) { + throw new Error( + `Organization ID mismatch: expected ${this.organizationId.value}, got ${orgId}`, + ); } - return this.criticalAppsList + return this.criticalAppsListSubject$ .asObservable() .pipe(map((apps) => apps.filter((app) => app.organizationId === orgId))); } // Reset the critical apps list setAppsInListForOrg(apps: PasswordHealthReportApplicationsResponse[]) { - this.criticalAppsList.next(apps); + this.criticalAppsListSubject$.next(apps); } // Save the selected critical apps for a given organization async setCriticalApps(orgId: OrganizationId, selectedUrls: string[]) { - if (orgId != this.orgId.value) { + if (orgId != this.organizationId.value) { throw new Error("Organization ID mismatch"); } @@ -79,7 +103,7 @@ export class CriticalAppsService { // only save records that are not already in the database const newEntries = await this.filterNewEntries(orgId as OrganizationId, selectedUrls); const criticalAppsRequests = await this.encryptNewEntries( - this.orgId.value as OrganizationId, + this.organizationId.value as OrganizationId, orgKey, newEntries, ); @@ -89,7 +113,7 @@ export class CriticalAppsService { ); // add the new entries to the criticalAppsList - const updatedList = [...this.criticalAppsList.value]; + const updatedList = [...this.criticalAppsListSubject$.value]; for (const responseItem of dbResponse) { const decryptedUrl = await this.encryptService.decryptString( new EncString(responseItem.uri), @@ -103,26 +127,17 @@ export class CriticalAppsService { } as PasswordHealthReportApplicationsResponse); } } - this.criticalAppsList.next(updatedList); - } - - // Get the critical apps for a given organization - setOrganizationId(orgId: OrganizationId, userId: UserId) { - this.orgKey$ = this.keyService.orgKeys$(userId).pipe( - filter((OrgKeys) => !!OrgKeys), - map((organizationKeysById) => organizationKeysById[orgId as OrganizationId]), - ); - this.orgId.next(orgId); + this.criticalAppsListSubject$.next(updatedList); } // Drop a critical app for a given organization // Only one app may be dropped at a time async dropCriticalApp(orgId: OrganizationId, selectedUrl: string) { - if (orgId != this.orgId.value) { + if (orgId != this.organizationId.value) { throw new Error("Organization ID mismatch"); } - const app = this.criticalAppsList.value.find( + const app = this.criticalAppsListSubject$.value.find( (f) => f.organizationId === orgId && f.uri === selectedUrl, ); @@ -135,7 +150,9 @@ export class CriticalAppsService { passwordHealthReportApplicationIds: [app.id], }); - this.criticalAppsList.next(this.criticalAppsList.value.filter((f) => f.uri !== selectedUrl)); + this.criticalAppsListSubject$.next( + this.criticalAppsListSubject$.value.filter((f) => f.uri !== selectedUrl), + ); } private retrieveCriticalApps( @@ -170,7 +187,7 @@ export class CriticalAppsService { } private async filterNewEntries(orgId: OrganizationId, selectedUrls: string[]): Promise { - return await firstValueFrom(this.criticalAppsList).then((criticalApps) => { + return await firstValueFrom(this.criticalAppsListSubject$).then((criticalApps) => { const criticalAppsUri = criticalApps .filter((f) => f.organizationId === orgId) .map((f) => f.uri); 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 f3736c517e7..f58c13a9cfb 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,7 +1,13 @@ -import { BehaviorSubject } from "rxjs"; -import { finalize } from "rxjs/operators"; +import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs"; +import { finalize, switchMap, withLatestFrom } from "rxjs/operators"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +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 { AppAtRiskMembersDialogParams, @@ -9,14 +15,35 @@ import { AtRiskMemberDetail, DrawerType, ApplicationHealthReportDetail, + ApplicationHealthReportDetailEnriched, } from "../models/report-models"; +import { CriticalAppsService } from "./critical-apps.service"; import { RiskInsightsReportService } from "./risk-insights-report.service"; export class RiskInsightsDataService { - private applicationsSubject = new BehaviorSubject(null); + // -------------------------- Context state -------------------------- + // Current user viewing risk insights + private userIdSubject = new BehaviorSubject(null); + userId$ = this.userIdSubject.asObservable(); + // Organization the user is currently viewing + private organizationDetailsSubject = new BehaviorSubject<{ + organizationId: OrganizationId; + organizationName: string; + } | null>(null); + organizationDetails$ = this.organizationDetailsSubject.asObservable(); + + // -------------------------- Data ------------------------------------ + private applicationsSubject = new BehaviorSubject(null); applications$ = this.applicationsSubject.asObservable(); + private dataLastUpdatedSubject = new BehaviorSubject(null); + dataLastUpdated$ = this.dataLastUpdatedSubject.asObservable(); + + criticalApps$ = this.criticalAppsService.criticalAppsList$; + + // --------------------------- UI State ------------------------------------ + private isLoadingSubject = new BehaviorSubject(false); isLoading$ = this.isLoadingSubject.asObservable(); @@ -26,9 +53,6 @@ export class RiskInsightsDataService { private errorSubject = new BehaviorSubject(null); error$ = this.errorSubject.asObservable(); - private dataLastUpdatedSubject = new BehaviorSubject(null); - dataLastUpdated$ = this.dataLastUpdatedSubject.asObservable(); - openDrawer = false; drawerInvokerId: string = ""; activeDrawerType: DrawerType = DrawerType.None; @@ -36,7 +60,51 @@ export class RiskInsightsDataService { appAtRiskMembers: AppAtRiskMembersDialogParams | null = null; atRiskAppDetails: AtRiskApplicationDetail[] | null = null; - constructor(private reportService: RiskInsightsReportService) {} + constructor( + private accountService: AccountService, + private criticalAppsService: CriticalAppsService, + private organizationService: OrganizationService, + private reportService: RiskInsightsReportService, + ) {} + + // [FIXME] PM-25612 - Call Initialization in RiskInsightsComponent instead of child components + 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 - update when we can + // 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); + + // TODO: PM-25613 + // // Load existing report + + // this.fetchLastReport(organizationId, userId); + + // // Setup new report generation + // this._runApplicationsReport().subscribe({ + // next: (result) => { + // this.isRunningReportSubject.next(false); + // }, + // error: () => { + // this.errorSubject.next("Failed to save report"); + // }, + // }); + } /** * Fetches the applications report and updates the applicationsSubject. @@ -72,6 +140,44 @@ export class RiskInsightsDataService { this.fetchApplicationsReport(organizationId, true); } + // ------------------------------- 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 { + return of(applications).pipe( + withLatestFrom(this.organizationDetails$, this.criticalApps$), + switchMap(async ([apps, orgDetails, criticalApps]) => { + if (!orgDetails) { + return []; + } + + // Get ciphers for application + const cipherMap = await this.reportService.getApplicationCipherMap( + apps, + orgDetails.organizationId, + ); + + // 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[]; + }), + ); + } + + // ------------------------------- Drawer management methods ------------------------------- isActiveDrawerType = (drawerType: DrawerType): boolean => { return this.activeDrawerType === drawerType; }; 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 e6843385833..7341beb3fe2 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 @@ -59,15 +59,6 @@ import { RiskInsightsApiService } from "./risk-insights-api.service"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; export class RiskInsightsReportService { - constructor( - private passwordStrengthService: PasswordStrengthServiceAbstraction, - private auditService: AuditService, - private cipherService: CipherService, - private memberCipherDetailsApiService: MemberCipherDetailsApiService, - private riskInsightsApiService: RiskInsightsApiService, - private riskInsightsEncryptionService: RiskInsightsEncryptionService, - ) {} - private riskInsightsReportSubject = new BehaviorSubject([]); riskInsightsReport$ = this.riskInsightsReportSubject.asObservable(); @@ -84,6 +75,27 @@ export class RiskInsightsReportService { }); riskInsightsSummary$ = this.riskInsightsSummarySubject.asObservable(); + // [FIXME] CipherData + // Cipher data + // private _ciphersSubject = new BehaviorSubject(null); + // _ciphers$ = this._ciphersSubject.asObservable(); + + constructor( + private passwordStrengthService: PasswordStrengthServiceAbstraction, + private auditService: AuditService, + private cipherService: CipherService, + private memberCipherDetailsApiService: MemberCipherDetailsApiService, + private riskInsightsApiService: RiskInsightsApiService, + private riskInsightsEncryptionService: RiskInsightsEncryptionService, + ) {} + + // [FIXME] CipherData + // async loadCiphersForOrganization(organizationId: OrganizationId): Promise { + // await this.cipherService.getAllFromApiForOrganization(organizationId).then((ciphers) => { + // this._ciphersSubject.next(ciphers); + // }); + // } + /** * Report data from raw cipher health data. * Can be used in the Raw Data diagnostic tab (just exclude the members in the view) @@ -559,6 +571,31 @@ export class RiskInsightsReportService { return applicationMap; } + /** + * + * @param applications The list of application health report details to map ciphers to + * @param organizationId + * @returns + */ + async getApplicationCipherMap( + applications: ApplicationHealthReportDetail[], + organizationId: OrganizationId, + ): Promise> { + // [FIXME] CipherData + // This call is made multiple times. We can optimize this + // by loading the ciphers once via a load method to avoid multiple API calls + // for the same organization + const allCiphers = await this.cipherService.getAllFromApiForOrganization(organizationId); + const cipherMap = new Map(); + + applications.forEach((app) => { + const filteredCiphers = allCiphers.filter((c) => app.cipherIds.includes(c.id)); + cipherMap.set(app.applicationName, filteredCiphers); + }); + return cipherMap; + } + + // --------------------------- Aggregation methods --------------------------- /** * Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item. * If the item is new, create and add the object with the flattened details 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 0fe1737bde3..c39f06a57a9 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 @@ -11,6 +11,8 @@ import { import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.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"; +import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction"; @@ -36,10 +38,15 @@ import { RiskInsightsComponent } from "./risk-insights.component"; MemberCipherDetailsApiService, ], }, - { + safeProvider({ provide: RiskInsightsDataService, - deps: [RiskInsightsReportService], - }, + deps: [ + AccountServiceAbstraction, + CriticalAppsService, + OrganizationService, + RiskInsightsReportService, + ], + }), { provide: RiskInsightsEncryptionService, useClass: RiskInsightsEncryptionService, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts index c8bc3e81680..481ed39a004 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts @@ -66,40 +66,42 @@ export class CriticalApplicationsComponent implements OnInit { "organizationId", ) as OrganizationId; const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId, userId); - // this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId); - combineLatest([ - this.dataService.applications$, - this.criticalAppsService.getAppsListForOrg(this.organizationId as OrganizationId), - ]) - .pipe( - takeUntilDestroyed(this.destroyRef), - map(([applications, criticalApps]) => { - const criticalUrls = criticalApps.map((ca) => ca.uri); - const data = applications?.map((app) => ({ - ...app, - isMarkedAsCritical: criticalUrls.includes(app.applicationName), - })) as LEGACY_ApplicationHealthReportDetailWithCriticalFlag[]; - return data?.filter((app) => app.isMarkedAsCritical); - }), - switchMap(async (data) => { - if (data) { - const dataWithCiphers = await this.reportService.identifyCiphers( - data, - this.organizationId, - ); - return dataWithCiphers; + this.criticalAppsService.loadOrganizationContext(this.organizationId as OrganizationId, userId); + + if (this.organizationId) { + combineLatest([ + this.dataService.applications$, + this.criticalAppsService.getAppsListForOrg(this.organizationId as OrganizationId), + ]) + .pipe( + takeUntilDestroyed(this.destroyRef), + map(([applications, criticalApps]) => { + const criticalUrls = criticalApps.map((ca) => ca.uri); + const data = applications?.map((app) => ({ + ...app, + isMarkedAsCritical: criticalUrls.includes(app.applicationName), + })) as LEGACY_ApplicationHealthReportDetailWithCriticalFlag[]; + return data?.filter((app) => app.isMarkedAsCritical); + }), + switchMap(async (data) => { + if (data) { + const dataWithCiphers = await this.reportService.identifyCiphers( + data, + this.organizationId, + ); + return dataWithCiphers; + } + return null; + }), + ) + .subscribe((applications) => { + if (applications) { + this.dataSource.data = applications; + this.applicationSummary = this.reportService.generateApplicationsSummary(applications); + this.enableRequestPasswordChange = this.applicationSummary.totalAtRiskMemberCount > 0; } - return null; - }), - ) - .subscribe((applications) => { - if (applications) { - this.dataSource.data = applications; - this.applicationSummary = this.reportService.generateApplicationsSummary(applications); - this.enableRequestPasswordChange = this.applicationSummary.totalAtRiskMemberCount > 0; - } - }); + }); + } } goToAllAppsTab = async () => { 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 b7e440880e3..8279ae612e9 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 @@ -127,7 +127,10 @@ export class RiskInsightsComponent implements OnInit { this.appsCount = applications.length; } - this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId, userId); + this.criticalAppsService.loadOrganizationContext( + this.organizationId as OrganizationId, + userId, + ); this.criticalApps$ = this.criticalAppsService.getAppsListForOrg( this.organizationId as OrganizationId, );