From d4271b49d8318d4681bb4f025caa62c6c798f00b Mon Sep 17 00:00:00 2001 From: voommen-livefront Date: Tue, 6 May 2025 16:25:09 -0500 Subject: [PATCH] pm-20577 encrypt and save api --- .../risk-insights/models/password-health.ts | 27 ++++++++ .../services/critical-apps.service.ts | 4 ++ .../reports/risk-insights/services/index.ts | 1 + .../risk-insights-api.service.spec.ts | 52 ++++++++++++++ .../services/risk-insights-api.service.ts | 28 ++++++++ .../services/risk-insights-report.service.ts | 42 ++++++++++- .../access-intelligence.module.ts | 8 +++ .../all-applications.component.ts | 69 ++++++++++++++++++- 8 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts 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 723d737d5bd..050bfb50e76 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 @@ -156,4 +156,31 @@ export enum DrawerType { OrgAtRiskApps = 3, } +export interface RiskInsightsReport { + organizationId: OrganizationId; + date: string; + reportData: string; + totalMembers: number; + totalAtRiskMembers: number; + totalApplications: number; + totalAtRiskApplications: number; + totalCriticalApplications: number; +} + +export interface SaveRiskInsightsReportRequest { + data: RiskInsightsReport; +} + +export interface SaveRiskInsightsReportResponse { + id: string; + organizationId: OrganizationId; + date: string; + reportData: string; + totalMembers: number; + totalAtRiskMembers: number; + totalApplications: number; + totalAtRiskApplications: number; + totalCriticalApplications: number; +} + export type PasswordHealthReportApplicationId = Opaque; 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 b879ef94705..edad5503e4a 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,6 +49,10 @@ 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/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts index f547df31f41..0c972081b7b 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts @@ -4,3 +4,4 @@ export * from "./critical-apps.service"; export * from "./critical-apps-api.service"; export * from "./risk-insights-report.service"; export * from "./risk-insights-data.service"; +export * from "./risk-insights-api.service"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts new file mode 100644 index 00000000000..1ce297377d2 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts @@ -0,0 +1,52 @@ +import { mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { RiskInsightsApiService } from "./risk-insights-api.service"; + +describe("RiskInsightsApiService", () => { + let service: RiskInsightsApiService; + const apiService = mock(); + + beforeEach(() => { + service = new RiskInsightsApiService(apiService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should call apiService.send with correct parameters for saveRiskInsightsReport", (done) => { + const orgId = "org1" as OrganizationId; + const request = { + data: { + organizationId: orgId, + date: new Date().toISOString(), + reportData: "test data", + totalMembers: 10, + totalAtRiskMembers: 5, + totalApplications: 100, + totalAtRiskApplications: 50, + totalCriticalApplications: 22, + }, + }; + const response = { + ...request.data, + }; + + apiService.send.mockReturnValue(Promise.resolve(response)); + + service.saveRiskInsightsReport(orgId, request).subscribe((result) => { + expect(result).toEqual(response); + expect(apiService.send).toHaveBeenCalledWith( + "PUT", + `/reports/risk-insights-report/${orgId.toString()}`, + request.data, + true, + true, + ); + done(); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts new file mode 100644 index 00000000000..bcf4b1efcd2 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts @@ -0,0 +1,28 @@ +import { from, Observable } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { + SaveRiskInsightsReportRequest, + SaveRiskInsightsReportResponse, +} from "../models/password-health"; + +export class RiskInsightsApiService { + constructor(private apiService: ApiService) {} + + saveRiskInsightsReport( + orgId: OrganizationId, + request: SaveRiskInsightsReportRequest, + ): Observable { + const dbResponse = this.apiService.send( + "PUT", + `/reports/risk-insights-report/${orgId.toString()}`, + request.data, + true, + true, + ); + + return from(dbResponse as Promise); + } +} 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 027760f678c..7c96a9a907e 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,13 +1,16 @@ // FIXME: Update this file to be type safe // @ts-strict-ignore -import { concatMap, first, from, map, Observable, zip } from "rxjs"; +import { concatMap, first, firstValueFrom, from, map, Observable, takeWhile, zip } from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { KeyService } from "@bitwarden/key-management"; import { ApplicationHealthReportDetail, @@ -20,8 +23,10 @@ import { MemberDetailsFlat, WeakPasswordDetail, WeakPasswordScore, + RiskInsightsReport, } from "../models/password-health"; +import { CriticalAppsService } from "./critical-apps.service"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; export class RiskInsightsReportService { @@ -30,6 +35,9 @@ export class RiskInsightsReportService { private auditService: AuditService, private cipherService: CipherService, private memberCipherDetailsApiService: MemberCipherDetailsApiService, + private keyService: KeyService, + private encryptService: EncryptService, + private criticalAppsService: CriticalAppsService, ) {} /** @@ -162,6 +170,38 @@ export class RiskInsightsReportService { }; } + async generateRiskInsightsReport( + organizationId: OrganizationId, + details: ApplicationHealthReportDetail[], + summary: ApplicationHealthReportSummary, + ): Promise { + const key = await this.keyService.getOrgKey(organizationId as string); + if (key === null) { + throw new Error("Organization key not found"); + } + + const reportData = await this.encryptService.encryptString(JSON.stringify(details), key); + + // const atRiskMembers = this.generateAtRiskMemberList(details); + // const atRiskApplications = this.generateAtRiskApplicationList(details); + const criticalApps = await firstValueFrom( + this.criticalAppsService + .getAppsListForOrg(organizationId) + .pipe(takeWhile((apps) => apps !== null && apps.length > 0)), + ); + + return { + organizationId: organizationId, + date: new Date().toISOString(), + reportData: reportData?.encryptedString?.toString() ?? "", + totalMembers: summary.totalMemberCount, + totalAtRiskMembers: summary.totalAtRiskMemberCount, + totalApplications: summary.totalApplicationCount, + totalAtRiskApplications: summary.totalAtRiskApplicationCount, + totalCriticalApplications: criticalApps.length, + }; + } + /** * Associates the members with the ciphers they have access to. Calculates the password health. * Finds the trimmed uris. 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 25baf2e0fed..5110a05283d 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 @@ -5,6 +5,7 @@ import { CriticalAppsService } from "@bitwarden/bit-common/dirt/reports/risk-ins import { CriticalAppsApiService, MemberCipherDetailsApiService, + RiskInsightsApiService, RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights/services"; @@ -32,6 +33,9 @@ import { RiskInsightsComponent } from "./risk-insights.component"; AuditService, CipherService, MemberCipherDetailsApiService, + KeyService, + EncryptService, + CriticalAppsService, ], }, { @@ -48,6 +52,10 @@ import { RiskInsightsComponent } from "./risk-insights.component"; useClass: CriticalAppsApiService, deps: [ApiService], }), + safeProvider({ + provide: RiskInsightsApiService, + deps: [ApiService], + }), ], }) export class AccessIntelligenceModule {} 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 f53272845f2..b9284cabbb3 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 @@ -2,10 +2,21 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { combineLatest, debounceTime, firstValueFrom, map, Observable, of, skipWhile } from "rxjs"; +import { + BehaviorSubject, + combineLatest, + debounceTime, + firstValueFrom, + map, + Observable, + of, + skipWhile, + switchMap, +} from "rxjs"; import { CriticalAppsService, + RiskInsightsApiService, RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; @@ -24,6 +35,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Icons, @@ -74,6 +86,12 @@ export class AllApplicationsComponent implements OnInit { isLoading$: Observable = of(false); isCriticalAppsFeatureEnabled = false; + private atRiskInsightsReport = new BehaviorSubject<{ + data: ApplicationHealthReportDetailWithCriticalFlag[]; + organization: Organization; + summary: ApplicationHealthReportSummary; + }>(null); + async ngOnInit() { this.isCriticalAppsFeatureEnabled = await this.configService.getFeatureFlag( FeatureFlag.CriticalApps, @@ -112,6 +130,16 @@ export class AllApplicationsComponent implements OnInit { if (organization) { this.organization = organization; } + + if (data && organization) { + this.atRiskInsightsReport.next({ + data, + organization, + summary: this.applicationSummary, + }); + } + + // this.persistReportData(); }); this.isLoading$ = this.dataService.isLoading$; @@ -129,10 +157,49 @@ export class AllApplicationsComponent implements OnInit { protected reportService: RiskInsightsReportService, private accountService: AccountService, protected criticalAppsService: CriticalAppsService, + protected riskInsightsApiService: RiskInsightsApiService, ) { this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) .subscribe((v) => (this.dataSource.filter = v)); + + this.atRiskInsightsReport + .asObservable() + .pipe( + debounceTime(500), + switchMap(async (report) => { + if (report && report.organization?.id && report.data && report.summary) { + const data = await this.reportService.generateRiskInsightsReport( + report.organization.id as OrganizationId, + report.data, + report.summary, + ); + return data; + } + return null; + }), + switchMap(async (reportData) => { + if (reportData) { + const request = { data: reportData }; + try { + const response = await firstValueFrom( + this.riskInsightsApiService.saveRiskInsightsReport( + this.organization.id as OrganizationId, + request, + ), + ); + // console.log("Risk insights report saved successfully.", JSON.stringify(response)); + return response; + } catch { + // console.error("Error saving risk insights report:", error); + } + + return null; + } + }), + takeUntilDestroyed(), + ) + .subscribe(); } goToCreateNewLoginItem = async () => {