1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +00:00

[PM-25614] Add Encrichment Logic for Risk Insights Data Service (#16577)

* Add encryption logic. Minor updates to critical apps service

* Fix possibly null type
This commit is contained in:
Leslie Tilton
2025-09-26 09:53:08 -05:00
committed by GitHub
parent f1a5d7af5e
commit 466bf18d51
8 changed files with 265 additions and 93 deletions

View File

@@ -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 = {

View File

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

View File

@@ -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<OrganizationId | null>(null);
// -------------------------- Context state --------------------------
// The organization ID of the organization the user is currently viewing
private organizationId = new BehaviorSubject<OrganizationId | null>(null);
private orgKey$ = new Observable<OrgKey>();
private criticalAppsList = new BehaviorSubject<PasswordHealthReportApplicationsResponse[]>([]);
private teardown = new Subject<void>();
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<PasswordHealthReportApplicationsResponse[]> {
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<string[]> {
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);

View File

@@ -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<ApplicationHealthReportDetail[] | null>(null);
// -------------------------- Context state --------------------------
// Current user viewing risk insights
private userIdSubject = new BehaviorSubject<UserId | null>(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<ApplicationHealthReportDetail[] | null>(null);
applications$ = this.applicationsSubject.asObservable();
private dataLastUpdatedSubject = new BehaviorSubject<Date | null>(null);
dataLastUpdated$ = this.dataLastUpdatedSubject.asObservable();
criticalApps$ = this.criticalAppsService.criticalAppsList$;
// --------------------------- UI State ------------------------------------
private isLoadingSubject = new BehaviorSubject<boolean>(false);
isLoading$ = this.isLoadingSubject.asObservable();
@@ -26,9 +53,6 @@ export class RiskInsightsDataService {
private errorSubject = new BehaviorSubject<string | null>(null);
error$ = this.errorSubject.asObservable();
private dataLastUpdatedSubject = new BehaviorSubject<Date | null>(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<ApplicationHealthReportDetailEnriched[]> {
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;
};

View File

@@ -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<ApplicationHealthReportDetail[]>([]);
riskInsightsReport$ = this.riskInsightsReportSubject.asObservable();
@@ -84,6 +75,27 @@ export class RiskInsightsReportService {
});
riskInsightsSummary$ = this.riskInsightsSummarySubject.asObservable();
// [FIXME] CipherData
// Cipher data
// private _ciphersSubject = new BehaviorSubject<CipherView[] | null>(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<void> {
// 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<Map<string, CipherView[]>> {
// [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<string, CipherView[]>();
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

View File

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

View File

@@ -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 () => {

View File

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