mirror of
https://github.com/bitwarden/browser
synced 2026-02-04 02:33:33 +00:00
Add orchestration updates for fetching applications as well as migrating data.
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { OrganizationReportId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { MemberCipherDetailsResponse } from "../models";
|
||||
import {
|
||||
AtRiskApplicationDetail,
|
||||
AtRiskMemberDetail,
|
||||
MemberCipherDetailsResponse,
|
||||
} from "../models";
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
MemberDetails,
|
||||
OrganizationReportSummary,
|
||||
RiskInsightsData,
|
||||
@@ -61,6 +67,7 @@ export function getUniqueMembers(orgMembers: MemberDetails[]): MemberDetails[] {
|
||||
*/
|
||||
export function createNewReportData(): RiskInsightsData {
|
||||
return {
|
||||
id: "" as OrganizationReportId,
|
||||
creationDate: new Date(),
|
||||
reportData: [],
|
||||
summaryData: createNewSummaryData(),
|
||||
@@ -86,3 +93,60 @@ export function createNewSummaryData(): OrganizationReportSummary {
|
||||
newApplications: [],
|
||||
};
|
||||
}
|
||||
export function getAtRiskApplicationList(
|
||||
cipherHealthReportDetails: ApplicationHealthReportDetail[],
|
||||
): AtRiskApplicationDetail[] {
|
||||
const applicationPasswordRiskMap = new Map<string, number>();
|
||||
|
||||
cipherHealthReportDetails
|
||||
.filter((app) => app.atRiskPasswordCount > 0)
|
||||
.forEach((app) => {
|
||||
const atRiskPasswordCount = applicationPasswordRiskMap.get(app.applicationName) ?? 0;
|
||||
applicationPasswordRiskMap.set(
|
||||
app.applicationName,
|
||||
atRiskPasswordCount + app.atRiskPasswordCount,
|
||||
);
|
||||
});
|
||||
|
||||
return Array.from(applicationPasswordRiskMap.entries()).map(
|
||||
([applicationName, atRiskPasswordCount]) => ({
|
||||
applicationName,
|
||||
atRiskPasswordCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Generates a list of members with at-risk passwords along with the number of at-risk passwords.
|
||||
*/
|
||||
export function getAtRiskMemberList(
|
||||
cipherHealthReportDetails: ApplicationHealthReportDetail[],
|
||||
): AtRiskMemberDetail[] {
|
||||
const memberRiskMap = new Map<string, number>();
|
||||
|
||||
cipherHealthReportDetails.forEach((app) => {
|
||||
app.atRiskMemberDetails.forEach((member) => {
|
||||
const currentCount = memberRiskMap.get(member.email) ?? 0;
|
||||
memberRiskMap.set(member.email, currentCount + 1);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(memberRiskMap.entries()).map(([email, atRiskPasswordCount]) => ({
|
||||
email,
|
||||
atRiskPasswordCount,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of passwords to the number of times they are used across ciphers
|
||||
*
|
||||
* @param ciphers List of ciphers to check for password reuse
|
||||
* @returns A map where the key is the password and the value is the number of times it is used
|
||||
*/
|
||||
export function buildPasswordUseMap(ciphers: CipherView[]): Map<string, number> {
|
||||
const passwordUseMap = new Map<string, number>();
|
||||
ciphers.forEach((cipher) => {
|
||||
const password = cipher.login.password!;
|
||||
passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1);
|
||||
});
|
||||
return passwordUseMap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MemberDetails } from "./report-models";
|
||||
|
||||
// -------------------- Drawer and UI Models --------------------
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum DrawerType {
|
||||
None = 0,
|
||||
AppAtRiskMembers = 1,
|
||||
OrgAtRiskMembers = 2,
|
||||
OrgAtRiskApps = 3,
|
||||
}
|
||||
|
||||
export type DrawerDetails = {
|
||||
open: boolean;
|
||||
invokerId: string;
|
||||
activeDrawerType: DrawerType;
|
||||
atRiskMemberDetails?: AtRiskMemberDetail[];
|
||||
appAtRiskMembers?: AppAtRiskMembersDialogParams | null;
|
||||
atRiskAppDetails?: AtRiskApplicationDetail[] | null;
|
||||
};
|
||||
export type AppAtRiskMembersDialogParams = {
|
||||
members: MemberDetails[];
|
||||
applicationName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Member email with the number of at risk passwords
|
||||
* At risk member detail that contains the email
|
||||
* and the count of at risk ciphers
|
||||
*/
|
||||
|
||||
export type AtRiskMemberDetail = {
|
||||
email: string;
|
||||
atRiskPasswordCount: number;
|
||||
};
|
||||
|
||||
/*
|
||||
* A list of applications and the count of
|
||||
* at risk passwords for each application
|
||||
*/
|
||||
export type AtRiskApplicationDetail = {
|
||||
applicationName: string;
|
||||
atRiskPasswordCount: number;
|
||||
};
|
||||
@@ -3,3 +3,4 @@ export * from "./password-health";
|
||||
export * from "./report-data-service.types";
|
||||
export * from "./report-encryption.types";
|
||||
export * from "./report-models";
|
||||
export * from "./drawer-models.types";
|
||||
|
||||
@@ -81,10 +81,12 @@ export const mockApplicationData: OrganizationReportApplication[] = [
|
||||
{
|
||||
applicationName: "application1.com",
|
||||
isCritical: true,
|
||||
reviewedDate: new Date(),
|
||||
},
|
||||
{
|
||||
applicationName: "application2.com",
|
||||
isCritical: false,
|
||||
reviewedDate: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,44 +1,12 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
import { OrganizationReportId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { BadgeVariant } from "@bitwarden/components";
|
||||
|
||||
import { ExposedPasswordDetail, WeakPasswordDetail } from "./password-health";
|
||||
|
||||
// -------------------- Drawer and UI Models --------------------
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum DrawerType {
|
||||
None = 0,
|
||||
AppAtRiskMembers = 1,
|
||||
OrgAtRiskMembers = 2,
|
||||
OrgAtRiskApps = 3,
|
||||
}
|
||||
|
||||
export type DrawerDetails = {
|
||||
open: boolean;
|
||||
invokerId: string;
|
||||
activeDrawerType: DrawerType;
|
||||
atRiskMemberDetails?: AtRiskMemberDetail[];
|
||||
appAtRiskMembers?: AppAtRiskMembersDialogParams | null;
|
||||
atRiskAppDetails?: AtRiskApplicationDetail[] | null;
|
||||
};
|
||||
|
||||
export type AppAtRiskMembersDialogParams = {
|
||||
members: MemberDetails[];
|
||||
applicationName: string;
|
||||
};
|
||||
|
||||
// -------------------- Member Models --------------------
|
||||
/**
|
||||
* Member email with the number of at risk passwords
|
||||
* At risk member detail that contains the email
|
||||
* and the count of at risk ciphers
|
||||
*/
|
||||
export type AtRiskMemberDetail = {
|
||||
email: string;
|
||||
atRiskPasswordCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattened member details that associates an
|
||||
@@ -71,18 +39,6 @@ export type CipherHealthReport = {
|
||||
cipher: CipherView;
|
||||
};
|
||||
|
||||
/**
|
||||
* Breaks the cipher health info out by uri and passes
|
||||
* along the password health and member info
|
||||
*/
|
||||
export type CipherApplicationView = {
|
||||
cipherId: string;
|
||||
cipher: CipherView;
|
||||
cipherMembers: MemberDetails[];
|
||||
application: string;
|
||||
healthData: PasswordHealthData;
|
||||
};
|
||||
|
||||
// -------------------- Application Health Report Models --------------------
|
||||
/**
|
||||
* All applications report summary. The total members,
|
||||
@@ -91,21 +47,16 @@ export type CipherApplicationView = {
|
||||
*/
|
||||
export type OrganizationReportSummary = {
|
||||
totalMemberCount: number;
|
||||
totalCriticalMemberCount: number;
|
||||
totalAtRiskMemberCount: number;
|
||||
totalCriticalAtRiskMemberCount: number;
|
||||
totalApplicationCount: number;
|
||||
totalCriticalApplicationCount: number;
|
||||
totalAtRiskMemberCount: number;
|
||||
totalAtRiskApplicationCount: number;
|
||||
totalCriticalApplicationCount: number;
|
||||
totalCriticalMemberCount: number;
|
||||
totalCriticalAtRiskMemberCount: number;
|
||||
totalCriticalAtRiskApplicationCount: number;
|
||||
newApplications: string[];
|
||||
};
|
||||
|
||||
export type CriticalSummaryDetails = {
|
||||
totalCriticalMembersCount: number;
|
||||
totalCriticalApplicationsCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* An entry for an organization application and if it is
|
||||
* marked as critical
|
||||
@@ -113,6 +64,7 @@ export type CriticalSummaryDetails = {
|
||||
export type OrganizationReportApplication = {
|
||||
applicationName: string;
|
||||
isCritical: boolean;
|
||||
reviewedDate: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -131,15 +83,6 @@ export type ApplicationHealthReportDetail = {
|
||||
cipherIds: string[];
|
||||
};
|
||||
|
||||
/*
|
||||
* A list of applications and the count of
|
||||
* at risk passwords for each application
|
||||
*/
|
||||
export type AtRiskApplicationDetail = {
|
||||
applicationName: string;
|
||||
atRiskPasswordCount: number;
|
||||
};
|
||||
|
||||
// -------------------- Password Health Report Models --------------------
|
||||
export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">;
|
||||
|
||||
@@ -152,6 +95,7 @@ export type ReportResult = CipherView & {
|
||||
};
|
||||
|
||||
export interface RiskInsightsData {
|
||||
id: OrganizationReportId;
|
||||
creationDate: Date;
|
||||
reportData: ApplicationHealthReportDetail[];
|
||||
summaryData: OrganizationReportSummary;
|
||||
@@ -163,3 +107,13 @@ export interface ReportState {
|
||||
error: string | null;
|
||||
data: RiskInsightsData | null;
|
||||
}
|
||||
|
||||
// TODO Make Versioned models for structure changes
|
||||
// export type VersionedRiskInsightsData = RiskInsightsDataV1 | RiskInsightsDataV2;
|
||||
// export interface RiskInsightsDataV1 {
|
||||
// version: 1;
|
||||
// creationDate: Date;
|
||||
// reportData: ApplicationHealthReportDetail[];
|
||||
// summaryData: OrganizationReportSummary;
|
||||
// applicationData: OrganizationReportApplication[];
|
||||
// }
|
||||
|
||||
@@ -228,11 +228,12 @@ describe("RiskInsightsApiService", () => {
|
||||
|
||||
it("updateRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => {
|
||||
const reportId = "report123" as OrganizationReportId;
|
||||
const mockApplication = mockApplicationData[0];
|
||||
// TODO Update to be encrypted test
|
||||
const mockApplication = makeEncString("application-data");
|
||||
|
||||
mockApiService.send.mockResolvedValueOnce(undefined);
|
||||
const result = await firstValueFrom(
|
||||
service.updateRiskInsightsApplicationData$(mockApplication, orgId, reportId),
|
||||
service.updateRiskInsightsApplicationData$(mockApplication.encryptedString, orgId, reportId),
|
||||
);
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, OrganizationReportApplication } from "../../models";
|
||||
import { EncryptedDataWithKey } from "../../models";
|
||||
import {
|
||||
GetRiskInsightsApplicationDataResponse,
|
||||
GetRiskInsightsReportResponse,
|
||||
@@ -102,7 +102,7 @@ export class RiskInsightsApiService {
|
||||
}
|
||||
|
||||
updateRiskInsightsApplicationData$(
|
||||
applicationData: OrganizationReportApplication,
|
||||
applicationData: string,
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
): Observable<void> {
|
||||
|
||||
@@ -139,10 +139,11 @@ export class CriticalAppsService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.criticalAppsApiService.dropCriticalApp({
|
||||
organizationId: app.organizationId,
|
||||
passwordHealthReportApplicationIds: [app.id],
|
||||
});
|
||||
// TODO Uncomment when done testing that the migration is working
|
||||
// await this.criticalAppsApiService.dropCriticalApp({
|
||||
// organizationId: app.organizationId,
|
||||
// passwordHealthReportApplicationIds: [app.id],
|
||||
// });
|
||||
|
||||
this.criticalAppsListSubject$.next(
|
||||
this.criticalAppsListSubject$.value.filter((f) => f.uri !== selectedUrl),
|
||||
|
||||
@@ -3,7 +3,9 @@ import { of, throwError } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { createNewSummaryData } from "../../helpers";
|
||||
import { ReportState } from "../../models";
|
||||
@@ -12,8 +14,12 @@ import {
|
||||
mockEnrichedReportData,
|
||||
mockSummaryData,
|
||||
} from "../../models/mocks/mock-data";
|
||||
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
|
||||
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
||||
|
||||
import { CriticalAppsService } from "./critical-apps.service";
|
||||
import { PasswordHealthService } from "./password-health.service";
|
||||
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
|
||||
import { RiskInsightsOrchestratorService } from "./risk-insights-orchestrator.service";
|
||||
import { RiskInsightsReportService } from "./risk-insights-report.service";
|
||||
|
||||
@@ -24,6 +30,7 @@ describe("RiskInsightsOrchestratorService", () => {
|
||||
const mockOrgId = "org-789" as OrganizationId;
|
||||
const mockOrgName = "Test Org";
|
||||
const mockUserId = "user-101" as UserId;
|
||||
const mockReportId = "report-1" as OrganizationReportId;
|
||||
|
||||
// Mock services
|
||||
const mockAccountService = mock<AccountService>({
|
||||
@@ -33,14 +40,26 @@ describe("RiskInsightsOrchestratorService", () => {
|
||||
criticalAppsList$: of([]),
|
||||
});
|
||||
const mockOrganizationService = mock<OrganizationService>();
|
||||
const mockCipherService = mock<CipherService>();
|
||||
const mockMemberCipherDetailsApiService = mock<MemberCipherDetailsApiService>();
|
||||
const mockPasswordHealthService = mock<PasswordHealthService>();
|
||||
const mockReportApiService = mock<RiskInsightsApiService>();
|
||||
const mockReportService = mock<RiskInsightsReportService>();
|
||||
const mockRiskInsightsEncryptionService = mock<RiskInsightsEncryptionService>();
|
||||
const mockLogService = mock<LogService>();
|
||||
|
||||
beforeEach(() => {
|
||||
service = new RiskInsightsOrchestratorService(
|
||||
mockAccountService,
|
||||
mockCipherService,
|
||||
mockCriticalAppsService,
|
||||
mockMemberCipherDetailsApiService,
|
||||
mockOrganizationService,
|
||||
mockPasswordHealthService,
|
||||
mockReportApiService,
|
||||
mockReportService,
|
||||
mockRiskInsightsEncryptionService,
|
||||
mockLogService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -53,6 +72,7 @@ describe("RiskInsightsOrchestratorService", () => {
|
||||
loading: false,
|
||||
error: null,
|
||||
data: {
|
||||
id: mockReportId,
|
||||
reportData: [],
|
||||
summaryData: createNewSummaryData(),
|
||||
applicationData: [],
|
||||
@@ -106,14 +126,14 @@ describe("RiskInsightsOrchestratorService", () => {
|
||||
});
|
||||
|
||||
describe("generateReport", () => {
|
||||
it("should call reportService.generateApplicationsReport$ and saveRiskInsightsReport$ and emit ReportState", (done) => {
|
||||
it("should call reportService.generateApplicationsReport and saveRiskInsightsReport$ and emit ReportState", (done) => {
|
||||
const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"];
|
||||
const privateUserIdSubject = service["_userIdSubject"];
|
||||
|
||||
// Arrange
|
||||
mockReportService.generateApplicationsReport$.mockReturnValueOnce(of(mockEnrichedReportData));
|
||||
mockReportService.generateApplicationsSummary.mockReturnValueOnce(mockSummaryData);
|
||||
mockReportService.generateOrganizationApplications.mockReturnValueOnce(mockApplicationData);
|
||||
mockReportService.generateApplicationsReport.mockReturnValueOnce(mockEnrichedReportData);
|
||||
mockReportService.getApplicationsSummary.mockReturnValueOnce(mockSummaryData);
|
||||
mockReportService.getOrganizationApplications.mockReturnValueOnce(mockApplicationData);
|
||||
mockReportService.saveRiskInsightsReport$.mockReturnValueOnce(of(null));
|
||||
privateOrganizationDetailsSubject.next({
|
||||
organizationId: mockOrgId,
|
||||
@@ -127,7 +147,7 @@ describe("RiskInsightsOrchestratorService", () => {
|
||||
// Assert
|
||||
service.rawReportData$.subscribe((state) => {
|
||||
if (!state.loading) {
|
||||
expect(mockReportService.generateApplicationsReport$).toHaveBeenCalledWith(mockOrgId);
|
||||
expect(mockReportService.generateApplicationsReport).toHaveBeenCalledWith(mockOrgId);
|
||||
expect(mockReportService.saveRiskInsightsReport$).toHaveBeenCalledWith(
|
||||
mockEnrichedReportData,
|
||||
mockSummaryData,
|
||||
@@ -142,35 +162,13 @@ describe("RiskInsightsOrchestratorService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit error ReportState when generateApplicationsReport$ throws", (done) => {
|
||||
const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"];
|
||||
const privateUserIdSubject = service["_userIdSubject"];
|
||||
|
||||
mockReportService.generateApplicationsReport$.mockReturnValueOnce(
|
||||
throwError(() => new Error("Generate error")),
|
||||
);
|
||||
privateOrganizationDetailsSubject.next({
|
||||
organizationId: mockOrgId,
|
||||
organizationName: mockOrgName,
|
||||
});
|
||||
privateUserIdSubject.next(mockUserId);
|
||||
service.generateReport();
|
||||
service.rawReportData$.subscribe((state) => {
|
||||
if (!state.loading) {
|
||||
expect(state.error).toBe("Failed to generate or save report");
|
||||
expect(state.data).toBeNull();
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit error ReportState when saveRiskInsightsReport$ throws", (done) => {
|
||||
const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"];
|
||||
const privateUserIdSubject = service["_userIdSubject"];
|
||||
|
||||
mockReportService.generateApplicationsReport$.mockReturnValueOnce(of(mockEnrichedReportData));
|
||||
mockReportService.generateApplicationsSummary.mockReturnValueOnce(mockSummaryData);
|
||||
mockReportService.generateOrganizationApplications.mockReturnValueOnce(mockApplicationData);
|
||||
mockReportService.generateApplicationsReport.mockReturnValueOnce(mockEnrichedReportData);
|
||||
mockReportService.getApplicationsSummary.mockReturnValueOnce(mockSummaryData);
|
||||
mockReportService.getOrganizationApplications.mockReturnValueOnce(mockApplicationData);
|
||||
mockReportService.saveRiskInsightsReport$.mockReturnValueOnce(
|
||||
throwError(() => new Error("Save error")),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
forkJoin,
|
||||
from,
|
||||
merge,
|
||||
Observable,
|
||||
@@ -30,13 +31,26 @@ import {
|
||||
} 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 { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
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 { RiskInsightsEnrichedData } from "../../models/report-data-service.types";
|
||||
import { ReportState } from "../../models/report-models";
|
||||
import {
|
||||
CipherHealthReport,
|
||||
MemberDetails,
|
||||
OrganizationReportApplication,
|
||||
ReportState,
|
||||
} from "../../models/report-models";
|
||||
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
|
||||
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
||||
|
||||
import { CriticalAppsService } from "./critical-apps.service";
|
||||
import { PasswordHealthService } from "./password-health.service";
|
||||
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
|
||||
import { RiskInsightsReportService } from "./risk-insights-report.service";
|
||||
|
||||
export class RiskInsightsOrchestratorService {
|
||||
@@ -54,6 +68,10 @@ export class RiskInsightsOrchestratorService {
|
||||
} | null>(null);
|
||||
organizationDetails$ = this._organizationDetailsSubject.asObservable();
|
||||
|
||||
// ------------------------- Raw data -------------------------
|
||||
private _ciphersSubject = new BehaviorSubject<CipherView[] | null>(null);
|
||||
private _ciphers$ = this._ciphersSubject.asObservable();
|
||||
|
||||
// ------------------------- Report Variables ----------------
|
||||
private _rawReportDataSubject = new BehaviorSubject<ReportState>({
|
||||
loading: true,
|
||||
@@ -78,19 +96,28 @@ export class RiskInsightsOrchestratorService {
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private cipherService: CipherService,
|
||||
private criticalAppsService: CriticalAppsService,
|
||||
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
|
||||
private organizationService: OrganizationService,
|
||||
private passwordHealthService: PasswordHealthService,
|
||||
private reportApiService: RiskInsightsApiService,
|
||||
private reportService: RiskInsightsReportService,
|
||||
private riskInsightsEncryptionService: RiskInsightsEncryptionService,
|
||||
private logService: LogService,
|
||||
) {
|
||||
this.logService.debug("[RiskInsightsOrchestratorService] Setting up");
|
||||
this._setupCriticalApplicationContext();
|
||||
this._setupCriticalApplicationReport();
|
||||
this._setupEnrichedReportData();
|
||||
this._setupInitializationPipeline();
|
||||
this._setupMigrationAndCleanup();
|
||||
this._setupReportState();
|
||||
this._setupUserId();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.logService.debug("[RiskInsightsOrchestratorService] Destroying");
|
||||
if (this._reportStateSubscription) {
|
||||
this._reportStateSubscription.unsubscribe();
|
||||
}
|
||||
@@ -98,19 +125,11 @@ export class RiskInsightsOrchestratorService {
|
||||
this._destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the service context for a specific organization
|
||||
*
|
||||
* @param organizationId The ID of the organization to initialize context for
|
||||
*/
|
||||
initializeForOrganization(organizationId: OrganizationId) {
|
||||
this._initializeOrganizationTriggerSubject.next(organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the latest report for the current organization and user
|
||||
*/
|
||||
fetchReport(): void {
|
||||
this.logService.debug("[RiskInsightsOrchestratorService] Fetch report triggered");
|
||||
this._fetchReportTriggerSubject.next();
|
||||
}
|
||||
|
||||
@@ -118,16 +137,125 @@ export class RiskInsightsOrchestratorService {
|
||||
* Generates a new report for the current organization and user
|
||||
*/
|
||||
generateReport(): void {
|
||||
this.logService.debug("[RiskInsightsOrchestratorService] Create new report triggered");
|
||||
this._generateReportTriggerSubject.next(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the service context for a specific organization
|
||||
*
|
||||
* @param organizationId The ID of the organization to initialize context for
|
||||
*/
|
||||
initializeForOrganization(organizationId: OrganizationId) {
|
||||
this.logService.debug("[RiskInsightsOrchestratorService] Initializing for org", organizationId);
|
||||
this._initializeOrganizationTriggerSubject.next(organizationId);
|
||||
}
|
||||
|
||||
setCriticalApplications$(criticalApplications: string[]): Observable<ReportState> {
|
||||
return this.rawReportData$.pipe(
|
||||
take(1),
|
||||
withLatestFrom(this.organizationDetails$, this._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 updatedApplicationData = this._mergeApplicationData(
|
||||
existingApplicationData,
|
||||
newCriticalAppNamesSet,
|
||||
);
|
||||
|
||||
const updatedState: ReportState = {
|
||||
...reportState,
|
||||
data: {
|
||||
...reportState.data,
|
||||
applicationData: updatedApplicationData,
|
||||
},
|
||||
};
|
||||
|
||||
this.logService.debug(
|
||||
"[RiskInsightsOrchestratorService] Updated applications data",
|
||||
updatedState,
|
||||
);
|
||||
return { reportState, organizationDetails, updatedState, userId };
|
||||
}),
|
||||
switchMap(({ reportState, organizationDetails, updatedState, userId }) => {
|
||||
return from(
|
||||
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
||||
{
|
||||
organizationId: organizationDetails!.organizationId,
|
||||
userId,
|
||||
},
|
||||
{
|
||||
reportData: reportState.data.reportData,
|
||||
summaryData: reportState.data.summaryData,
|
||||
applicationData: updatedState.data.applicationData,
|
||||
},
|
||||
),
|
||||
).pipe(
|
||||
map((encryptedData) => ({
|
||||
reportState,
|
||||
organizationDetails,
|
||||
updatedState,
|
||||
encryptedData,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
|
||||
// Chain the save operation using switchMap
|
||||
return this.reportApiService
|
||||
.updateRiskInsightsApplicationData$(
|
||||
encryptedData.encryptedApplicationData.encryptedString,
|
||||
organizationDetails.organizationId,
|
||||
reportState.data.id,
|
||||
)
|
||||
.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" });
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private _fetchReport$(organizationId: OrganizationId, userId: UserId): Observable<ReportState> {
|
||||
return this.reportService.getRiskInsightsReport$(organizationId, userId).pipe(
|
||||
tap(() => this.logService.debug("[RiskInsightsOrchestratorService] Fetching report")),
|
||||
map(
|
||||
({ reportData, summaryData, applicationData, creationDate }): ReportState => ({
|
||||
({ id, reportData, summaryData, applicationData, creationDate }): ReportState => ({
|
||||
loading: false,
|
||||
error: null,
|
||||
data: {
|
||||
id,
|
||||
reportData,
|
||||
summaryData,
|
||||
applicationData,
|
||||
@@ -140,16 +268,30 @@ export class RiskInsightsOrchestratorService {
|
||||
);
|
||||
}
|
||||
|
||||
private _generateApplicationsReport$(
|
||||
private _generateNewApplicationsReport$(
|
||||
organizationId: OrganizationId,
|
||||
userId: UserId,
|
||||
): Observable<ReportState> {
|
||||
// Generate the report
|
||||
return this.reportService.generateApplicationsReport$(organizationId).pipe(
|
||||
map((enrichedReport) => ({
|
||||
report: enrichedReport,
|
||||
summary: this.reportService.generateApplicationsSummary(enrichedReport),
|
||||
applications: this.reportService.generateOrganizationApplications(enrichedReport),
|
||||
const memberCiphers$ = from(
|
||||
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
|
||||
).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers)));
|
||||
|
||||
return forkJoin([this._ciphers$, memberCiphers$]).pipe(
|
||||
tap(() => this.logService.debug("[RiskInsightsOrchestratorService] Generating new report")),
|
||||
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,
|
||||
),
|
||||
})),
|
||||
switchMap(({ report, summary, applications }) =>
|
||||
// Save the report after enrichment
|
||||
@@ -172,6 +314,7 @@ export class RiskInsightsOrchestratorService {
|
||||
loading: false,
|
||||
error: null,
|
||||
data: {
|
||||
id: "" as OrganizationReportId,
|
||||
reportData: report,
|
||||
summaryData: summary,
|
||||
applicationData: applications,
|
||||
@@ -186,6 +329,106 @@ export class RiskInsightsOrchestratorService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates the members with the ciphers they have access to. Calculates the password health.
|
||||
* Finds the trimmed uris.
|
||||
* @param ciphers Org ciphers
|
||||
* @param memberDetails Org members
|
||||
* @returns Cipher password health data with trimmed uris and associated members
|
||||
*/
|
||||
private _getCipherHealth(
|
||||
ciphers: CipherView[],
|
||||
memberDetails: MemberDetails[],
|
||||
): Observable<CipherHealthReport[]> {
|
||||
const validCiphers = ciphers.filter((cipher) =>
|
||||
this.passwordHealthService.isValidCipher(cipher),
|
||||
);
|
||||
const passwordUseMap = buildPasswordUseMap(validCiphers);
|
||||
|
||||
// Check for exposed passwords and map to cipher health report
|
||||
return this.passwordHealthService.auditPasswordLeaks$(validCiphers).pipe(
|
||||
map((exposedDetails) => {
|
||||
return validCiphers.map((cipher) => {
|
||||
const exposedPasswordDetail = exposedDetails.find((x) => x?.cipherId === cipher.id);
|
||||
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
|
||||
const applications = getTrimmedCipherUris(cipher);
|
||||
const weakPasswordDetail = this.passwordHealthService.findWeakPasswordDetails(cipher);
|
||||
const reusedPasswordCount = passwordUseMap.get(cipher.login.password!) ?? 0;
|
||||
|
||||
return {
|
||||
cipher,
|
||||
cipherMembers,
|
||||
applications,
|
||||
healthData: {
|
||||
weakPasswordDetail,
|
||||
reusedPasswordCount,
|
||||
exposedPasswordDetail,
|
||||
},
|
||||
};
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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.",
|
||||
);
|
||||
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;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Setup the pipeline to load critical applications when organization or user changes
|
||||
private _setupCriticalApplicationContext() {
|
||||
this.organizationDetails$
|
||||
@@ -194,6 +437,10 @@ export class RiskInsightsOrchestratorService {
|
||||
withLatestFrom(this._userId$),
|
||||
filter(([_, userId]) => !!userId),
|
||||
tap(([orgDetails, userId]) => {
|
||||
this.logService.debug(
|
||||
"[RiskInsightsOrchestratorService] Loading critical applications for org",
|
||||
orgDetails!.organizationId,
|
||||
);
|
||||
this.criticalAppsService.loadOrganizationContext(orgDetails!.organizationId, userId!);
|
||||
}),
|
||||
takeUntil(this._destroy$),
|
||||
@@ -206,16 +453,22 @@ export class RiskInsightsOrchestratorService {
|
||||
const criticalReportResultsPipeline$ = this.enrichedReportData$.pipe(
|
||||
filter((state) => !!state),
|
||||
map((enrichedReports) => {
|
||||
this.logService.debug(
|
||||
"[RiskInsightsOrchestratorService] Generating critical applications report from",
|
||||
enrichedReports,
|
||||
);
|
||||
const criticalApplications = enrichedReports!.reportData.filter(
|
||||
(app) => app.isMarkedAsCritical,
|
||||
);
|
||||
const summary = this.reportService.generateApplicationsSummary(criticalApplications);
|
||||
// Generate a new summary based on just the critical applications
|
||||
const summary = this.reportService.getApplicationsSummary(criticalApplications);
|
||||
return {
|
||||
...enrichedReports,
|
||||
summaryData: summary,
|
||||
reportData: criticalApplications,
|
||||
};
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
this.criticalReportResults$ = criticalReportResultsPipeline$;
|
||||
@@ -229,27 +482,29 @@ export class RiskInsightsOrchestratorService {
|
||||
// Setup the enriched report data pipeline
|
||||
const enrichmentSubscription = combineLatest([
|
||||
this.rawReportData$.pipe(filter((data) => !!data)),
|
||||
this.organizationDetails$.pipe(filter((details) => !!details)),
|
||||
this.criticalAppsService.criticalAppsList$.pipe(filter((list) => !!list)),
|
||||
this._ciphers$.pipe(filter((data) => !!data)),
|
||||
]).pipe(
|
||||
switchMap(([rawReportData, orgDetails, criticalApps]) => {
|
||||
const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.uri));
|
||||
const rawReports = rawReportData.data?.reportData || [];
|
||||
return from(
|
||||
this.reportService.getApplicationCipherMap(rawReports, orgDetails!.organizationId),
|
||||
).pipe(
|
||||
map((cipherMap) => {
|
||||
return rawReports.map((app) => ({
|
||||
...app,
|
||||
ciphers: cipherMap.get(app.applicationName) || [],
|
||||
isMarkedAsCritical: criticalApplicationNames.has(app.applicationName),
|
||||
})) as ApplicationHealthReportDetailEnriched[];
|
||||
}),
|
||||
map((enrichedReportData) => ({ ...rawReportData.data, reportData: enrichedReportData })),
|
||||
catchError(() => {
|
||||
return of(null);
|
||||
}),
|
||||
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 criticalApplicationNames = new Set(criticalApps.map((ca) => ca.applicationName));
|
||||
const rawReports = rawReportData.data?.reportData || [];
|
||||
const cipherMap = this.reportService.getApplicationCipherMap(ciphers, rawReports);
|
||||
|
||||
const enrichedReports: ApplicationHealthReportDetailEnriched[] = rawReports.map((app) => ({
|
||||
...app,
|
||||
ciphers: cipherMap.get(app.applicationName) || [],
|
||||
isMarkedAsCritical: criticalApplicationNames.has(app.applicationName),
|
||||
}));
|
||||
|
||||
const enrichedData: RiskInsightsEnrichedData = {
|
||||
...rawReportData.data,
|
||||
reportData: enrichedReports,
|
||||
};
|
||||
|
||||
return of(enrichedData);
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
@@ -271,11 +526,50 @@ export class RiskInsightsOrchestratorService {
|
||||
map((org) => ({ organizationId: orgId, organizationName: org.name })),
|
||||
),
|
||||
),
|
||||
tap(async (orgDetails) => {
|
||||
this.logService.debug("[RiskInsightsOrchestratorService] Fetching organization ciphers");
|
||||
const ciphers = await this.cipherService.getAllFromApiForOrganization(
|
||||
orgDetails.organizationId,
|
||||
);
|
||||
this._ciphersSubject.next(ciphers);
|
||||
}),
|
||||
takeUntil(this._destroy$),
|
||||
)
|
||||
.subscribe((orgDetails) => this._organizationDetailsSubject.next(orgDetails));
|
||||
}
|
||||
|
||||
private _setupMigrationAndCleanup() {
|
||||
this.criticalAppsService.criticalAppsList$
|
||||
.pipe(
|
||||
filter((criticalApps) => criticalApps.length > 0),
|
||||
tap(() => {
|
||||
this.logService.debug(
|
||||
"[RiskInsightsOrchestratorService] Detected legacy critical apps, running migration and cleanup.",
|
||||
);
|
||||
}),
|
||||
switchMap(() =>
|
||||
this._runMigrationAndCleanup$().pipe(
|
||||
tap((migratedApps) => {
|
||||
if (migratedApps.length > 0) {
|
||||
this.logService.debug(
|
||||
"[RiskInsightsOrchestratorService] Migration and cleanup completed.",
|
||||
migratedApps,
|
||||
);
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(
|
||||
"[RiskInsightsOrchestratorService] Migration and cleanup failed.",
|
||||
error,
|
||||
);
|
||||
return of([]);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
// Setup the report state management pipeline
|
||||
private _setupReportState() {
|
||||
// Dependencies needed for report state
|
||||
@@ -285,14 +579,9 @@ export class RiskInsightsOrchestratorService {
|
||||
]).pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
|
||||
// A stream for the initial report fetch (triggered by critical apps loading)
|
||||
const initialReportLoad$ = combineLatest([
|
||||
this.criticalAppsService.criticalAppsList$,
|
||||
reportDependencies$,
|
||||
]).pipe(
|
||||
const initialReportLoad$ = reportDependencies$.pipe(
|
||||
take(1), // Fetch only once on initial data load
|
||||
exhaustMap(([_, [orgDetails, userId]]) =>
|
||||
this._fetchReport$(orgDetails!.organizationId, userId!),
|
||||
),
|
||||
exhaustMap(([orgDetails, userId]) => this._fetchReport$(orgDetails!.organizationId, userId!)),
|
||||
);
|
||||
|
||||
// A stream for manually triggered fetches
|
||||
@@ -309,7 +598,7 @@ export class RiskInsightsOrchestratorService {
|
||||
filter((isRunning) => isRunning),
|
||||
withLatestFrom(reportDependencies$),
|
||||
exhaustMap(([_, [orgDetails, userId]]) =>
|
||||
this._generateApplicationsReport$(orgDetails!.organizationId, userId),
|
||||
this._generateNewApplicationsReport$(orgDetails!.organizationId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -332,6 +621,7 @@ export class RiskInsightsOrchestratorService {
|
||||
this._reportStateSubscription = mergedReportState$
|
||||
.pipe(takeUntil(this._destroy$))
|
||||
.subscribe((state) => {
|
||||
this.logService.debug("[RiskInsightsOrchestratorService] Updating report state", state);
|
||||
this._rawReportDataSubject.next(state);
|
||||
});
|
||||
}
|
||||
@@ -343,4 +633,19 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,9 +79,6 @@ describe("RiskInsightsReportService", () => {
|
||||
});
|
||||
|
||||
service = new RiskInsightsReportService(
|
||||
cipherService,
|
||||
memberCipherDetailsService,
|
||||
mockPasswordHealthService,
|
||||
mockRiskInsightsApiService,
|
||||
mockRiskInsightsEncryptionService,
|
||||
);
|
||||
@@ -93,23 +90,21 @@ describe("RiskInsightsReportService", () => {
|
||||
};
|
||||
});
|
||||
|
||||
it("should group and aggregate application health reports correctly", (done) => {
|
||||
it("should group and aggregate application health reports correctly", () => {
|
||||
// Mock the service methods
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCipherViews);
|
||||
memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberDetails);
|
||||
|
||||
service.generateApplicationsReport$("orgId" as any).subscribe((result) => {
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const result = service.generateApplicationsReport("orgId" as any);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
|
||||
// Should group by application name (trimmedUris)
|
||||
const appCom = result.find((r) => r.applicationName === "app.com");
|
||||
const otherCom = result.find((r) => r.applicationName === "other.com");
|
||||
expect(appCom).toBeTruthy();
|
||||
expect(appCom?.passwordCount).toBe(2);
|
||||
expect(otherCom).toBeTruthy();
|
||||
expect(otherCom?.passwordCount).toBe(1);
|
||||
done();
|
||||
});
|
||||
// Should group by application name (trimmedUris)
|
||||
const appCom = result.find((r) => r.applicationName === "app.com");
|
||||
const otherCom = result.find((r) => r.applicationName === "other.com");
|
||||
expect(appCom).toBeTruthy();
|
||||
expect(appCom?.passwordCount).toBe(2);
|
||||
expect(otherCom).toBeTruthy();
|
||||
expect(otherCom?.passwordCount).toBe(1);
|
||||
});
|
||||
|
||||
describe("saveRiskInsightsReport$", () => {
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
import {
|
||||
catchError,
|
||||
EMPTY,
|
||||
forkJoin,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
switchMap,
|
||||
throwError,
|
||||
} from "rxjs";
|
||||
import { catchError, EMPTY, from, map, Observable, of, switchMap, throwError } from "rxjs";
|
||||
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import {
|
||||
createNewReportData,
|
||||
flattenMemberDetails,
|
||||
getTrimmedCipherUris,
|
||||
getUniqueMembers,
|
||||
} from "../../helpers/risk-insights-data-mappers";
|
||||
import { createNewReportData, getUniqueMembers } from "../../helpers/risk-insights-data-mappers";
|
||||
import {
|
||||
isSaveRiskInsightsReportResponse,
|
||||
SaveRiskInsightsReportResponse,
|
||||
@@ -27,109 +11,34 @@ import {
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
OrganizationReportSummary,
|
||||
AtRiskApplicationDetail,
|
||||
AtRiskMemberDetail,
|
||||
CipherHealthReport,
|
||||
MemberDetails,
|
||||
PasswordHealthData,
|
||||
OrganizationReportApplication,
|
||||
RiskInsightsData,
|
||||
} from "../../models/report-models";
|
||||
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
|
||||
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
||||
|
||||
import { PasswordHealthService } from "./password-health.service";
|
||||
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
|
||||
|
||||
export class RiskInsightsReportService {
|
||||
// [FIXME] CipherData
|
||||
// Cipher data
|
||||
// private _ciphersSubject = new BehaviorSubject<CipherView[] | null>(null);
|
||||
// _ciphers$ = this._ciphersSubject.asObservable();
|
||||
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
|
||||
private passwordHealthService: PasswordHealthService,
|
||||
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 for the aggregation of uris to like uris and getting password/member counts,
|
||||
* members, and at risk statuses.
|
||||
*
|
||||
* @param organizationId Id of the organization
|
||||
* @param ciphers The list of ciphers to analyze
|
||||
* @param memberCiphers The list of member cipher details to associate members to ciphers
|
||||
* @returns The all applications health report data
|
||||
*/
|
||||
generateApplicationsReport$(
|
||||
organizationId: OrganizationId,
|
||||
): Observable<ApplicationHealthReportDetail[]> {
|
||||
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId));
|
||||
const memberCiphers$ = from(
|
||||
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
|
||||
).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers)));
|
||||
generateApplicationsReport(ciphers: CipherHealthReport[]): ApplicationHealthReportDetail[] {
|
||||
const groupedByApplication = this._groupCiphersByApplication(ciphers);
|
||||
|
||||
return forkJoin([allCiphers$, memberCiphers$]).pipe(
|
||||
switchMap(([ciphers, memberCiphers]) => this._getCipherDetails(ciphers, memberCiphers)),
|
||||
map((cipherApplications) => {
|
||||
const groupedByApplication = this._groupCiphersByApplication(cipherApplications);
|
||||
|
||||
return Array.from(groupedByApplication.entries()).map(([application, ciphers]) =>
|
||||
this._getApplicationHealthReport(application, ciphers),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a list of members with at-risk passwords along with the number of at-risk passwords.
|
||||
*/
|
||||
generateAtRiskMemberList(
|
||||
cipherHealthReportDetails: ApplicationHealthReportDetail[],
|
||||
): AtRiskMemberDetail[] {
|
||||
const memberRiskMap = new Map<string, number>();
|
||||
|
||||
cipherHealthReportDetails.forEach((app) => {
|
||||
app.atRiskMemberDetails.forEach((member) => {
|
||||
const currentCount = memberRiskMap.get(member.email) ?? 0;
|
||||
memberRiskMap.set(member.email, currentCount + 1);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(memberRiskMap.entries()).map(([email, atRiskPasswordCount]) => ({
|
||||
email,
|
||||
atRiskPasswordCount,
|
||||
}));
|
||||
}
|
||||
|
||||
generateAtRiskApplicationList(
|
||||
cipherHealthReportDetails: ApplicationHealthReportDetail[],
|
||||
): AtRiskApplicationDetail[] {
|
||||
const applicationPasswordRiskMap = new Map<string, number>();
|
||||
|
||||
cipherHealthReportDetails
|
||||
.filter((app) => app.atRiskPasswordCount > 0)
|
||||
.forEach((app) => {
|
||||
const atRiskPasswordCount = applicationPasswordRiskMap.get(app.applicationName) ?? 0;
|
||||
applicationPasswordRiskMap.set(
|
||||
app.applicationName,
|
||||
atRiskPasswordCount + app.atRiskPasswordCount,
|
||||
);
|
||||
});
|
||||
|
||||
return Array.from(applicationPasswordRiskMap.entries()).map(
|
||||
([applicationName, atRiskPasswordCount]) => ({
|
||||
applicationName,
|
||||
atRiskPasswordCount,
|
||||
}),
|
||||
return Array.from(groupedByApplication.entries()).map(([application, ciphers]) =>
|
||||
this._getApplicationHealthReport(application, ciphers),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +48,7 @@ export class RiskInsightsReportService {
|
||||
* @param reports The previously calculated application health report data
|
||||
* @returns A summary object containing report totals
|
||||
*/
|
||||
generateApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary {
|
||||
getApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary {
|
||||
const totalMembers = reports.flatMap((x) => x.memberDetails);
|
||||
const uniqueMembers = getUniqueMembers(totalMembers);
|
||||
|
||||
@@ -182,13 +91,32 @@ export class RiskInsightsReportService {
|
||||
* @param reports
|
||||
* @returns A list of applications with a critical marking flag
|
||||
*/
|
||||
generateOrganizationApplications(
|
||||
getOrganizationApplications(
|
||||
reports: ApplicationHealthReportDetail[],
|
||||
previousApplications: OrganizationReportApplication[] = [],
|
||||
): OrganizationReportApplication[] {
|
||||
return reports.map((report) => ({
|
||||
applicationName: report.applicationName,
|
||||
isCritical: false,
|
||||
}));
|
||||
if (previousApplications.length > 0) {
|
||||
// Preserve existing critical application markings and dates
|
||||
return reports.map((report) => {
|
||||
const existingApp = previousApplications.find(
|
||||
(app) => app.applicationName === report.applicationName,
|
||||
);
|
||||
return {
|
||||
applicationName: report.applicationName,
|
||||
isCritical: existingApp ? existingApp.isCritical : false,
|
||||
reviewedDate: existingApp ? existingApp.reviewedDate : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// No previous applications, return all as non-critical with current date
|
||||
return reports.map(
|
||||
(report): OrganizationReportApplication => ({
|
||||
applicationName: report.applicationName,
|
||||
isCritical: false,
|
||||
reviewedDate: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,6 +164,7 @@ export class RiskInsightsReportService {
|
||||
),
|
||||
).pipe(
|
||||
map((decryptedData) => ({
|
||||
id: response.id as OrganizationReportId,
|
||||
reportData: decryptedData.reportData,
|
||||
summaryData: decryptedData.summaryData,
|
||||
applicationData: decryptedData.applicationData,
|
||||
@@ -319,28 +248,12 @@ export class RiskInsightsReportService {
|
||||
);
|
||||
}
|
||||
|
||||
private _buildPasswordUseMap(ciphers: CipherView[]): Map<string, number> {
|
||||
const passwordUseMap = new Map<string, number>();
|
||||
ciphers.forEach((cipher) => {
|
||||
const password = cipher.login.password!;
|
||||
passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1);
|
||||
});
|
||||
return passwordUseMap;
|
||||
}
|
||||
|
||||
private _groupCiphersByApplication(
|
||||
cipherHealthData: CipherHealthReport[],
|
||||
): Map<string, CipherHealthReport[]> {
|
||||
const applicationMap = new Map<string, CipherHealthReport[]>();
|
||||
|
||||
cipherHealthData.forEach((cipher: CipherHealthReport) => {
|
||||
// Warning: Currently does not show ciphers with NO Application
|
||||
// if (cipher.applications.length === 0) {
|
||||
// const existingApplication = applicationMap.get("None") || [];
|
||||
// existingApplication.push(cipher);
|
||||
// applicationMap.set("None", existingApplication);
|
||||
// }
|
||||
|
||||
cipher.applications.forEach((application) => {
|
||||
const existingApplication = applicationMap.get(application) || [];
|
||||
existingApplication.push(cipher);
|
||||
@@ -357,19 +270,13 @@ export class RiskInsightsReportService {
|
||||
* @param organizationId
|
||||
* @returns
|
||||
*/
|
||||
async getApplicationCipherMap(
|
||||
getApplicationCipherMap(
|
||||
ciphers: CipherView[],
|
||||
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);
|
||||
): Map<string, CipherView[]> {
|
||||
const cipherMap = new Map<string, CipherView[]>();
|
||||
|
||||
applications.forEach((app) => {
|
||||
const filteredCiphers = allCiphers.filter((c) => app.cipherIds.includes(c.id));
|
||||
const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id));
|
||||
cipherMap.set(app.applicationName, filteredCiphers);
|
||||
});
|
||||
return cipherMap;
|
||||
@@ -465,42 +372,4 @@ export class RiskInsightsReportService {
|
||||
healthData.reusedPasswordCount > 1
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Associates the members with the ciphers they have access to. Calculates the password health.
|
||||
* Finds the trimmed uris.
|
||||
* @param ciphers Org ciphers
|
||||
* @param memberDetails Org members
|
||||
* @returns Cipher password health data with trimmed uris and associated members
|
||||
*/
|
||||
private _getCipherDetails(
|
||||
ciphers: CipherView[],
|
||||
memberDetails: MemberDetails[],
|
||||
): Observable<CipherHealthReport[]> {
|
||||
const validCiphers = ciphers.filter((cipher) =>
|
||||
this.passwordHealthService.isValidCipher(cipher),
|
||||
);
|
||||
// Build password use map
|
||||
const passwordUseMap = this._buildPasswordUseMap(validCiphers);
|
||||
|
||||
return this.passwordHealthService.auditPasswordLeaks$(validCiphers).pipe(
|
||||
map((exposedDetails) => {
|
||||
return validCiphers.map((cipher) => {
|
||||
const exposedPassword = exposedDetails.find((x) => x?.cipherId === cipher.id);
|
||||
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
|
||||
|
||||
const result = {
|
||||
cipher: cipher,
|
||||
cipherMembers,
|
||||
healthData: {
|
||||
weakPasswordDetail: this.passwordHealthService.findWeakPasswordDetails(cipher),
|
||||
exposedPasswordDetail: exposedPassword,
|
||||
reusedPasswordCount: passwordUseMap.get(cipher.login.password!) ?? 0,
|
||||
},
|
||||
applications: getTrimmedCipherUris(cipher),
|
||||
} as CipherHealthReport;
|
||||
return result;
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { catchError, distinctUntilChanged, exhaustMap, map } from "rxjs/operator
|
||||
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { RiskInsightsEnrichedData } from "../../models/report-data-service.types";
|
||||
import { DrawerType, DrawerDetails, ReportState } from "../../models/report-models";
|
||||
import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers";
|
||||
import { ReportState, DrawerDetails, DrawerType, RiskInsightsEnrichedData } from "../../models";
|
||||
import { CriticalAppsService } from "../domain/critical-apps.service";
|
||||
import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service";
|
||||
import { RiskInsightsReportService } from "../domain/risk-insights-report.service";
|
||||
@@ -113,9 +113,7 @@ export class RiskInsightsDataService {
|
||||
return;
|
||||
}
|
||||
|
||||
const atRiskMemberDetails = this.reportService.generateAtRiskMemberList(
|
||||
reportResults.reportData,
|
||||
);
|
||||
const atRiskMemberDetails = getAtRiskMemberList(reportResults.reportData);
|
||||
|
||||
this.drawerDetailsSubject.next({
|
||||
open: true,
|
||||
@@ -170,9 +168,7 @@ export class RiskInsightsDataService {
|
||||
if (!reportResults) {
|
||||
return;
|
||||
}
|
||||
const atRiskAppDetails = this.reportService.generateAtRiskApplicationList(
|
||||
reportResults.reportData,
|
||||
);
|
||||
const atRiskAppDetails = getAtRiskApplicationList(reportResults.reportData);
|
||||
|
||||
this.drawerDetailsSubject.next({
|
||||
open: true,
|
||||
@@ -187,6 +183,7 @@ export class RiskInsightsDataService {
|
||||
|
||||
// ------------------------------ Critical application methods --------------
|
||||
saveCriticalApplications(selectedUrls: string[]) {
|
||||
this.orchestrator.setCriticalApplications$(selectedUrls);
|
||||
return this.organizationDetails$.pipe(
|
||||
exhaustMap((organizationDetails) => {
|
||||
if (!organizationDetails?.organizationId) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module";
|
||||
import { RiskInsightsComponent } from "./risk-insights.component";
|
||||
@@ -48,21 +49,21 @@ import { RiskInsightsComponent } from "./risk-insights.component";
|
||||
safeProvider({
|
||||
provide: RiskInsightsReportService,
|
||||
useClass: RiskInsightsReportService,
|
||||
deps: [
|
||||
CipherService,
|
||||
MemberCipherDetailsApiService,
|
||||
PasswordHealthService,
|
||||
RiskInsightsApiService,
|
||||
RiskInsightsEncryptionService,
|
||||
],
|
||||
deps: [RiskInsightsApiService, RiskInsightsEncryptionService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: RiskInsightsOrchestratorService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
CipherService,
|
||||
CriticalAppsService,
|
||||
MemberCipherDetailsApiService,
|
||||
OrganizationService,
|
||||
PasswordHealthService,
|
||||
RiskInsightsApiService,
|
||||
RiskInsightsReportService,
|
||||
RiskInsightsEncryptionService,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -6,8 +6,10 @@ import { EMPTY } from "rxjs";
|
||||
import { map, tap } from "rxjs/operators";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
|
||||
import {
|
||||
DrawerType,
|
||||
RiskInsightsDataService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
Reference in New Issue
Block a user