1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-03 10:13:31 +00:00

Updated migration of critical applications and merged old saved data to new critical applications on report object

This commit is contained in:
Leslie Tilton
2025-10-14 12:21:03 -05:00
parent 127f4cc017
commit b0c81f461e
16 changed files with 381 additions and 234 deletions

View File

@@ -1,6 +1,6 @@
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
import { createNewSummaryData } from "../helpers";
@@ -46,11 +46,11 @@ export interface SaveRiskInsightsReportRequest {
}
export class SaveRiskInsightsReportResponse extends BaseResponse {
id: string;
id: OrganizationReportId;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("organizationId");
this.id = this.getResponseProperty("id");
}
}
export function isSaveRiskInsightsReportResponse(obj: any): obj is SaveRiskInsightsReportResponse {
@@ -69,7 +69,7 @@ export class GetRiskInsightsReportResponse extends BaseResponse {
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("organizationId");
this.id = this.getResponseProperty("id");
this.organizationId = this.getResponseProperty("organizationId");
this.creationDate = new Date(this.getResponseProperty("creationDate"));
this.reportData = new EncString(this.getResponseProperty("reportData"));
@@ -130,3 +130,14 @@ export class MemberCipherDetailsResponse extends BaseResponse {
this.cipherIds = this.getResponseProperty("CipherIds");
}
}
export interface UpdateRiskInsightsApplicationDataRequest {
data: {
applicationData: string;
};
}
export class UpdateRiskInsightsApplicationDataResponse extends BaseResponse {
constructor(response: any) {
super(response);
}
}

View File

@@ -18,6 +18,7 @@ export type DrawerDetails = {
appAtRiskMembers?: AppAtRiskMembersDialogParams | null;
atRiskAppDetails?: AtRiskApplicationDetail[] | null;
};
export type AppAtRiskMembersDialogParams = {
members: MemberDetails[];
applicationName: string;
@@ -28,7 +29,6 @@ export type AppAtRiskMembersDialogParams = {
* At risk member detail that contains the email
* and the count of at risk ciphers
*/
export type AtRiskMemberDetail = {
email: string;
atRiskPasswordCount: number;

View File

@@ -233,7 +233,9 @@ describe("RiskInsightsApiService", () => {
mockApiService.send.mockResolvedValueOnce(undefined);
const result = await firstValueFrom(
service.updateRiskInsightsApplicationData$(mockApplication.encryptedString, orgId, reportId),
service.updateRiskInsightsApplicationData$(reportId, orgId, {
data: { applicationData: mockApplication.encryptedString! },
}),
);
expect(mockApiService.send).toHaveBeenCalledWith(
"PATCH",

View File

@@ -4,7 +4,11 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
import { EncryptedDataWithKey } from "../../models";
import {
EncryptedDataWithKey,
UpdateRiskInsightsApplicationDataRequest,
UpdateRiskInsightsApplicationDataResponse,
} from "../../models";
import {
GetRiskInsightsApplicationDataResponse,
GetRiskInsightsReportResponse,
@@ -102,18 +106,20 @@ export class RiskInsightsApiService {
}
updateRiskInsightsApplicationData$(
applicationData: string,
orgId: OrganizationId,
reportId: OrganizationReportId,
): Observable<void> {
orgId: OrganizationId,
request: UpdateRiskInsightsApplicationDataRequest,
): Observable<UpdateRiskInsightsApplicationDataResponse> {
const dbResponse = this.apiService.send(
"PATCH",
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
applicationData,
{ ...request.data, id: reportId, organizationId: orgId },
true,
true,
);
return from(dbResponse as Promise<void>);
return from(dbResponse).pipe(
map((response) => new UpdateRiskInsightsApplicationDataResponse(response)),
);
}
}

View File

@@ -181,7 +181,7 @@ describe("CriticalAppsService", () => {
privateCriticalAppsSubject.next(initialList);
// act
await service.dropCriticalApp(SomeOrganization, selectedUrl);
await service.dropCriticalAppByUrl(SomeOrganization, selectedUrl);
// expectations
expect(criticalAppsApiService.dropCriticalApp).toHaveBeenCalledWith({
@@ -213,7 +213,7 @@ describe("CriticalAppsService", () => {
privateCriticalAppsSubject.next(initialList);
// act
await service.dropCriticalApp(SomeOrganization, selectedUrl);
await service.dropCriticalAppByUrl(SomeOrganization, selectedUrl);
// expectations
expect(criticalAppsApiService.dropCriticalApp).not.toHaveBeenCalled();

View File

@@ -1,13 +1,17 @@
import {
BehaviorSubject,
catchError,
filter,
first,
firstValueFrom,
forkJoin,
from,
map,
Observable,
of,
switchMap,
tap,
throwError,
zip,
} from "rxjs";
@@ -124,9 +128,14 @@ export class CriticalAppsService {
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) {
/**
* Drop a critical application by url
*
* @param orgId
* @param selectedUrl
* @returns
*/
async dropCriticalAppByUrl(orgId: OrganizationId, selectedUrl: string) {
if (orgId != this.organizationId.value) {
throw new Error("Organization ID mismatch");
}
@@ -140,16 +149,41 @@ export class CriticalAppsService {
}
// TODO Uncomment when done testing that the migration is working
// await this.criticalAppsApiService.dropCriticalApp({
// organizationId: app.organizationId,
// passwordHealthReportApplicationIds: [app.id],
// });
await this.criticalAppsApiService.dropCriticalApp({
organizationId: app.organizationId,
passwordHealthReportApplicationIds: [app.id],
});
this.criticalAppsListSubject$.next(
this.criticalAppsListSubject$.value.filter((f) => f.uri !== selectedUrl),
);
}
/**
* Drop multiple critical applications by id
*
* @param orgId
* @param ids
* @returns
*/
dropCriticalAppsById(orgId: OrganizationId, ids: string[]) {
return from(
this.criticalAppsApiService.dropCriticalApp({
organizationId: orgId,
passwordHealthReportApplicationIds: ids,
}),
).pipe(
tap((response) => {
this.criticalAppsListSubject$.next(
this.criticalAppsListSubject$.value.filter((f) => ids.some((id) => id === f.id)),
);
}),
catchError((error: unknown) => {
return throwError(() => error);
}),
);
}
private retrieveCriticalApps(
orgId: OrganizationId | null,
): Observable<PasswordHealthReportApplicationsResponse[]> {

View File

@@ -9,6 +9,7 @@ import { makeSymmetricCryptoKey } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { EncryptedReportData, DecryptedReportData } from "../../models";
import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data";
@@ -20,6 +21,7 @@ describe("RiskInsightsEncryptionService", () => {
const mockKeyService = mock<KeyService>();
const mockEncryptService = mock<EncryptService>();
const mockKeyGenerationService = mock<KeyGenerationService>();
const mockLogService = mock<LogService>();
const ENCRYPTED_TEXT = "This data has been encrypted";
const ENCRYPTED_KEY = "Re-encrypted Cipher Key";
@@ -43,6 +45,7 @@ describe("RiskInsightsEncryptionService", () => {
mockKeyService,
mockEncryptService,
mockKeyGenerationService,
mockLogService,
);
jest.clearAllMocks();

View File

@@ -6,14 +6,24 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { DecryptedReportData, EncryptedReportData, EncryptedDataWithKey } from "../../models";
import { createNewSummaryData } from "../../helpers";
import {
DecryptedReportData,
EncryptedReportData,
EncryptedDataWithKey,
ApplicationHealthReportDetail,
OrganizationReportSummary,
OrganizationReportApplication,
} from "../../models";
export class RiskInsightsEncryptionService {
constructor(
private keyService: KeyService,
private encryptService: EncryptService,
private keyGeneratorService: KeyGenerationService,
private logService: LogService,
) {}
async encryptRiskInsightsReport(
@@ -24,6 +34,7 @@ export class RiskInsightsEncryptionService {
data: DecryptedReportData,
wrappedKey?: EncString,
): Promise<EncryptedDataWithKey> {
this.logService.info("[RiskInsightsEncryptionService] Encrypting risk insights report");
const { userId, organizationId } = context;
const orgKey = await firstValueFrom(
this.keyService
@@ -36,16 +47,24 @@ export class RiskInsightsEncryptionService {
);
if (!orgKey) {
this.logService.warning(
"[RiskInsightsEncryptionService] Attempted to encrypt report data without org id",
);
throw new Error("Organization key not found");
}
let contentEncryptionKey: SymmetricCryptoKey;
if (!wrappedKey) {
// Generate a new key
contentEncryptionKey = await this.keyGeneratorService.createKey(512);
} else {
// Unwrap the existing key
contentEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey);
try {
if (!wrappedKey) {
// Generate a new key
contentEncryptionKey = await this.keyGeneratorService.createKey(512);
} else {
// Unwrap the existing key
contentEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey);
}
} catch (error: unknown) {
this.logService.error("[RiskInsightsEncryptionService] Failed to get encryption key", error);
throw new Error("Failed to get encryption key");
}
const { reportData, summaryData, applicationData } = data;
@@ -75,6 +94,9 @@ export class RiskInsightsEncryptionService {
!encryptedApplicationData.encryptedString ||
!wrappedEncryptionKey.encryptedString
) {
this.logService.error(
"[RiskInsightsEncryptionService] Encryption failed, encrypted strings are null",
);
throw new Error("Encryption failed, encrypted strings are null");
}
@@ -97,6 +119,8 @@ export class RiskInsightsEncryptionService {
encryptedData: EncryptedReportData,
wrappedKey: EncString,
): Promise<DecryptedReportData> {
this.logService.info("[RiskInsightsEncryptionService] Decrypting risk insights report");
const { userId, organizationId } = context;
const orgKey = await firstValueFrom(
this.keyService
@@ -109,47 +133,106 @@ export class RiskInsightsEncryptionService {
);
if (!orgKey) {
this.logService.warning(
"[RiskInsightsEncryptionService] Attempted to decrypt report data without org id",
);
throw new Error("Organization key not found");
}
const unwrappedEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey);
if (!unwrappedEncryptionKey) {
this.logService.error("[RiskInsightsEncryptionService] Encryption key not found");
throw Error("Encryption key not found");
}
const { encryptedReportData, encryptedSummaryData, encryptedApplicationData } = encryptedData;
if (!encryptedReportData || !encryptedSummaryData || !encryptedApplicationData) {
throw new Error("Missing data");
}
// Decrypt the data
const decryptedReportData = await this.encryptService.decryptString(
const decryptedReportData = await this._handleDecryptReport(
encryptedReportData,
unwrappedEncryptionKey,
);
const decryptedSummaryData = await this.encryptService.decryptString(
const decryptedSummaryData = await this._handleDecryptSummary(
encryptedSummaryData,
unwrappedEncryptionKey,
);
const decryptedApplicationData = await this.encryptService.decryptString(
const decryptedApplicationData = await this._handleDecryptApplication(
encryptedApplicationData,
unwrappedEncryptionKey,
);
if (!decryptedReportData || !decryptedSummaryData || !decryptedApplicationData) {
throw new Error("Decryption failed, decrypted strings are null");
}
const decryptedReportDataJson = JSON.parse(decryptedReportData);
const decryptedSummaryDataJson = JSON.parse(decryptedSummaryData);
const decryptedApplicationDataJson = JSON.parse(decryptedApplicationData);
const decryptedFullReport = {
reportData: decryptedReportDataJson,
summaryData: decryptedSummaryDataJson,
applicationData: decryptedApplicationDataJson,
reportData: decryptedReportData,
summaryData: decryptedSummaryData,
applicationData: decryptedApplicationData,
};
return decryptedFullReport;
}
private async _handleDecryptReport(
encryptedData: EncString | null,
key: SymmetricCryptoKey,
): Promise<ApplicationHealthReportDetail[]> {
if (encryptedData == null) {
return [];
}
try {
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
const parsedData = JSON.parse(decryptedData);
// TODO Add type guard to check that parsed data is actual type
return parsedData as ApplicationHealthReportDetail[];
} catch (error: unknown) {
this.logService.error("[RiskInsightsEncryptionService] Failed to decrypt report", error);
return [];
}
}
private async _handleDecryptSummary(
encryptedData: EncString | null,
key: SymmetricCryptoKey,
): Promise<OrganizationReportSummary> {
if (encryptedData == null) {
return createNewSummaryData();
}
try {
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
const parsedData = JSON.parse(decryptedData);
// TODO Add type guard to check that parsed data is actual type
return parsedData as OrganizationReportSummary;
} catch (error: unknown) {
this.logService.error(
"[RiskInsightsEncryptionService] Failed to decrypt report summary",
error,
);
return createNewSummaryData();
}
}
private async _handleDecryptApplication(
encryptedData: EncString | null,
key: SymmetricCryptoKey,
): Promise<OrganizationReportApplication[]> {
if (encryptedData == null) {
return [];
}
try {
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
const parsedData = JSON.parse(decryptedData);
// TODO Add type guard to check that parsed data is actual type
return parsedData as OrganizationReportApplication[];
} catch (error: unknown) {
this.logService.error(
"[RiskInsightsEncryptionService] Failed to decrypt report applications",
error,
);
return [];
}
}
}

View File

@@ -8,7 +8,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { LogService } from "@bitwarden/logging";
import { createNewSummaryData } from "../../helpers";
import { ReportState } from "../../models";
import { RiskInsightsData, SaveRiskInsightsReportResponse } from "../../models";
import {
mockApplicationData,
mockEnrichedReportData,
@@ -68,18 +68,14 @@ describe("RiskInsightsOrchestratorService", () => {
const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"];
const privateUserIdSubject = service["_userIdSubject"];
// Arrange
const reportState: ReportState = {
loading: false,
error: null,
data: {
id: mockReportId,
reportData: [],
summaryData: createNewSummaryData(),
applicationData: [],
creationDate: new Date(),
},
const reportState: RiskInsightsData = {
id: mockReportId,
reportData: [],
summaryData: createNewSummaryData(),
applicationData: [],
creationDate: new Date(),
};
mockReportService.getRiskInsightsReport$.mockReturnValueOnce(of(reportState.data));
mockReportService.getRiskInsightsReport$.mockReturnValueOnce(of(reportState));
// Set up organization and user context
privateOrganizationDetailsSubject.next({
organizationId: mockOrgId,
@@ -97,7 +93,7 @@ describe("RiskInsightsOrchestratorService", () => {
mockOrgId,
mockUserId,
);
expect(state.data).toEqual(reportState.data);
expect(state.data).toEqual(reportState);
done();
}
});
@@ -134,7 +130,9 @@ describe("RiskInsightsOrchestratorService", () => {
mockReportService.generateApplicationsReport.mockReturnValueOnce(mockEnrichedReportData);
mockReportService.getApplicationsSummary.mockReturnValueOnce(mockSummaryData);
mockReportService.getOrganizationApplications.mockReturnValueOnce(mockApplicationData);
mockReportService.saveRiskInsightsReport$.mockReturnValueOnce(of(null));
mockReportService.saveRiskInsightsReport$.mockReturnValueOnce(
of({ id: mockReportId } as SaveRiskInsightsReportResponse),
);
privateOrganizationDetailsSubject.next({
organizationId: mockOrgId,
organizationName: mockOrgName,
@@ -146,7 +144,7 @@ describe("RiskInsightsOrchestratorService", () => {
// Assert
service.rawReportData$.subscribe((state) => {
if (!state.loading) {
if (!state.loading && state.data) {
expect(mockReportService.generateApplicationsReport).toHaveBeenCalledWith(mockOrgId);
expect(mockReportService.saveRiskInsightsReport$).toHaveBeenCalledWith(
mockEnrichedReportData,
@@ -189,22 +187,20 @@ describe("RiskInsightsOrchestratorService", () => {
});
describe("destroy", () => {
it("should complete destroy$ subject and unsubscribe reportStateSubscription", (done) => {
const privateDestroy = service["_destroy$"];
const privateReportStateSubscription = service["_reportStateSubscription"];
it("should complete destroy$ subject and unsubscribe reportStateSubscription", () => {
const privateDestroy = (service as any)._destroy$;
const privateReportStateSubscription = (service as any)._reportStateSubscription;
// Spy on the methods you expect to be called.
const destroyCompleteSpy = jest.spyOn(privateDestroy, "complete");
const unsubscribeSpy = jest.spyOn(privateReportStateSubscription, "unsubscribe");
// Execute the destroy method.
service.destroy();
// Assert that the methods were called as expected.
expect(destroyCompleteSpy).toHaveBeenCalled();
expect(unsubscribeSpy).toHaveBeenCalled();
privateDestroy.subscribe({
error: (err: unknown) => {
done.fail("Should not error: " + err);
},
complete: () => {
done();
},
});
});
});
});

View File

@@ -8,6 +8,7 @@ import {
of,
Subject,
Subscription,
throwError,
} from "rxjs";
import {
catchError,
@@ -36,8 +37,16 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LogService } from "@bitwarden/logging";
import { buildPasswordUseMap, flattenMemberDetails, getTrimmedCipherUris } from "../../helpers";
import { ApplicationHealthReportDetailEnriched } from "../../models";
import {
buildPasswordUseMap,
createNewSummaryData,
flattenMemberDetails,
getTrimmedCipherUris,
} from "../../helpers";
import {
ApplicationHealthReportDetailEnriched,
PasswordHealthReportApplicationsResponse,
} from "../../models";
import { RiskInsightsEnrichedData } from "../../models/report-data-service.types";
import {
CipherHealthReport,
@@ -92,7 +101,9 @@ export class RiskInsightsOrchestratorService {
// --------------------------- Trigger subjects ---------------------
private _initializeOrganizationTriggerSubject = new Subject<OrganizationId>();
private _fetchReportTriggerSubject = new Subject<void>();
private _reportStateSubscription: Subscription;
private _reportStateSubscription: Subscription | null = null;
private _migrationSubscription: Subscription | null = null;
constructor(
private accountService: AccountService,
@@ -121,6 +132,9 @@ export class RiskInsightsOrchestratorService {
if (this._reportStateSubscription) {
this._reportStateSubscription.unsubscribe();
}
if (this._migrationSubscription) {
this._migrationSubscription.unsubscribe();
}
this._destroy$.next();
this._destroy$.complete();
}
@@ -151,50 +165,33 @@ export class RiskInsightsOrchestratorService {
this._initializeOrganizationTriggerSubject.next(organizationId);
}
setCriticalApplications$(criticalApplications: string[]): Observable<ReportState> {
saveCriticalApplications$(criticalApplications: string[]): Observable<ReportState> {
this.logService.debug(
"[RiskInsightsOrchestratorService] Saving critical applications to report",
);
return this.rawReportData$.pipe(
take(1),
withLatestFrom(this.organizationDetails$, this._userId$),
filter((data) => !data.loading && data.data != null),
withLatestFrom(
this.organizationDetails$.pipe(filter((org) => !!org && !!org.organizationId)),
this._userId$.pipe(filter((userId) => !!userId)),
),
map(([reportState, organizationDetails, userId]) => {
if (!organizationDetails) {
this.logService.warning(
"[RiskInsightsOrchestratorService] No organization details available when setting critical applications.",
);
return {
reportState,
organizationDetails: null,
updatedState: reportState,
}; // Return current state if no org details
}
// Handle the case where there is no report data
if (!reportState?.data) {
this.logService.warning(
"[RiskInsightsOrchestratorService] Attempted to set critical applications with no report data.",
);
return {
reportState,
organizationDetails,
updatedState: reportState,
};
}
// Create a set for quick lookup of the new critical apps
const newCriticalAppNamesSet = new Set(criticalApplications);
const existingApplicationData = reportState.data.applicationData || [];
const existingApplicationData = reportState?.data?.applicationData || [];
const updatedApplicationData = this._mergeApplicationData(
existingApplicationData,
newCriticalAppNamesSet,
);
const updatedState: ReportState = {
const updatedState = {
...reportState,
data: {
...reportState.data,
applicationData: updatedApplicationData,
},
};
} as ReportState;
this.logService.debug(
"[RiskInsightsOrchestratorService] Updated applications data",
@@ -207,12 +204,12 @@ export class RiskInsightsOrchestratorService {
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
{
organizationId: organizationDetails!.organizationId,
userId,
userId: userId!,
},
{
reportData: reportState.data.reportData,
summaryData: reportState.data.summaryData,
applicationData: updatedState.data.applicationData,
reportData: reportState?.data?.reportData ?? [],
summaryData: reportState?.data?.summaryData ?? createNewSummaryData(),
applicationData: updatedState?.data?.applicationData ?? [],
},
),
).pipe(
@@ -225,19 +222,25 @@ export class RiskInsightsOrchestratorService {
);
}),
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
// Chain the save operation using switchMap
this.logService.debug(
`[RiskInsightsOrchestratorService] Saving updated applicationData with report id: ${reportState?.data?.id} and org id: ${organizationDetails?.organizationId}`,
);
if (!reportState?.data?.id || !organizationDetails?.organizationId) {
return of({ ...reportState });
}
return this.reportApiService
.updateRiskInsightsApplicationData$(
encryptedData.encryptedApplicationData.encryptedString,
organizationDetails.organizationId,
reportState.data.id,
organizationDetails.organizationId,
{
data: {
applicationData: encryptedData.encryptedApplicationData.toSdk(),
},
},
)
.pipe(
// Map the result of the save operation to the updated state
map(() => updatedState),
// Use tap to push the updated state to the subject
tap((finalState) => this._rawReportDataSubject.next(finalState)),
// Handle errors from the save operation
catchError((error: unknown) => {
this.logService.error("Failed to save updated applicationData", error);
return of({ ...reportState, error: "Failed to save application data" });
@@ -263,6 +266,9 @@ export class RiskInsightsOrchestratorService {
},
}),
),
tap((fetchedReport) =>
this.logService.debug("[RiskInsightsOrchestratorService] _fetchReport$", fetchedReport),
),
catchError(() => of({ loading: false, error: "Failed to fetch report", data: null })),
startWith({ loading: true, error: null, data: null }),
);
@@ -279,18 +285,17 @@ export class RiskInsightsOrchestratorService {
return forkJoin([this._ciphers$, memberCiphers$]).pipe(
tap(() => this.logService.debug("[RiskInsightsOrchestratorService] Generating new report")),
switchMap(([ciphers, memberCiphers]) => this._getCipherHealth(ciphers, memberCiphers)),
switchMap(([ciphers, memberCiphers]) => this._getCipherHealth(ciphers ?? [], memberCiphers)),
map((cipherHealthReports) =>
this.reportService.generateApplicationsReport(cipherHealthReports),
),
withLatestFrom(this.rawReportData$),
map(([report, previousReport]) => ({
report: report,
summary: this.reportService.getApplicationsSummary(report),
applications: this.reportService.getOrganizationApplications(
report,
previousReport.data.applicationData,
previousReport?.data?.applicationData ?? [],
),
})),
switchMap(({ report, summary, applications }) =>
@@ -354,7 +359,6 @@ export class RiskInsightsOrchestratorService {
const applications = getTrimmedCipherUris(cipher);
const weakPasswordDetail = this.passwordHealthService.findWeakPasswordDetails(cipher);
const reusedPasswordCount = passwordUseMap.get(cipher.login.password!) ?? 0;
return {
cipher,
cipherMembers,
@@ -364,68 +368,79 @@ export class RiskInsightsOrchestratorService {
reusedPasswordCount,
exposedPasswordDetail,
},
};
} as CipherHealthReport;
});
}),
);
}
private _runMigrationAndCleanup$(): Observable<OrganizationReportApplication[]> {
// Start with rawReportData$ to ensure it has a value
return this.rawReportData$.pipe(
// Ensure rawReportData has a data payload
filter((reportState) => !!reportState.data),
take(1), // Use the first valid report state
// Now switch to the migration logic
switchMap((rawReportData) =>
this.criticalAppsService.criticalAppsList$.pipe(
take(1),
withLatestFrom(this.organizationDetails$),
switchMap(([savedCriticalApps, organizationDetails]) => {
// Check if there are any critical apps to migrate.
if (!savedCriticalApps || savedCriticalApps.length === 0) {
this.logService.debug(
"[RiskInsightsOrchestratorService] No critical apps to migrate.",
private _mergeApplicationData(
existingApplications: OrganizationReportApplication[],
criticalApplications: Set<string>,
): OrganizationReportApplication[] {
const setToMerge = new Set(criticalApplications);
// First, iterate through the existing apps and update their isCritical flag
const updatedApps = existingApplications.map((app) => {
const foundCritical = setToMerge.has(app.applicationName);
if (foundCritical) {
setToMerge.delete(app.applicationName);
}
return {
...app,
isCritical: foundCritical || app.isCritical,
};
});
setToMerge.forEach((applicationName) => {
updatedApps.push({
applicationName,
isCritical: true,
reviewedDate: null,
});
});
return updatedApps;
}
private _runMigrationAndCleanup$(criticalApps: PasswordHealthReportApplicationsResponse[]) {
return of(criticalApps).pipe(
withLatestFrom(this.organizationDetails$),
switchMap(([savedCriticalApps, organizationDetails]) => {
// No saved critical apps for migration
if (!savedCriticalApps || savedCriticalApps.length === 0) {
this.logService.debug("[RiskInsightsOrchestratorService] No critical apps to migrate.");
return of([]);
}
const criticalAppsNames = savedCriticalApps.map((app) => app.uri);
const criticalAppsIds = savedCriticalApps.map((app) => app.id);
// Use the setCriticalApplications$ function to update and save the report
return this.saveCriticalApplications$(criticalAppsNames).pipe(
// After setCriticalApplications$ completes, trigger the deletion.
switchMap(() => {
return this.criticalAppsService
.dropCriticalAppsById(organizationDetails!.organizationId, criticalAppsIds)
.pipe(
// After all deletes complete, map to the migrated apps.
tap(() => {
this.logService.debug(
"[RiskInsightsOrchestratorService] Migrated and deleted critical applications.",
);
}),
);
return of([]);
}
// Map the saved critical apps to the new format
const migratedApps = savedCriticalApps.map(
(app): OrganizationReportApplication => ({
applicationName: app.uri,
isCritical: true,
reviewedDate: null,
}),
);
// Use the setCriticalApplications$ function to update and save the report
return this.setCriticalApplications$(
migratedApps.map((app) => app.applicationName),
).pipe(
// After setCriticalApplications$ completes, trigger the deletion.
switchMap(() => {
const deleteObservables = savedCriticalApps.map(
(app) => of(null),
// this.criticalAppsService.dropCriticalApp(
// organizationDetails!.organizationId,
// app.id,
// ),
);
return forkJoin(deleteObservables).pipe(
// After all deletes complete, map to the migrated apps.
map(() => {
this.logService.debug(
"[RiskInsightsOrchestratorService] Migrated and deleted critical applications.",
);
return migratedApps;
}),
);
}),
);
}),
),
),
catchError((error: unknown) => {
this.logService.error(
"[RiskInsightsOrchestratorService] Failed to save migrated critical applications",
error,
);
return throwError(() => error);
}),
);
}),
);
}
@@ -481,14 +496,15 @@ export class RiskInsightsOrchestratorService {
private _setupEnrichedReportData() {
// Setup the enriched report data pipeline
const enrichmentSubscription = combineLatest([
this.rawReportData$.pipe(filter((data) => !!data)),
this.rawReportData$.pipe(filter((data) => !!data && !!data?.data)),
this._ciphers$.pipe(filter((data) => !!data)),
]).pipe(
switchMap(([rawReportData, ciphers]) => {
this.logService.debug(
"[RiskInsightsOrchestratorService] Enriching report data with ciphers and critical app status",
);
const criticalApps = rawReportData?.data?.applicationData.filter((app) => app.isCritical);
const criticalApps =
rawReportData?.data?.applicationData.filter((app) => app.isCritical) ?? [];
const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.applicationName));
const rawReports = rawReportData.data?.reportData || [];
const cipherMap = this.reportService.getApplicationCipherMap(ciphers, rawReports);
@@ -499,10 +515,10 @@ export class RiskInsightsOrchestratorService {
isMarkedAsCritical: criticalApplicationNames.has(app.applicationName),
}));
const enrichedData: RiskInsightsEnrichedData = {
const enrichedData = {
...rawReportData.data,
reportData: enrichedReports,
};
} as RiskInsightsEnrichedData;
return of(enrichedData);
}),
@@ -523,7 +539,7 @@ export class RiskInsightsOrchestratorService {
exhaustMap(([orgId, userId]) =>
this.organizationService.organizations$(userId!).pipe(
getOrganizationById(orgId),
map((org) => ({ organizationId: orgId, organizationName: org.name })),
map((org) => ({ organizationId: orgId!, organizationName: org?.name ?? "" })),
),
),
tap(async (orgDetails) => {
@@ -539,24 +555,39 @@ export class RiskInsightsOrchestratorService {
}
private _setupMigrationAndCleanup() {
this.criticalAppsService.criticalAppsList$
const criticalApps$ = this.criticalAppsService.criticalAppsList$.pipe(
tap((criticalApps) => {
this.logService.debug(
"[RiskInsightsOrchestratorService] criticalAppsService.criticalAppsList",
criticalApps,
);
}),
filter((criticalApps) => criticalApps.length > 0),
take(1),
);
const rawReportData$ = this.rawReportData$.pipe(
tap((reportState) => {
this.logService.debug(
`[RiskInsightsOrchestratorService] Report state on _setupMigrationAndCleanup`,
!!reportState.data,
reportState,
);
}),
filter((reportState) => !!reportState.data),
take(1),
);
this._migrationSubscription = forkJoin([criticalApps$, rawReportData$])
.pipe(
filter((criticalApps) => criticalApps.length > 0),
tap(() => {
tap(([criticalApps]) => {
this.logService.debug(
"[RiskInsightsOrchestratorService] Detected legacy critical apps, running migration and cleanup.",
`[RiskInsightsOrchestratorService] Detected ${criticalApps.length} legacy critical apps, running migration and cleanup`,
criticalApps,
);
}),
switchMap(() =>
this._runMigrationAndCleanup$().pipe(
tap((migratedApps) => {
if (migratedApps.length > 0) {
this.logService.debug(
"[RiskInsightsOrchestratorService] Migration and cleanup completed.",
migratedApps,
);
}
}),
switchMap(([criticalApps, _reportState]) =>
this._runMigrationAndCleanup$(criticalApps).pipe(
catchError((error: unknown) => {
this.logService.error(
"[RiskInsightsOrchestratorService] Migration and cleanup failed.",
@@ -566,6 +597,7 @@ export class RiskInsightsOrchestratorService {
}),
),
),
take(1),
)
.subscribe();
}
@@ -578,9 +610,9 @@ export class RiskInsightsOrchestratorService {
this._userId$.pipe(filter((user) => !!user)),
]).pipe(shareReplay({ bufferSize: 1, refCount: true }));
// A stream for the initial report fetch (triggered by critical apps loading)
// A stream for the initial report fetch
const initialReportLoad$ = reportDependencies$.pipe(
take(1), // Fetch only once on initial data load
take(1),
exhaustMap(([orgDetails, userId]) => this._fetchReport$(orgDetails!.organizationId, userId!)),
);
@@ -598,7 +630,7 @@ export class RiskInsightsOrchestratorService {
filter((isRunning) => isRunning),
withLatestFrom(reportDependencies$),
exhaustMap(([_, [orgDetails, userId]]) =>
this._generateNewApplicationsReport$(orgDetails!.organizationId, userId),
this._generateNewApplicationsReport$(orgDetails!.organizationId, userId!),
),
);
@@ -633,19 +665,4 @@ export class RiskInsightsOrchestratorService {
this._userIdSubject.next(userId);
});
}
private _mergeApplicationData(
existingApps: OrganizationReportApplication[],
newCriticalAppNamesSet: Set<string>,
): OrganizationReportApplication[] {
// First, iterate through the existing apps and update their isCritical flag
const updatedApps = existingApps.map((app) => {
return {
...app,
isCritical: newCriticalAppNamesSet.has(app.applicationName) ?? app.isCritical,
};
});
return updatedApps;
}
}

View File

@@ -171,15 +171,12 @@ export class RiskInsightsReportService {
creationDate: response.creationDate,
})),
catchError((error: unknown) => {
// TODO Handle errors appropriately
// console.error("An error occurred when decrypting report", error);
return EMPTY;
return throwError(() => error);
}),
);
}),
catchError((error: unknown) => {
// console.error("An error occurred when fetching the last report", error);
return EMPTY;
return throwError(() => error);
}),
);
}

View File

@@ -1,9 +1,11 @@
export * from "./api/member-cipher-details-api.service";
export * from "./domain/password-health.service";
export * from "./domain/critical-apps.service";
export * from "./api/critical-apps-api.service";
export * from "./api/member-cipher-details-api.service";
export * from "./api/risk-insights-api.service";
export * from "./domain/risk-insights-report.service";
export * from "./view/risk-insights-data.service";
export * from "./view/all-activities.service";
export * from "./api/security-tasks-api.service";
export * from "./domain/critical-apps.service";
export * from "./domain/password-health.service";
export * from "./domain/risk-insights-encryption.service";
export * from "./domain/risk-insights-orchestrator.service";
export * from "./domain/risk-insights-report.service";
export * from "./view/all-activities.service";
export * from "./view/risk-insights-data.service";

View File

@@ -183,7 +183,10 @@ export class RiskInsightsDataService {
// ------------------------------ Critical application methods --------------
saveCriticalApplications(selectedUrls: string[]) {
this.orchestrator.setCriticalApplications$(selectedUrls);
// Saving critical applications to the report
this.orchestrator.saveCriticalApplications$(selectedUrls);
// Legacy support: also save to the CriticalAppsService for backward compatibility
return this.organizationDetails$.pipe(
exhaustMap((organizationDetails) => {
if (!organizationDetails?.organizationId) {
@@ -207,7 +210,7 @@ export class RiskInsightsDataService {
if (!organizationDetails?.organizationId) {
return EMPTY;
}
return this.criticalAppsService.dropCriticalApp(
return this.criticalAppsService.dropCriticalAppByUrl(
organizationDetails?.organizationId,
hostname,
);

View File

@@ -70,11 +70,10 @@ import { RiskInsightsComponent } from "./risk-insights.component";
provide: RiskInsightsDataService,
deps: [CriticalAppsService, RiskInsightsReportService, RiskInsightsOrchestratorService],
}),
{
safeProvider({
provide: RiskInsightsEncryptionService,
useClass: RiskInsightsEncryptionService,
deps: [KeyService, EncryptService, KeyGenerationService],
},
deps: [KeyService, EncryptService, KeyGenerationService, LogService],
}),
safeProvider({
provide: CriticalAppsService,
useClass: CriticalAppsService,

View File

@@ -26,7 +26,7 @@
buttonType="secondary"
class="tw-border-none !tw-font-normal tw-cursor-pointer !tw-py-0"
tabindex="0"
[bitAction]="refreshData.bind(this)"
[bitAction]="generateReport.bind(this)"
>
{{ "riskInsightsRunReport" | i18n }}
</button>

View File

@@ -59,7 +59,6 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
private organizationId: OrganizationId = "" as OrganizationId;
dataLastUpdated: Date | null = null;
refetching: boolean = false;
constructor(
private route: ActivatedRoute,
@@ -114,19 +113,14 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// The component tells the facade when to stop
this.dataService.destroy();
}
runReport = () => {
this.dataService.triggerReport();
};
/**
* Refreshes the data by re-fetching the applications report.
* This will automatically notify child components subscribed to the RiskInsightsDataService observables.
*/
refreshData(): void {
generateReport(): void {
if (this.organizationId) {
this.dataService.triggerReport();
}