mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-23680] Report Applications data (#16819)
* Move files to folders. Delete unused component. Move model to file * Move risk insights services to folder structure capturing domains, api, and view organization. Move mock data * Remove legacy risk insight report code * Move api model to file * Separate data service and orchestration of data to make the data service a facade * Add orchestration updates for fetching applications as well as migrating data. * Updated migration of critical applications and merged old saved data to new critical applications on report object * Update test cases * Fixed test case after merge. Cleaned up per comments on review * Fixed decryption and encryption issue when not using existing content key * Fix type errors * Fix test update * Fixe remove critical applications * Fix report generating flag not being reset * Removed extra logs
This commit is contained in:
@@ -2,20 +2,19 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import {
|
||||
LEGACY_MemberDetailsFlat,
|
||||
LEGACY_CipherHealthReportDetail,
|
||||
LEGACY_CipherHealthReportUriDetail,
|
||||
} from "../models/password-health";
|
||||
AtRiskApplicationDetail,
|
||||
AtRiskMemberDetail,
|
||||
MemberCipherDetailsResponse,
|
||||
} from "../models";
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
MemberDetails,
|
||||
OrganizationReportSummary,
|
||||
RiskInsightsData,
|
||||
} from "../models/report-models";
|
||||
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
|
||||
|
||||
export function flattenMemberDetails(
|
||||
memberCiphers: MemberCipherDetailsResponse[],
|
||||
): LEGACY_MemberDetailsFlat[] {
|
||||
): MemberDetails[] {
|
||||
return memberCiphers.flatMap((member) =>
|
||||
member.cipherIds.map((cipherId) => ({
|
||||
userGuid: member.userGuid,
|
||||
@@ -48,9 +47,7 @@ export function getTrimmedCipherUris(cipher: CipherView): string[] {
|
||||
}
|
||||
|
||||
// Returns a deduplicated array of members by email
|
||||
export function getUniqueMembers(
|
||||
orgMembers: LEGACY_MemberDetailsFlat[],
|
||||
): LEGACY_MemberDetailsFlat[] {
|
||||
export function getUniqueMembers(orgMembers: MemberDetails[]): MemberDetails[] {
|
||||
const existingEmails = new Set<string>();
|
||||
return orgMembers.filter((member) => {
|
||||
if (existingEmails.has(member.email)) {
|
||||
@@ -61,108 +58,6 @@ export function getUniqueMembers(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a flattened member details object
|
||||
* @param userGuid User GUID
|
||||
* @param userName User name
|
||||
* @param email User email
|
||||
* @param cipherId Cipher ID
|
||||
* @returns Flattened member details
|
||||
*/
|
||||
export function getMemberDetailsFlat(
|
||||
userGuid: string,
|
||||
userName: string,
|
||||
email: string,
|
||||
cipherId: string,
|
||||
): LEGACY_MemberDetailsFlat {
|
||||
return {
|
||||
userGuid: userGuid,
|
||||
userName: userName,
|
||||
email: email,
|
||||
cipherId: cipherId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a flattened cipher details object for URI reporting
|
||||
* @param detail Cipher health report detail
|
||||
* @param uri Trimmed URI
|
||||
* @returns Flattened cipher health details to URI
|
||||
*/
|
||||
export function getFlattenedCipherDetails(
|
||||
detail: LEGACY_CipherHealthReportDetail,
|
||||
uri: string,
|
||||
): LEGACY_CipherHealthReportUriDetail {
|
||||
return {
|
||||
cipherId: detail.id,
|
||||
reusedPasswordCount: detail.reusedPasswordCount,
|
||||
weakPasswordDetail: detail.weakPasswordDetail,
|
||||
exposedPasswordDetail: detail.exposedPasswordDetail,
|
||||
cipherMembers: detail.cipherMembers,
|
||||
trimmedUri: uri,
|
||||
cipher: detail as CipherView,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the new application health report detail object with the details from the cipher health report uri detail object
|
||||
* update or create the at risk values if the item is at risk.
|
||||
* @param newUriDetail New cipher uri detail
|
||||
* @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk
|
||||
* @param existingUriDetail The previously processed Uri item
|
||||
* @returns The new or updated application health report detail
|
||||
*/
|
||||
export function getApplicationReportDetail(
|
||||
newUriDetail: LEGACY_CipherHealthReportUriDetail,
|
||||
isAtRisk: boolean,
|
||||
existingUriDetail?: ApplicationHealthReportDetail,
|
||||
): ApplicationHealthReportDetail {
|
||||
const reportDetail = {
|
||||
applicationName: existingUriDetail
|
||||
? existingUriDetail.applicationName
|
||||
: newUriDetail.trimmedUri,
|
||||
passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1,
|
||||
memberDetails: existingUriDetail
|
||||
? getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
|
||||
: newUriDetail.cipherMembers,
|
||||
atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [],
|
||||
atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0,
|
||||
atRiskCipherIds: existingUriDetail ? existingUriDetail.atRiskCipherIds : [],
|
||||
atRiskMemberCount: existingUriDetail ? existingUriDetail.atRiskMemberDetails.length : 0,
|
||||
cipherIds: existingUriDetail
|
||||
? existingUriDetail.cipherIds.concat(newUriDetail.cipherId)
|
||||
: [newUriDetail.cipherId],
|
||||
} as ApplicationHealthReportDetail;
|
||||
|
||||
if (isAtRisk) {
|
||||
reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1;
|
||||
reportDetail.atRiskCipherIds.push(newUriDetail.cipherId);
|
||||
|
||||
reportDetail.atRiskMemberDetails = getUniqueMembers(
|
||||
reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers),
|
||||
);
|
||||
reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length;
|
||||
}
|
||||
|
||||
reportDetail.memberCount = reportDetail.memberDetails.length;
|
||||
|
||||
return reportDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Risk Insights Report
|
||||
*
|
||||
* @returns An empty report
|
||||
*/
|
||||
export function createNewReportData(): RiskInsightsData {
|
||||
return {
|
||||
creationDate: new Date(),
|
||||
reportData: [],
|
||||
summaryData: createNewSummaryData(),
|
||||
applicationData: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Risk Insights Report Summary
|
||||
*
|
||||
@@ -181,3 +76,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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
@@ -113,3 +113,31 @@ export class GetRiskInsightsApplicationDataResponse extends BaseResponse {
|
||||
this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey");
|
||||
}
|
||||
}
|
||||
|
||||
export class MemberCipherDetailsResponse extends BaseResponse {
|
||||
userGuid: string;
|
||||
userName: string;
|
||||
email: string;
|
||||
useKeyConnector: boolean;
|
||||
cipherIds: string[] = [];
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.userGuid = this.getResponseProperty("UserGuid");
|
||||
this.userName = this.getResponseProperty("UserName");
|
||||
this.email = this.getResponseProperty("Email");
|
||||
this.useKeyConnector = this.getResponseProperty("UseKeyConnector");
|
||||
this.cipherIds = this.getResponseProperty("CipherIds");
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateRiskInsightsApplicationDataRequest {
|
||||
data: {
|
||||
applicationData: string;
|
||||
};
|
||||
}
|
||||
export class UpdateRiskInsightsApplicationDataResponse extends BaseResponse {
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,109 +1,83 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { MemberCipherDetailsResponse } from "..";
|
||||
|
||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
||||
|
||||
export const mockMemberCipherDetails: any = [
|
||||
{
|
||||
export const mockMemberCipherDetailsResponse: MemberCipherDetailsResponse[] = [
|
||||
mock<MemberCipherDetailsResponse>({
|
||||
userGuid: "user-1",
|
||||
userName: "David Brent",
|
||||
email: "david.brent@wernhamhogg.uk",
|
||||
usesKeyConnector: true,
|
||||
useKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
mock<MemberCipherDetailsResponse>({
|
||||
userGuid: "user-2",
|
||||
userName: "Tim Canterbury",
|
||||
email: "tim.canterbury@wernhamhogg.uk",
|
||||
usesKeyConnector: false,
|
||||
useKeyConnector: false,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
mock<MemberCipherDetailsResponse>({
|
||||
userGuid: "user-3",
|
||||
userName: "Gareth Keenan",
|
||||
email: "gareth.keenan@wernhamhogg.uk",
|
||||
usesKeyConnector: true,
|
||||
useKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm7",
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
mock<MemberCipherDetailsResponse>({
|
||||
userGuid: "user-4",
|
||||
userName: "Dawn Tinsley",
|
||||
email: "dawn.tinsley@wernhamhogg.uk",
|
||||
usesKeyConnector: true,
|
||||
useKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
mock<MemberCipherDetailsResponse>({
|
||||
userGuid: "user-5",
|
||||
userName: "Keith Bishop",
|
||||
email: "keith.bishop@wernhamhogg.uk",
|
||||
usesKeyConnector: false,
|
||||
useKeyConnector: false,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
mock<MemberCipherDetailsResponse>({
|
||||
userGuid: "user-1",
|
||||
userName: "Chris Finch",
|
||||
email: "chris.finch@wernhamhogg.uk",
|
||||
usesKeyConnector: true,
|
||||
useKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
mock<MemberCipherDetailsResponse>({
|
||||
userGuid: "user-1",
|
||||
userName: "Mister Secure",
|
||||
email: "mister.secure@secureco.com",
|
||||
usesKeyConnector: true,
|
||||
useKeyConnector: true,
|
||||
cipherIds: ["cbea34a8-bde4-46ad-9d19-b05001227tt1"],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
describe("Member Cipher Details API Service", () => {
|
||||
let memberCipherDetailsApiService: MemberCipherDetailsApiService;
|
||||
|
||||
const apiService = mock<ApiService>();
|
||||
|
||||
beforeEach(() => {
|
||||
memberCipherDetailsApiService = new MemberCipherDetailsApiService(apiService);
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(memberCipherDetailsApiService).not.toBeFalsy();
|
||||
});
|
||||
|
||||
it("getMemberCipherDetails retrieves data", async () => {
|
||||
apiService.send.mockResolvedValue(mockMemberCipherDetails);
|
||||
|
||||
const orgId = "1234";
|
||||
const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toHaveLength(7);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
"/reports/member-cipher-details/" + orgId,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,14 +3,15 @@ import { mock } from "jest-mock-extended";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
|
||||
|
||||
import { ApplicationHealthReportDetailEnriched } from "./report-data-service.types";
|
||||
import { MemberCipherDetailsResponse } from "..";
|
||||
import { ApplicationHealthReportDetailEnriched } from "../report-data-service.types";
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
CipherHealthReport,
|
||||
OrganizationReportApplication,
|
||||
OrganizationReportSummary,
|
||||
} from "./report-models";
|
||||
PasswordHealthData,
|
||||
} from "../report-models";
|
||||
|
||||
const mockApplication1: ApplicationHealthReportDetail = {
|
||||
applicationName: "application1.com",
|
||||
@@ -82,10 +83,12 @@ export const mockApplicationData: OrganizationReportApplication[] = [
|
||||
{
|
||||
applicationName: "application1.com",
|
||||
isCritical: true,
|
||||
reviewedDate: new Date(),
|
||||
},
|
||||
{
|
||||
applicationName: "application2.com",
|
||||
isCritical: false,
|
||||
reviewedDate: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -138,3 +141,41 @@ export const mockMemberDetails = [
|
||||
email: "user3@other.com",
|
||||
}),
|
||||
];
|
||||
|
||||
export const mockCipherHealthReports: CipherHealthReport[] = [
|
||||
{
|
||||
applications: ["app.com"],
|
||||
cipherMembers: [],
|
||||
healthData: createPasswordHealthData(0),
|
||||
cipher: mockCipherViews[0],
|
||||
},
|
||||
{
|
||||
applications: ["app.com"],
|
||||
cipherMembers: [],
|
||||
healthData: createPasswordHealthData(1),
|
||||
cipher: mockCipherViews[1],
|
||||
},
|
||||
{
|
||||
applications: ["other.com"],
|
||||
cipherMembers: [],
|
||||
healthData: createPasswordHealthData(2),
|
||||
cipher: mockCipherViews[2],
|
||||
},
|
||||
];
|
||||
|
||||
function createPasswordHealthData(reusedPasswordCount: number | null): PasswordHealthData {
|
||||
return {
|
||||
reusedPasswordCount: reusedPasswordCount ?? 0,
|
||||
weakPasswordDetail: {
|
||||
score: 0,
|
||||
detailValue: {
|
||||
label: "",
|
||||
badgeVariant: "info",
|
||||
},
|
||||
},
|
||||
exposedPasswordDetail: {
|
||||
cipherId: "",
|
||||
exposedXTimes: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { BadgeVariant } from "@bitwarden/components";
|
||||
|
||||
import { ApplicationHealthReportDetail } from "./report-models";
|
||||
|
||||
/**
|
||||
* Weak password details containing the score
|
||||
* and the score type for the label and badge
|
||||
@@ -30,37 +27,3 @@ export type ExposedPasswordDetail = {
|
||||
cipherId: string;
|
||||
exposedXTimes: number;
|
||||
} | null;
|
||||
|
||||
export type LEGACY_MemberDetailsFlat = {
|
||||
userGuid: string;
|
||||
userName: string;
|
||||
email: string;
|
||||
cipherId: string;
|
||||
};
|
||||
|
||||
export type LEGACY_ApplicationHealthReportDetailWithCriticalFlag = ApplicationHealthReportDetail & {
|
||||
isMarkedAsCritical: boolean;
|
||||
};
|
||||
|
||||
export type LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher =
|
||||
LEGACY_ApplicationHealthReportDetailWithCriticalFlag & {
|
||||
ciphers: CipherView[];
|
||||
};
|
||||
|
||||
export type LEGACY_CipherHealthReportDetail = CipherView & {
|
||||
reusedPasswordCount: number;
|
||||
weakPasswordDetail: WeakPasswordDetail;
|
||||
exposedPasswordDetail: ExposedPasswordDetail;
|
||||
cipherMembers: LEGACY_MemberDetailsFlat[];
|
||||
trimmedUris: string[];
|
||||
};
|
||||
|
||||
export type LEGACY_CipherHealthReportUriDetail = {
|
||||
cipherId: string;
|
||||
reusedPasswordCount: number;
|
||||
weakPasswordDetail: WeakPasswordDetail;
|
||||
exposedPasswordDetail: ExposedPasswordDetail;
|
||||
cipherMembers: LEGACY_MemberDetailsFlat[];
|
||||
trimmedUri: string;
|
||||
cipher: CipherView;
|
||||
};
|
||||
|
||||
@@ -1,44 +1,13 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
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 +40,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 +48,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 +65,11 @@ export type CriticalSummaryDetails = {
|
||||
export type OrganizationReportApplication = {
|
||||
applicationName: string;
|
||||
isCritical: boolean;
|
||||
/**
|
||||
* Captures when a report has been reviewed by a user and
|
||||
* can be filtered on to check for new applications
|
||||
* */
|
||||
reviewedDate: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -131,15 +88,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,8 +100,26 @@ export type ReportResult = CipherView & {
|
||||
};
|
||||
|
||||
export interface RiskInsightsData {
|
||||
id: OrganizationReportId;
|
||||
creationDate: Date;
|
||||
contentEncryptionKey: EncString;
|
||||
reportData: ApplicationHealthReportDetail[];
|
||||
summaryData: OrganizationReportSummary;
|
||||
applicationData: OrganizationReportApplication[];
|
||||
}
|
||||
|
||||
export interface ReportState {
|
||||
loading: boolean;
|
||||
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[];
|
||||
// }
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class MemberCipherDetailsResponse extends BaseResponse {
|
||||
userGuid: string;
|
||||
userName: string;
|
||||
email: string;
|
||||
useKeyConnector: boolean;
|
||||
cipherIds: string[] = [];
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.userGuid = this.getResponseProperty("UserGuid");
|
||||
this.userName = this.getResponseProperty("UserName");
|
||||
this.email = this.getResponseProperty("Email");
|
||||
this.useKeyConnector = this.getResponseProperty("UseKeyConnector");
|
||||
this.cipherIds = this.getResponseProperty("CipherIds");
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
PasswordHealthReportApplicationDropRequest,
|
||||
PasswordHealthReportApplicationsRequest,
|
||||
PasswordHealthReportApplicationsResponse,
|
||||
} from "../models/api-models.types";
|
||||
import { PasswordHealthReportApplicationId } from "../models/report-models";
|
||||
} from "../../models/api-models.types";
|
||||
import { PasswordHealthReportApplicationId } from "../../models/report-models";
|
||||
|
||||
import { CriticalAppsApiService } from "./critical-apps-api.service";
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
PasswordHealthReportApplicationDropRequest,
|
||||
PasswordHealthReportApplicationsRequest,
|
||||
PasswordHealthReportApplicationsResponse,
|
||||
} from "../models/api-models.types";
|
||||
} from "../../models/api-models.types";
|
||||
|
||||
export class CriticalAppsApiService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
|
||||
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
|
||||
|
||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
||||
|
||||
describe("Member Cipher Details API Service", () => {
|
||||
let memberCipherDetailsApiService: MemberCipherDetailsApiService;
|
||||
|
||||
const apiService = mock<ApiService>();
|
||||
|
||||
beforeEach(() => {
|
||||
memberCipherDetailsApiService = new MemberCipherDetailsApiService(apiService);
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(memberCipherDetailsApiService).not.toBeFalsy();
|
||||
});
|
||||
|
||||
it("getMemberCipherDetails retrieves data", async () => {
|
||||
apiService.send.mockResolvedValue(mockMemberCipherDetailsResponse);
|
||||
|
||||
const orgId = "1234";
|
||||
const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toHaveLength(7);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
"/reports/member-cipher-details/" + orgId,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
|
||||
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
|
||||
import { MemberCipherDetailsResponse } from "../../models";
|
||||
|
||||
@Injectable()
|
||||
export class MemberCipherDetailsApiService {
|
||||
@@ -7,17 +7,16 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response"
|
||||
import { makeEncString } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { EncryptedDataWithKey } from "../models";
|
||||
import { EncryptedDataWithKey } from "../../models";
|
||||
import {
|
||||
GetRiskInsightsApplicationDataResponse,
|
||||
GetRiskInsightsReportResponse,
|
||||
GetRiskInsightsSummaryResponse,
|
||||
SaveRiskInsightsReportRequest,
|
||||
SaveRiskInsightsReportResponse,
|
||||
} from "../models/api-models.types";
|
||||
import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data";
|
||||
|
||||
import { RiskInsightsApiService } from "./risk-insights-api.service";
|
||||
} from "../../models/api-models.types";
|
||||
import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data";
|
||||
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
||||
|
||||
describe("RiskInsightsApiService", () => {
|
||||
let service: RiskInsightsApiService;
|
||||
@@ -229,19 +228,22 @@ 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$(reportId, orgId, {
|
||||
data: { applicationData: mockApplication.encryptedString! },
|
||||
}),
|
||||
);
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
|
||||
mockApplication,
|
||||
{ applicationData: mockApplication.encryptedString!, id: reportId, organizationId: orgId },
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,18 @@ 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,
|
||||
UpdateRiskInsightsApplicationDataRequest,
|
||||
UpdateRiskInsightsApplicationDataResponse,
|
||||
} from "../../models";
|
||||
import {
|
||||
GetRiskInsightsApplicationDataResponse,
|
||||
GetRiskInsightsReportResponse,
|
||||
GetRiskInsightsSummaryResponse,
|
||||
SaveRiskInsightsReportRequest,
|
||||
SaveRiskInsightsReportResponse,
|
||||
} from "../models/api-models.types";
|
||||
} from "../../models/api-models.types";
|
||||
|
||||
export class RiskInsightsApiService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
@@ -102,18 +106,20 @@ export class RiskInsightsApiService {
|
||||
}
|
||||
|
||||
updateRiskInsightsApplicationData$(
|
||||
applicationData: OrganizationReportApplication,
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import { KeyService } from "@bitwarden/key-management";
|
||||
import {
|
||||
PasswordHealthReportApplicationsRequest,
|
||||
PasswordHealthReportApplicationsResponse,
|
||||
} from "../models/api-models.types";
|
||||
import { PasswordHealthReportApplicationId } from "../models/report-models";
|
||||
} from "../../models/api-models.types";
|
||||
import { PasswordHealthReportApplicationId } from "../../models/report-models";
|
||||
import { CriticalAppsApiService } from "../api/critical-apps-api.service";
|
||||
|
||||
import { CriticalAppsApiService } from "./critical-apps-api.service";
|
||||
import { CriticalAppsService } from "./critical-apps.service";
|
||||
|
||||
const SomeCsprngArray = new Uint8Array(64) as CsprngArray;
|
||||
@@ -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();
|
||||
@@ -1,13 +1,17 @@
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
filter,
|
||||
first,
|
||||
firstValueFrom,
|
||||
forkJoin,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
switchMap,
|
||||
tap,
|
||||
throwError,
|
||||
zip,
|
||||
} from "rxjs";
|
||||
|
||||
@@ -20,9 +24,8 @@ import { KeyService } from "@bitwarden/key-management";
|
||||
import {
|
||||
PasswordHealthReportApplicationsRequest,
|
||||
PasswordHealthReportApplicationsResponse,
|
||||
} from "../models/api-models.types";
|
||||
|
||||
import { CriticalAppsApiService } from "./critical-apps-api.service";
|
||||
} from "../../models/api-models.types";
|
||||
import { CriticalAppsApiService } from "../api/critical-apps-api.service";
|
||||
|
||||
/* Retrieves and decrypts critical apps for a given organization
|
||||
* Encrypts and saves data for a given organization
|
||||
@@ -125,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");
|
||||
}
|
||||
@@ -150,6 +158,31 @@ export class CriticalAppsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[]> {
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ExposedPasswordDetail,
|
||||
WeakPasswordDetail,
|
||||
WeakPasswordScore,
|
||||
} from "../models/password-health";
|
||||
} from "../../models/password-health";
|
||||
|
||||
export class PasswordHealthService {
|
||||
constructor(
|
||||
@@ -9,9 +9,10 @@ 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/mock-data";
|
||||
import { EncryptedReportData, DecryptedReportData } from "../../models";
|
||||
import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data";
|
||||
|
||||
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
|
||||
|
||||
@@ -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();
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
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 { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { makeEncString } from "@bitwarden/common/spec";
|
||||
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 { RiskInsightsData, SaveRiskInsightsReportResponse } from "../../models";
|
||||
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
|
||||
import {
|
||||
mockApplicationData,
|
||||
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";
|
||||
|
||||
describe("RiskInsightsOrchestratorService", () => {
|
||||
let service: RiskInsightsOrchestratorService;
|
||||
|
||||
// Non changing mock data
|
||||
const mockOrgId = "org-789" as OrganizationId;
|
||||
const mockOrgName = "Test Org";
|
||||
const mockUserId = "user-101" as UserId;
|
||||
const mockReportId = "report-1" as OrganizationReportId;
|
||||
const mockKey: EncString = makeEncString("wrappedKey");
|
||||
|
||||
const reportState: RiskInsightsData = {
|
||||
id: mockReportId,
|
||||
reportData: [],
|
||||
summaryData: createNewSummaryData(),
|
||||
applicationData: [],
|
||||
creationDate: new Date(),
|
||||
contentEncryptionKey: mockKey,
|
||||
};
|
||||
const mockCiphers = [{ id: "cipher-1" }] as any;
|
||||
|
||||
// Mock services
|
||||
const mockAccountService = mock<AccountService>({
|
||||
activeAccount$: of(mock<Account>({ id: mockUserId })),
|
||||
});
|
||||
const mockCriticalAppsService = mock<CriticalAppsService>({
|
||||
criticalAppsList$: of([]),
|
||||
});
|
||||
const mockOrganizationService = mock<OrganizationService>();
|
||||
const mockCipherService = mock<CipherService>();
|
||||
const mockMemberCipherDetailsApiService = mock<MemberCipherDetailsApiService>();
|
||||
let mockPasswordHealthService: PasswordHealthService;
|
||||
const mockReportApiService = mock<RiskInsightsApiService>();
|
||||
let mockReportService: RiskInsightsReportService;
|
||||
const mockRiskInsightsEncryptionService = mock<RiskInsightsEncryptionService>();
|
||||
const mockLogService = mock<LogService>();
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock pipes from constructor
|
||||
mockReportService = mock<RiskInsightsReportService>({
|
||||
generateApplicationsReport: jest.fn().mockReturnValue(mockEnrichedReportData),
|
||||
getApplicationsSummary: jest.fn().mockReturnValue(mockSummaryData),
|
||||
getOrganizationApplications: jest.fn().mockReturnValue(mockApplicationData),
|
||||
getRiskInsightsReport$: jest.fn().mockReturnValue(of(reportState)),
|
||||
saveRiskInsightsReport$: jest.fn().mockReturnValue(
|
||||
of({
|
||||
response: { id: mockReportId } as SaveRiskInsightsReportResponse,
|
||||
contentEncryptionKey: mockKey,
|
||||
}),
|
||||
),
|
||||
});
|
||||
// Arrange mocks for new flow
|
||||
mockMemberCipherDetailsApiService.getMemberCipherDetails.mockResolvedValue(
|
||||
mockMemberCipherDetailsResponse,
|
||||
);
|
||||
|
||||
mockPasswordHealthService = mock<PasswordHealthService>({
|
||||
auditPasswordLeaks$: jest.fn(() => of([])),
|
||||
isValidCipher: jest.fn().mockReturnValue(true),
|
||||
findWeakPasswordDetails: jest.fn().mockReturnValue(null),
|
||||
});
|
||||
|
||||
mockCipherService.getAllFromApiForOrganization.mockReturnValue(mockCiphers);
|
||||
|
||||
service = new RiskInsightsOrchestratorService(
|
||||
mockAccountService,
|
||||
mockCipherService,
|
||||
mockCriticalAppsService,
|
||||
mockLogService,
|
||||
mockMemberCipherDetailsApiService,
|
||||
mockOrganizationService,
|
||||
mockPasswordHealthService,
|
||||
mockReportApiService,
|
||||
mockReportService,
|
||||
mockRiskInsightsEncryptionService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("fetchReport", () => {
|
||||
it("should call with correct org and user IDs and emit ReportState", (done) => {
|
||||
// Arrange
|
||||
const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"];
|
||||
const privateUserIdSubject = service["_userIdSubject"];
|
||||
|
||||
// Set up organization and user context
|
||||
privateOrganizationDetailsSubject.next({
|
||||
organizationId: mockOrgId,
|
||||
organizationName: mockOrgName,
|
||||
});
|
||||
privateUserIdSubject.next(mockUserId);
|
||||
|
||||
// Act
|
||||
service.fetchReport();
|
||||
|
||||
// Assert
|
||||
service.rawReportData$.subscribe((state) => {
|
||||
if (!state.loading) {
|
||||
expect(mockReportService.getRiskInsightsReport$).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
mockUserId,
|
||||
);
|
||||
expect(state.data).toEqual(reportState);
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit error ReportState when getRiskInsightsReport$ throws", (done) => {
|
||||
// Setup error passed via constructor for this test case
|
||||
mockReportService.getRiskInsightsReport$ = jest
|
||||
.fn()
|
||||
.mockReturnValue(throwError(() => new Error("API error")));
|
||||
const testService = new RiskInsightsOrchestratorService(
|
||||
mockAccountService,
|
||||
mockCipherService,
|
||||
mockCriticalAppsService,
|
||||
mockLogService,
|
||||
mockMemberCipherDetailsApiService,
|
||||
mockOrganizationService,
|
||||
mockPasswordHealthService,
|
||||
mockReportApiService,
|
||||
mockReportService,
|
||||
mockRiskInsightsEncryptionService,
|
||||
);
|
||||
|
||||
const { _organizationDetailsSubject, _userIdSubject } = testService as any;
|
||||
_organizationDetailsSubject.next({
|
||||
organizationId: mockOrgId,
|
||||
organizationName: mockOrgName,
|
||||
});
|
||||
_userIdSubject.next(mockUserId);
|
||||
testService.fetchReport();
|
||||
testService.rawReportData$.subscribe((state) => {
|
||||
if (!state.loading) {
|
||||
expect(state.error).toBe("Failed to fetch report");
|
||||
expect(state.data).toBeNull();
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateReport", () => {
|
||||
it("should generate report using member ciphers and password health, then save and emit ReportState", (done) => {
|
||||
const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"];
|
||||
const privateUserIdSubject = service["_userIdSubject"];
|
||||
|
||||
// Set up ciphers in orchestrator
|
||||
privateOrganizationDetailsSubject.next({
|
||||
organizationId: mockOrgId,
|
||||
organizationName: mockOrgName,
|
||||
});
|
||||
privateUserIdSubject.next(mockUserId);
|
||||
|
||||
// Act
|
||||
service.generateReport();
|
||||
|
||||
// Assert
|
||||
service.rawReportData$.subscribe((state) => {
|
||||
if (!state.loading && state.data) {
|
||||
expect(mockMemberCipherDetailsApiService.getMemberCipherDetails).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
);
|
||||
expect(mockReportService.generateApplicationsReport).toHaveBeenCalled();
|
||||
expect(mockReportService.saveRiskInsightsReport$).toHaveBeenCalledWith(
|
||||
mockEnrichedReportData,
|
||||
mockSummaryData,
|
||||
mockApplicationData,
|
||||
{ organizationId: mockOrgId, userId: mockUserId },
|
||||
);
|
||||
expect(state.data.reportData).toEqual(mockEnrichedReportData);
|
||||
expect(state.data.summaryData).toEqual(mockSummaryData);
|
||||
expect(state.data.applicationData).toEqual(mockApplicationData);
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,742 @@
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
forkJoin,
|
||||
from,
|
||||
merge,
|
||||
Observable,
|
||||
of,
|
||||
Subject,
|
||||
Subscription,
|
||||
throwError,
|
||||
} from "rxjs";
|
||||
import {
|
||||
catchError,
|
||||
distinctUntilChanged,
|
||||
exhaustMap,
|
||||
filter,
|
||||
map,
|
||||
scan,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
withLatestFrom,
|
||||
} from "rxjs/operators";
|
||||
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { 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,
|
||||
createNewSummaryData,
|
||||
flattenMemberDetails,
|
||||
getTrimmedCipherUris,
|
||||
} from "../../helpers";
|
||||
import {
|
||||
ApplicationHealthReportDetailEnriched,
|
||||
PasswordHealthReportApplicationsResponse,
|
||||
} from "../../models";
|
||||
import { RiskInsightsEnrichedData } from "../../models/report-data-service.types";
|
||||
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 {
|
||||
private _destroy$ = new Subject<void>();
|
||||
|
||||
// -------------------------- Context state --------------------------
|
||||
// Current user viewing risk insights
|
||||
private _userIdSubject = new BehaviorSubject<UserId | null>(null);
|
||||
private _userId$ = this._userIdSubject.asObservable();
|
||||
|
||||
// Organization the user is currently viewing
|
||||
private _organizationDetailsSubject = new BehaviorSubject<{
|
||||
organizationId: OrganizationId;
|
||||
organizationName: string;
|
||||
} | 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,
|
||||
error: null,
|
||||
data: null,
|
||||
});
|
||||
rawReportData$ = this._rawReportDataSubject.asObservable();
|
||||
private _enrichedReportDataSubject = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
|
||||
enrichedReportData$ = this._enrichedReportDataSubject.asObservable();
|
||||
|
||||
// Generate report trigger and state
|
||||
private _generateReportTriggerSubject = new BehaviorSubject<boolean>(false);
|
||||
generatingReport$ = this._generateReportTriggerSubject.asObservable();
|
||||
|
||||
// --------------------------- Critical Application data ---------------------
|
||||
criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
|
||||
|
||||
// --------------------------- Trigger subjects ---------------------
|
||||
private _initializeOrganizationTriggerSubject = new Subject<OrganizationId>();
|
||||
private _fetchReportTriggerSubject = new Subject<void>();
|
||||
|
||||
private _reportStateSubscription: Subscription | null = null;
|
||||
private _migrationSubscription: Subscription | null = null;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private cipherService: CipherService,
|
||||
private criticalAppsService: CriticalAppsService,
|
||||
private logService: LogService,
|
||||
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
|
||||
private organizationService: OrganizationService,
|
||||
private passwordHealthService: PasswordHealthService,
|
||||
private reportApiService: RiskInsightsApiService,
|
||||
private reportService: RiskInsightsReportService,
|
||||
private riskInsightsEncryptionService: RiskInsightsEncryptionService,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
if (this._migrationSubscription) {
|
||||
this._migrationSubscription.unsubscribe();
|
||||
}
|
||||
this._destroy$.next();
|
||||
this._destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the latest report for the current organization and user
|
||||
*/
|
||||
fetchReport(): void {
|
||||
this.logService.debug("[RiskInsightsOrchestratorService] Fetch report triggered");
|
||||
this._fetchReportTriggerSubject.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
removeCriticalApplication$(criticalApplication: string): Observable<ReportState> {
|
||||
this.logService.info(
|
||||
"[RiskInsightsOrchestratorService] Removing critical applications from report",
|
||||
);
|
||||
return this.rawReportData$.pipe(
|
||||
take(1),
|
||||
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]) => {
|
||||
// Create a set for quick lookup of the new critical apps
|
||||
const existingApplicationData = reportState?.data?.applicationData || [];
|
||||
const updatedApplicationData = this._removeCriticalApplication(
|
||||
existingApplicationData,
|
||||
criticalApplication,
|
||||
);
|
||||
|
||||
const updatedState = {
|
||||
...reportState,
|
||||
data: {
|
||||
...reportState.data,
|
||||
applicationData: updatedApplicationData,
|
||||
},
|
||||
} as ReportState;
|
||||
|
||||
return { reportState, organizationDetails, updatedState, userId };
|
||||
}),
|
||||
switchMap(({ reportState, organizationDetails, updatedState, userId }) => {
|
||||
return from(
|
||||
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
||||
{
|
||||
organizationId: organizationDetails!.organizationId,
|
||||
userId: userId!,
|
||||
},
|
||||
{
|
||||
reportData: reportState?.data?.reportData ?? [],
|
||||
summaryData: reportState?.data?.summaryData ?? createNewSummaryData(),
|
||||
applicationData: updatedState?.data?.applicationData ?? [],
|
||||
},
|
||||
reportState?.data?.contentEncryptionKey,
|
||||
),
|
||||
).pipe(
|
||||
map((encryptedData) => ({
|
||||
reportState,
|
||||
organizationDetails,
|
||||
updatedState,
|
||||
encryptedData,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
|
||||
this.logService.debug(
|
||||
`[RiskInsightsOrchestratorService] Saving applicationData with toggled critical flag for report with id: ${reportState?.data?.id} and org id: ${organizationDetails?.organizationId}`,
|
||||
);
|
||||
if (!reportState?.data?.id || !organizationDetails?.organizationId) {
|
||||
return of({ ...reportState });
|
||||
}
|
||||
return this.reportApiService
|
||||
.updateRiskInsightsApplicationData$(
|
||||
reportState.data.id,
|
||||
organizationDetails.organizationId,
|
||||
{
|
||||
data: {
|
||||
applicationData: encryptedData.encryptedApplicationData.toSdk(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
map(() => updatedState),
|
||||
tap((finalState) => this._rawReportDataSubject.next(finalState)),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to save updated applicationData", error);
|
||||
return of({ ...reportState, error: "Failed to remove a critical application" });
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
saveCriticalApplications$(criticalApplications: string[]): Observable<ReportState> {
|
||||
this.logService.info(
|
||||
"[RiskInsightsOrchestratorService] Saving critical applications to report",
|
||||
);
|
||||
return this.rawReportData$.pipe(
|
||||
take(1),
|
||||
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]) => {
|
||||
// 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,
|
||||
data: {
|
||||
...reportState.data,
|
||||
applicationData: updatedApplicationData,
|
||||
},
|
||||
} as ReportState;
|
||||
|
||||
return { reportState, organizationDetails, updatedState, userId };
|
||||
}),
|
||||
switchMap(({ reportState, organizationDetails, updatedState, userId }) => {
|
||||
return from(
|
||||
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
||||
{
|
||||
organizationId: organizationDetails!.organizationId,
|
||||
userId: userId!,
|
||||
},
|
||||
{
|
||||
reportData: reportState?.data?.reportData ?? [],
|
||||
summaryData: reportState?.data?.summaryData ?? createNewSummaryData(),
|
||||
applicationData: updatedState?.data?.applicationData ?? [],
|
||||
},
|
||||
reportState?.data?.contentEncryptionKey,
|
||||
),
|
||||
).pipe(
|
||||
map((encryptedData) => ({
|
||||
reportState,
|
||||
organizationDetails,
|
||||
updatedState,
|
||||
encryptedData,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
|
||||
this.logService.debug(
|
||||
`[RiskInsightsOrchestratorService] Saving critical applications on 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$(
|
||||
reportState.data.id,
|
||||
organizationDetails.organizationId,
|
||||
{
|
||||
data: {
|
||||
applicationData: encryptedData.encryptedApplicationData.toSdk(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
map(() => updatedState),
|
||||
tap((finalState) => this._rawReportDataSubject.next(finalState)),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to save updated applicationData", error);
|
||||
return of({ ...reportState, error: "Failed to save critical applications" });
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private _fetchReport$(organizationId: OrganizationId, userId: UserId): Observable<ReportState> {
|
||||
return this.reportService.getRiskInsightsReport$(organizationId, userId).pipe(
|
||||
tap(() => this.logService.debug("[RiskInsightsOrchestratorService] Fetching report")),
|
||||
map((result): ReportState => {
|
||||
return {
|
||||
loading: false,
|
||||
error: null,
|
||||
data: result ?? null,
|
||||
};
|
||||
}),
|
||||
catchError(() => of({ loading: false, error: "Failed to fetch report", data: null })),
|
||||
startWith({ loading: true, error: null, data: null }),
|
||||
);
|
||||
}
|
||||
|
||||
private _generateNewApplicationsReport$(
|
||||
organizationId: OrganizationId,
|
||||
userId: UserId,
|
||||
): Observable<ReportState> {
|
||||
// Generate the report
|
||||
const memberCiphers$ = from(
|
||||
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
|
||||
).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers)));
|
||||
|
||||
return forkJoin([this._ciphers$.pipe(take(1)), 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
|
||||
return this.reportService
|
||||
.saveRiskInsightsReport$(report, summary, applications, {
|
||||
organizationId,
|
||||
userId,
|
||||
})
|
||||
.pipe(
|
||||
map((result) => ({
|
||||
report,
|
||||
summary,
|
||||
applications,
|
||||
id: result.response.id,
|
||||
contentEncryptionKey: result.contentEncryptionKey,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
// Update the running state
|
||||
map((mappedResult): ReportState => {
|
||||
const { id, report, summary, applications, contentEncryptionKey } = mappedResult;
|
||||
return {
|
||||
loading: false,
|
||||
error: null,
|
||||
data: {
|
||||
id,
|
||||
reportData: report,
|
||||
summaryData: summary,
|
||||
applicationData: applications,
|
||||
creationDate: new Date(),
|
||||
contentEncryptionKey,
|
||||
},
|
||||
};
|
||||
}),
|
||||
catchError(() => {
|
||||
return of({ loading: false, error: "Failed to generate or save report", data: null });
|
||||
}),
|
||||
startWith({ loading: true, error: null, data: null }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
} as CipherHealthReport;
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Toggles the isCritical flag on applications via criticalApplicationName
|
||||
private _removeCriticalApplication(
|
||||
applicationData: OrganizationReportApplication[],
|
||||
criticalApplication: string,
|
||||
): OrganizationReportApplication[] {
|
||||
const updatedApplicationData = applicationData.map((application) => {
|
||||
if (application.applicationName == criticalApplication) {
|
||||
return { ...application, isCritical: false } as OrganizationReportApplication;
|
||||
}
|
||||
return application;
|
||||
});
|
||||
return updatedApplicationData;
|
||||
}
|
||||
|
||||
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.",
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(
|
||||
"[RiskInsightsOrchestratorService] Failed to save migrated critical applications",
|
||||
error,
|
||||
);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Setup the pipeline to load critical applications when organization or user changes
|
||||
private _setupCriticalApplicationContext() {
|
||||
this.organizationDetails$
|
||||
.pipe(
|
||||
filter((orgDetails) => !!orgDetails),
|
||||
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$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
// Setup the pipeline to create a report view filtered to only critical applications
|
||||
private _setupCriticalApplicationReport() {
|
||||
const criticalReportResultsPipeline$ = this.enrichedReportData$.pipe(
|
||||
filter((state) => !!state),
|
||||
map((enrichedReports) => {
|
||||
const criticalApplications = enrichedReports!.reportData.filter(
|
||||
(app) => app.isMarkedAsCritical,
|
||||
);
|
||||
// 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$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the basic application health report details and enriches them to include
|
||||
* critical app status and associated ciphers.
|
||||
*/
|
||||
private _setupEnrichedReportData() {
|
||||
// Setup the enriched report data pipeline
|
||||
const enrichmentSubscription = combineLatest([
|
||||
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 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 = {
|
||||
...rawReportData.data,
|
||||
reportData: enrichedReports,
|
||||
} as RiskInsightsEnrichedData;
|
||||
|
||||
return of(enrichedData);
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
enrichmentSubscription.pipe(takeUntil(this._destroy$)).subscribe((enrichedData) => {
|
||||
this._enrichedReportDataSubject.next(enrichedData);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup the pipeline to initialize organization context
|
||||
private _setupInitializationPipeline() {
|
||||
this._initializeOrganizationTriggerSubject
|
||||
.pipe(
|
||||
withLatestFrom(this._userId$),
|
||||
filter(([orgId, userId]) => !!orgId && !!userId),
|
||||
exhaustMap(([orgId, userId]) =>
|
||||
this.organizationService.organizations$(userId!).pipe(
|
||||
getOrganizationById(orgId),
|
||||
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() {
|
||||
const criticalApps$ = this.criticalAppsService.criticalAppsList$.pipe(
|
||||
filter((criticalApps) => criticalApps.length > 0),
|
||||
take(1),
|
||||
);
|
||||
|
||||
const rawReportData$ = this.rawReportData$.pipe(
|
||||
filter((reportState) => !!reportState.data),
|
||||
take(1),
|
||||
);
|
||||
|
||||
this._migrationSubscription = forkJoin([criticalApps$, rawReportData$])
|
||||
.pipe(
|
||||
tap(([criticalApps]) => {
|
||||
this.logService.debug(
|
||||
`[RiskInsightsOrchestratorService] Detected ${criticalApps.length} legacy critical apps, running migration and cleanup`,
|
||||
criticalApps,
|
||||
);
|
||||
}),
|
||||
switchMap(([criticalApps, _reportState]) =>
|
||||
this._runMigrationAndCleanup$(criticalApps).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(
|
||||
"[RiskInsightsOrchestratorService] Migration and cleanup failed.",
|
||||
error,
|
||||
);
|
||||
return of([]);
|
||||
}),
|
||||
),
|
||||
),
|
||||
take(1),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
// Setup the report state management pipeline
|
||||
private _setupReportState() {
|
||||
// Dependencies needed for report state
|
||||
const reportDependencies$ = combineLatest([
|
||||
this.organizationDetails$.pipe(filter((org) => !!org)),
|
||||
this._userId$.pipe(filter((user) => !!user)),
|
||||
]).pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
|
||||
// A stream for the initial report fetch
|
||||
const initialReportLoad$ = reportDependencies$.pipe(
|
||||
take(1),
|
||||
exhaustMap(([orgDetails, userId]) => this._fetchReport$(orgDetails!.organizationId, userId!)),
|
||||
);
|
||||
|
||||
// A stream for manually triggered fetches
|
||||
const manualReportFetch$ = this._fetchReportTriggerSubject.pipe(
|
||||
withLatestFrom(reportDependencies$),
|
||||
exhaustMap(([_, [orgDetails, userId]]) =>
|
||||
this._fetchReport$(orgDetails!.organizationId, userId!),
|
||||
),
|
||||
);
|
||||
|
||||
// A stream for generating a new report
|
||||
const newReportGeneration$ = this.generatingReport$.pipe(
|
||||
distinctUntilChanged(),
|
||||
filter((isRunning) => isRunning),
|
||||
withLatestFrom(reportDependencies$),
|
||||
exhaustMap(([_, [orgDetails, userId]]) =>
|
||||
this._generateNewApplicationsReport$(orgDetails!.organizationId, userId!),
|
||||
),
|
||||
tap(() => {
|
||||
this._generateReportTriggerSubject.next(false);
|
||||
}),
|
||||
);
|
||||
|
||||
// Combine all triggers and update the single report state
|
||||
const mergedReportState$ = merge(
|
||||
initialReportLoad$,
|
||||
manualReportFetch$,
|
||||
newReportGeneration$,
|
||||
).pipe(
|
||||
scan((prevState: ReportState, currState: ReportState) => ({
|
||||
...prevState,
|
||||
...currState,
|
||||
data: currState.data !== null ? currState.data : prevState.data,
|
||||
})),
|
||||
startWith({ loading: false, error: null, data: null }),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
takeUntil(this._destroy$),
|
||||
);
|
||||
|
||||
this._reportStateSubscription = mergedReportState$
|
||||
.pipe(takeUntil(this._destroy$))
|
||||
.subscribe((state) => {
|
||||
this._rawReportDataSubject.next(state);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup the user ID observable to track the current user
|
||||
private _setupUserId() {
|
||||
// Watch userId changes
|
||||
this.accountService.activeAccount$.pipe(getUserId).subscribe((userId) => {
|
||||
this._userIdSubject.next(userId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,24 +6,25 @@ import { makeEncString } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
import { DecryptedReportData, EncryptedDataWithKey } from "../models";
|
||||
import { DecryptedReportData, EncryptedDataWithKey } from "../../models";
|
||||
import {
|
||||
GetRiskInsightsReportResponse,
|
||||
SaveRiskInsightsReportResponse,
|
||||
} from "../models/api-models.types";
|
||||
} from "../../models/api-models.types";
|
||||
import { mockCiphers } from "../../models/mocks/ciphers.mock";
|
||||
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
|
||||
import {
|
||||
mockApplicationData,
|
||||
mockCipherHealthReports,
|
||||
mockCipherViews,
|
||||
mockMemberDetails,
|
||||
mockReportData,
|
||||
mockSummaryData,
|
||||
} from "../models/mock-data";
|
||||
} from "../../models/mocks/mock-data";
|
||||
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
|
||||
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
||||
|
||||
import { mockCiphers } from "./ciphers.mock";
|
||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
||||
import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec";
|
||||
import { PasswordHealthService } from "./password-health.service";
|
||||
import { RiskInsightsApiService } from "./risk-insights-api.service";
|
||||
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
|
||||
import { RiskInsightsReportService } from "./risk-insights-report.service";
|
||||
|
||||
@@ -54,7 +55,9 @@ describe("RiskInsightsReportService", () => {
|
||||
beforeEach(() => {
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers);
|
||||
|
||||
memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberCipherDetails);
|
||||
memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(
|
||||
mockMemberCipherDetailsResponse,
|
||||
);
|
||||
|
||||
// Mock PasswordHealthService methods
|
||||
mockPasswordHealthService.isValidCipher.mockImplementation((cipher: any) => {
|
||||
@@ -79,9 +82,6 @@ describe("RiskInsightsReportService", () => {
|
||||
});
|
||||
|
||||
service = new RiskInsightsReportService(
|
||||
cipherService,
|
||||
memberCipherDetailsService,
|
||||
mockPasswordHealthService,
|
||||
mockRiskInsightsApiService,
|
||||
mockRiskInsightsEncryptionService,
|
||||
);
|
||||
@@ -93,127 +93,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(mockCipherHealthReports);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate the raw data report correctly", async () => {
|
||||
const result = await firstValueFrom(service.LEGACY_generateRawDataReport$(mockOrganizationId));
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
|
||||
let testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001228ab1");
|
||||
expect(testCaseResults).toHaveLength(1);
|
||||
let testCase = testCaseResults[0];
|
||||
expect(testCase).toBeTruthy();
|
||||
expect(testCase.cipherMembers).toHaveLength(2);
|
||||
expect(testCase.trimmedUris).toHaveLength(5);
|
||||
expect(testCase.weakPasswordDetail).toBeTruthy();
|
||||
expect(testCase.exposedPasswordDetail).toBeTruthy();
|
||||
expect(testCase.reusedPasswordCount).toEqual(2);
|
||||
|
||||
testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001227tt1");
|
||||
expect(testCaseResults).toHaveLength(1);
|
||||
testCase = testCaseResults[0];
|
||||
expect(testCase).toBeTruthy();
|
||||
expect(testCase.cipherMembers).toHaveLength(1);
|
||||
expect(testCase.trimmedUris).toHaveLength(1);
|
||||
expect(testCase.weakPasswordDetail).toBeFalsy();
|
||||
expect(testCase.exposedPasswordDetail).toBeFalsy();
|
||||
expect(testCase.reusedPasswordCount).toEqual(1);
|
||||
});
|
||||
|
||||
it("should generate the raw data + uri report correctly", async () => {
|
||||
const result = await firstValueFrom(service.generateRawDataUriReport$(mockOrganizationId));
|
||||
|
||||
expect(result).toHaveLength(11);
|
||||
|
||||
// Two ciphers that have google.com as their uri. There should be 2 results
|
||||
const googleResults = result.filter((x) => x.trimmedUri === "google.com");
|
||||
expect(googleResults).toHaveLength(2);
|
||||
|
||||
// There is an invalid uri and it should not be trimmed
|
||||
const invalidUriResults = result.filter((x) => x.trimmedUri === "this_is-not|a-valid-uri123@+");
|
||||
expect(invalidUriResults).toHaveLength(1);
|
||||
|
||||
// Verify the details for one of the googles matches the password health info
|
||||
// expected
|
||||
const firstGoogle = googleResults.filter(
|
||||
(x) => x.cipherId === "cbea34a8-bde4-46ad-9d19-b05001228ab1" && x.trimmedUri === "google.com",
|
||||
)[0];
|
||||
expect(firstGoogle.weakPasswordDetail).toBeTruthy();
|
||||
expect(firstGoogle.exposedPasswordDetail).toBeTruthy();
|
||||
expect(firstGoogle.reusedPasswordCount).toEqual(2);
|
||||
});
|
||||
|
||||
it("should generate applications health report data correctly", async () => {
|
||||
const result = await firstValueFrom(
|
||||
service.LEGACY_generateApplicationsReport$(mockOrganizationId),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(8);
|
||||
|
||||
// Two ciphers have google.com associated with them. The first cipher
|
||||
// has 2 members and the second has 4. However, the 2 members in the first
|
||||
// cipher are also associated with the second. The total amount of members
|
||||
// should be 4 not 6
|
||||
const googleTestResults = result.filter((x) => x.applicationName === "google.com");
|
||||
expect(googleTestResults).toHaveLength(1);
|
||||
const googleTest = googleTestResults[0];
|
||||
expect(googleTest.memberCount).toEqual(4);
|
||||
|
||||
// Both ciphers have at risk passwords
|
||||
expect(googleTest.passwordCount).toEqual(2);
|
||||
|
||||
// All members are at risk since both ciphers are at risk
|
||||
expect(googleTest.atRiskMemberDetails).toHaveLength(4);
|
||||
expect(googleTest.atRiskPasswordCount).toEqual(2);
|
||||
|
||||
// There are 2 ciphers associated with 101domain.com
|
||||
const domain101TestResults = result.filter((x) => x.applicationName === "101domain.com");
|
||||
expect(domain101TestResults).toHaveLength(1);
|
||||
const domain101Test = domain101TestResults[0];
|
||||
expect(domain101Test.passwordCount).toEqual(2);
|
||||
|
||||
// The first cipher is at risk. The second cipher is not at risk
|
||||
expect(domain101Test.atRiskPasswordCount).toEqual(1);
|
||||
|
||||
// The first cipher has 2 members. The second cipher the second
|
||||
// cipher has 4. One of the members in the first cipher is associated
|
||||
// with the second. So there should be 5 members total.
|
||||
expect(domain101Test.memberCount).toEqual(5);
|
||||
|
||||
// The first cipher is at risk. The total at risk members is 2 and
|
||||
// at risk password count is 1.
|
||||
expect(domain101Test.atRiskMemberDetails).toHaveLength(2);
|
||||
expect(domain101Test.atRiskPasswordCount).toEqual(1);
|
||||
});
|
||||
|
||||
it("should generate applications summary data correctly", async () => {
|
||||
const reportResult = await firstValueFrom(
|
||||
service.LEGACY_generateApplicationsReport$(mockOrganizationId),
|
||||
);
|
||||
const reportSummary = service.generateApplicationsSummary(reportResult);
|
||||
|
||||
expect(reportSummary.totalMemberCount).toEqual(7);
|
||||
expect(reportSummary.totalAtRiskMemberCount).toEqual(6);
|
||||
expect(reportSummary.totalApplicationCount).toEqual(8);
|
||||
expect(reportSummary.totalAtRiskApplicationCount).toEqual(7);
|
||||
// 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$", () => {
|
||||
@@ -249,8 +143,6 @@ describe("RiskInsightsReportService", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should encrypt and save report, then update subjects", async () => {});
|
||||
});
|
||||
|
||||
describe("getRiskInsightsReport$", () => {
|
||||
@@ -338,7 +230,12 @@ describe("RiskInsightsReportService", () => {
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result).toEqual({ ...mockDecryptedData, creationDate: mockResponse.creationDate });
|
||||
expect(result).toEqual({
|
||||
...mockDecryptedData,
|
||||
id: mockResponse.id,
|
||||
creationDate: mockResponse.creationDate,
|
||||
contentEncryptionKey: mockEncryptedKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { catchError, EMPTY, from, map, Observable, switchMap, throwError } from "rxjs";
|
||||
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { getUniqueMembers } from "../../helpers/risk-insights-data-mappers";
|
||||
import {
|
||||
isSaveRiskInsightsReportResponse,
|
||||
SaveRiskInsightsReportResponse,
|
||||
} from "../../models/api-models.types";
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
OrganizationReportSummary,
|
||||
CipherHealthReport,
|
||||
PasswordHealthData,
|
||||
OrganizationReportApplication,
|
||||
RiskInsightsData,
|
||||
} from "../../models/report-models";
|
||||
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
||||
|
||||
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
|
||||
|
||||
export class RiskInsightsReportService {
|
||||
constructor(
|
||||
private riskInsightsApiService: RiskInsightsApiService,
|
||||
private riskInsightsEncryptionService: RiskInsightsEncryptionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Report data for the aggregation of uris to like uris and getting password/member counts,
|
||||
* members, and at risk statuses.
|
||||
*
|
||||
* @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(ciphers: CipherHealthReport[]): ApplicationHealthReportDetail[] {
|
||||
const groupedByApplication = this._groupCiphersByApplication(ciphers);
|
||||
|
||||
return Array.from(groupedByApplication.entries()).map(([application, ciphers]) =>
|
||||
this._getApplicationHealthReport(application, ciphers),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the summary from the application health report. Returns total members and applications as well
|
||||
* as the total at risk members and at risk applications
|
||||
* @param reports The previously calculated application health report data
|
||||
* @returns A summary object containing report totals
|
||||
*/
|
||||
getApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary {
|
||||
const totalMembers = reports.flatMap((x) => x.memberDetails);
|
||||
const uniqueMembers = getUniqueMembers(totalMembers);
|
||||
|
||||
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
|
||||
const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers);
|
||||
|
||||
// TODO: Replace with actual new applications detection logic (PM-26185)
|
||||
const dummyNewApplications = [
|
||||
"github.com",
|
||||
"google.com",
|
||||
"stackoverflow.com",
|
||||
"gitlab.com",
|
||||
"bitbucket.org",
|
||||
"npmjs.com",
|
||||
"docker.com",
|
||||
"aws.amazon.com",
|
||||
"azure.microsoft.com",
|
||||
"jenkins.io",
|
||||
"terraform.io",
|
||||
"kubernetes.io",
|
||||
"atlassian.net",
|
||||
];
|
||||
|
||||
return {
|
||||
totalMemberCount: uniqueMembers.length,
|
||||
totalAtRiskMemberCount: uniqueAtRiskMembers.length,
|
||||
totalApplicationCount: reports.length,
|
||||
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
|
||||
totalCriticalMemberCount: 0,
|
||||
totalCriticalAtRiskMemberCount: 0,
|
||||
totalCriticalApplicationCount: 0,
|
||||
totalCriticalAtRiskApplicationCount: 0,
|
||||
newApplications: dummyNewApplications,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a snapshot of applications and related data associated to this report
|
||||
*
|
||||
* @param reports
|
||||
* @returns A list of applications with a critical marking flag
|
||||
*/
|
||||
getOrganizationApplications(
|
||||
reports: ApplicationHealthReportDetail[],
|
||||
previousApplications: OrganizationReportApplication[] = [],
|
||||
): OrganizationReportApplication[] {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the risk insights report for a specific organization and user.
|
||||
*
|
||||
* @param organizationId
|
||||
* @param userId
|
||||
* @returns An observable that emits the decrypted risk insights report data.
|
||||
*/
|
||||
getRiskInsightsReport$(
|
||||
organizationId: OrganizationId,
|
||||
userId: UserId,
|
||||
): Observable<RiskInsightsData> {
|
||||
return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe(
|
||||
switchMap((response) => {
|
||||
if (!response) {
|
||||
// Return an empty report and summary if response is falsy
|
||||
return EMPTY;
|
||||
}
|
||||
if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") {
|
||||
return throwError(() => new Error("Report key not found"));
|
||||
}
|
||||
if (!response.reportData) {
|
||||
return throwError(() => new Error("Report data not found"));
|
||||
}
|
||||
if (!response.summaryData) {
|
||||
return throwError(() => new Error("Summary data not found"));
|
||||
}
|
||||
if (!response.applicationData) {
|
||||
return throwError(() => new Error("Application data not found"));
|
||||
}
|
||||
|
||||
return from(
|
||||
this.riskInsightsEncryptionService.decryptRiskInsightsReport(
|
||||
{
|
||||
organizationId,
|
||||
userId,
|
||||
},
|
||||
{
|
||||
encryptedReportData: response.reportData,
|
||||
encryptedSummaryData: response.summaryData,
|
||||
encryptedApplicationData: response.applicationData,
|
||||
},
|
||||
response.contentEncryptionKey,
|
||||
),
|
||||
).pipe(
|
||||
map((decryptedData) => {
|
||||
const newReport: RiskInsightsData = {
|
||||
id: response.id as OrganizationReportId,
|
||||
reportData: decryptedData.reportData,
|
||||
summaryData: decryptedData.summaryData,
|
||||
applicationData: decryptedData.applicationData,
|
||||
creationDate: response.creationDate,
|
||||
contentEncryptionKey: response.contentEncryptionKey,
|
||||
};
|
||||
return newReport;
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts the risk insights report data for a specific organization.
|
||||
* @param organizationId The ID of the organization.
|
||||
* @param userId The ID of the user.
|
||||
* @param report The report data to encrypt.
|
||||
* @returns A promise that resolves to an object containing the encrypted data and encryption key.
|
||||
*/
|
||||
saveRiskInsightsReport$(
|
||||
report: ApplicationHealthReportDetail[],
|
||||
summary: OrganizationReportSummary,
|
||||
applications: OrganizationReportApplication[],
|
||||
encryptionParameters: {
|
||||
organizationId: OrganizationId;
|
||||
userId: UserId;
|
||||
},
|
||||
): Observable<{ response: SaveRiskInsightsReportResponse; contentEncryptionKey: EncString }> {
|
||||
return from(
|
||||
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
||||
{
|
||||
organizationId: encryptionParameters.organizationId,
|
||||
userId: encryptionParameters.userId,
|
||||
},
|
||||
{
|
||||
reportData: report,
|
||||
summaryData: summary,
|
||||
applicationData: applications,
|
||||
},
|
||||
),
|
||||
).pipe(
|
||||
map(
|
||||
({
|
||||
encryptedReportData,
|
||||
encryptedSummaryData,
|
||||
encryptedApplicationData,
|
||||
contentEncryptionKey,
|
||||
}) => ({
|
||||
requestPayload: {
|
||||
data: {
|
||||
organizationId: encryptionParameters.organizationId,
|
||||
creationDate: new Date().toISOString(),
|
||||
reportData: encryptedReportData.toSdk(),
|
||||
summaryData: encryptedSummaryData.toSdk(),
|
||||
applicationData: encryptedApplicationData.toSdk(),
|
||||
contentEncryptionKey: contentEncryptionKey.toSdk(),
|
||||
},
|
||||
},
|
||||
// Keep the original EncString alongside the SDK payload so downstream can return the EncString type.
|
||||
contentEncryptionKey,
|
||||
}),
|
||||
),
|
||||
switchMap(({ requestPayload, contentEncryptionKey }) =>
|
||||
this.riskInsightsApiService
|
||||
.saveRiskInsightsReport$(requestPayload, encryptionParameters.organizationId)
|
||||
.pipe(
|
||||
map((response) => ({
|
||||
response,
|
||||
contentEncryptionKey,
|
||||
})),
|
||||
),
|
||||
),
|
||||
catchError((error: unknown) => {
|
||||
return EMPTY;
|
||||
}),
|
||||
map((result) => {
|
||||
if (!isSaveRiskInsightsReportResponse(result.response)) {
|
||||
throw new Error("Invalid response from API");
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private _groupCiphersByApplication(
|
||||
cipherHealthData: CipherHealthReport[],
|
||||
): Map<string, CipherHealthReport[]> {
|
||||
const applicationMap = new Map<string, CipherHealthReport[]>();
|
||||
|
||||
cipherHealthData.forEach((cipher: CipherHealthReport) => {
|
||||
cipher.applications.forEach((application) => {
|
||||
const existingApplication = applicationMap.get(application) || [];
|
||||
existingApplication.push(cipher);
|
||||
applicationMap.set(application, existingApplication);
|
||||
});
|
||||
});
|
||||
|
||||
return applicationMap;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param applications The list of application health report details to map ciphers to
|
||||
* @param organizationId
|
||||
* @returns
|
||||
*/
|
||||
getApplicationCipherMap(
|
||||
ciphers: CipherView[],
|
||||
applications: ApplicationHealthReportDetail[],
|
||||
): Map<string, CipherView[]> {
|
||||
const cipherMap = new Map<string, CipherView[]>();
|
||||
applications.forEach((app) => {
|
||||
const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id));
|
||||
cipherMap.set(app.applicationName, filteredCiphers);
|
||||
});
|
||||
return cipherMap;
|
||||
}
|
||||
|
||||
// --------------------------- Aggregation methods ---------------------------
|
||||
/**
|
||||
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
|
||||
* If the item is new, create and add the object with the flattened details
|
||||
* @param cipherHealthReport Cipher and password health info broken out into their uris
|
||||
* @returns Application health reports
|
||||
*/
|
||||
private _getApplicationHealthReport(
|
||||
application: string,
|
||||
ciphers: CipherHealthReport[],
|
||||
): ApplicationHealthReportDetail {
|
||||
let aggregatedReport: ApplicationHealthReportDetail | undefined;
|
||||
|
||||
ciphers.forEach((cipher) => {
|
||||
const isAtRisk = this._isPasswordAtRisk(cipher.healthData);
|
||||
aggregatedReport = this._aggregateReport(application, cipher, isAtRisk, aggregatedReport);
|
||||
});
|
||||
|
||||
return aggregatedReport!;
|
||||
}
|
||||
|
||||
private _aggregateReport(
|
||||
application: string,
|
||||
newCipherReport: CipherHealthReport,
|
||||
isAtRisk: boolean,
|
||||
existingReport?: ApplicationHealthReportDetail,
|
||||
): ApplicationHealthReportDetail {
|
||||
let baseReport = existingReport
|
||||
? this._updateExistingReport(existingReport, newCipherReport)
|
||||
: this._createNewReport(application, newCipherReport);
|
||||
if (isAtRisk) {
|
||||
baseReport = { ...baseReport, ...this._getAtRiskData(baseReport, newCipherReport) };
|
||||
}
|
||||
|
||||
baseReport.memberCount = baseReport.memberDetails.length;
|
||||
baseReport.atRiskMemberCount = baseReport.atRiskMemberDetails.length;
|
||||
|
||||
return baseReport;
|
||||
}
|
||||
private _createNewReport(
|
||||
application: string,
|
||||
cipherReport: CipherHealthReport,
|
||||
): ApplicationHealthReportDetail {
|
||||
return {
|
||||
applicationName: application,
|
||||
cipherIds: [cipherReport.cipher.id],
|
||||
passwordCount: 1,
|
||||
memberDetails: [...cipherReport.cipherMembers],
|
||||
memberCount: cipherReport.cipherMembers.length,
|
||||
atRiskCipherIds: [],
|
||||
atRiskMemberCount: 0,
|
||||
atRiskMemberDetails: [],
|
||||
atRiskPasswordCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private _updateExistingReport(
|
||||
existingReport: ApplicationHealthReportDetail,
|
||||
newCipherReport: CipherHealthReport,
|
||||
): ApplicationHealthReportDetail {
|
||||
return {
|
||||
...existingReport,
|
||||
passwordCount: existingReport.passwordCount + 1,
|
||||
memberDetails: getUniqueMembers(
|
||||
existingReport.memberDetails.concat(newCipherReport.cipherMembers),
|
||||
),
|
||||
cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id),
|
||||
};
|
||||
}
|
||||
|
||||
private _getAtRiskData(report: ApplicationHealthReportDetail, cipherReport: CipherHealthReport) {
|
||||
const atRiskMemberDetails = getUniqueMembers(
|
||||
report.atRiskMemberDetails.concat(cipherReport.cipherMembers),
|
||||
);
|
||||
return {
|
||||
atRiskPasswordCount: report.atRiskPasswordCount + 1,
|
||||
atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id),
|
||||
atRiskMemberDetails,
|
||||
atRiskMemberCount: atRiskMemberDetails.length,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO Move to health service
|
||||
private _isPasswordAtRisk(healthData: PasswordHealthData): boolean {
|
||||
return !!(
|
||||
healthData.exposedPasswordDetail ||
|
||||
healthData.weakPasswordDetail ||
|
||||
healthData.reusedPasswordCount > 1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
export * from "./member-cipher-details-api.service";
|
||||
export * from "./password-health.service";
|
||||
export * from "./critical-apps.service";
|
||||
export * from "./critical-apps-api.service";
|
||||
export * from "./risk-insights-api.service";
|
||||
export * from "./risk-insights-report.service";
|
||||
export * from "./risk-insights-data.service";
|
||||
export * from "./all-activities.service";
|
||||
export * from "./security-tasks-api.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 "./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";
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
export const mockMemberCipherDetailsResponse: { data: any[] } = {
|
||||
data: [
|
||||
{
|
||||
UserName: "David Brent",
|
||||
Email: "david.brent@wernhamhogg.uk",
|
||||
UsesKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
],
|
||||
},
|
||||
{
|
||||
UserName: "Tim Canterbury",
|
||||
Email: "tim.canterbury@wernhamhogg.uk",
|
||||
UsesKeyConnector: false,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
],
|
||||
},
|
||||
{
|
||||
UserName: "Gareth Keenan",
|
||||
Email: "gareth.keenan@wernhamhogg.uk",
|
||||
UsesKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm7",
|
||||
],
|
||||
},
|
||||
{
|
||||
UserName: "Dawn Tinsley",
|
||||
Email: "dawn.tinsley@wernhamhogg.uk",
|
||||
UsesKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
],
|
||||
},
|
||||
{
|
||||
UserName: "Keith Bishop",
|
||||
Email: "keith.bishop@wernhamhogg.uk",
|
||||
UsesKeyConnector: false,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
],
|
||||
},
|
||||
{
|
||||
UserName: "Chris Finch",
|
||||
Email: "chris.finch@wernhamhogg.uk",
|
||||
UsesKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
],
|
||||
},
|
||||
{
|
||||
UserName: "Chris Finch Tester",
|
||||
Email: "chris.finch@wernhamhogg.uk",
|
||||
UsesKeyConnector: true,
|
||||
cipherIds: [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,469 +0,0 @@
|
||||
import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of, throwError } from "rxjs";
|
||||
import {
|
||||
catchError,
|
||||
distinctUntilChanged,
|
||||
exhaustMap,
|
||||
filter,
|
||||
finalize,
|
||||
map,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
withLatestFrom,
|
||||
} from "rxjs/operators";
|
||||
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { ApplicationHealthReportDetailEnriched } from "../models";
|
||||
import { RiskInsightsEnrichedData } from "../models/report-data-service.types";
|
||||
import { DrawerType, DrawerDetails, ApplicationHealthReportDetail } from "../models/report-models";
|
||||
|
||||
import { CriticalAppsService } from "./critical-apps.service";
|
||||
import { RiskInsightsReportService } from "./risk-insights-report.service";
|
||||
|
||||
export class RiskInsightsDataService {
|
||||
// -------------------------- Context state --------------------------
|
||||
// Current user viewing risk insights
|
||||
private userIdSubject = new BehaviorSubject<UserId | null>(null);
|
||||
userId$ = this.userIdSubject.asObservable();
|
||||
|
||||
// Organization the user is currently viewing
|
||||
private organizationDetailsSubject = new BehaviorSubject<{
|
||||
organizationId: OrganizationId;
|
||||
organizationName: string;
|
||||
} | null>(null);
|
||||
organizationDetails$ = this.organizationDetailsSubject.asObservable();
|
||||
|
||||
// -------------------------- Data ------------------------------------
|
||||
// TODO: Remove. Will use report results
|
||||
private LEGACY_applicationsSubject = new BehaviorSubject<ApplicationHealthReportDetail[] | null>(
|
||||
null,
|
||||
);
|
||||
LEGACY_applications$ = this.LEGACY_applicationsSubject.asObservable();
|
||||
|
||||
// TODO: Remove. Will use date from report results
|
||||
private LEGACY_dataLastUpdatedSubject = new BehaviorSubject<Date | null>(null);
|
||||
dataLastUpdated$ = this.LEGACY_dataLastUpdatedSubject.asObservable();
|
||||
|
||||
// --------------------------- UI State ------------------------------------
|
||||
private isLoadingSubject = new BehaviorSubject<boolean>(false);
|
||||
isLoading$ = this.isLoadingSubject.asObservable();
|
||||
|
||||
private isRefreshingSubject = new BehaviorSubject<boolean>(false);
|
||||
isRefreshing$ = this.isRefreshingSubject.asObservable();
|
||||
|
||||
private errorSubject = new BehaviorSubject<string | null>(null);
|
||||
error$ = this.errorSubject.asObservable();
|
||||
|
||||
// ------------------------- Drawer Variables ----------------
|
||||
// Drawer variables unified into a single BehaviorSubject
|
||||
private drawerDetailsSubject = new BehaviorSubject<DrawerDetails>({
|
||||
open: false,
|
||||
invokerId: "",
|
||||
activeDrawerType: DrawerType.None,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
});
|
||||
drawerDetails$ = this.drawerDetailsSubject.asObservable();
|
||||
|
||||
// ------------------------- Report Variables ----------------
|
||||
// The last run report details
|
||||
private reportResultsSubject = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
|
||||
reportResults$ = this.reportResultsSubject.asObservable();
|
||||
// Is a report being generated
|
||||
private isRunningReportSubject = new BehaviorSubject<boolean>(false);
|
||||
isRunningReport$ = this.isRunningReportSubject.asObservable();
|
||||
|
||||
// --------------------------- Critical Application data ---------------------
|
||||
criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private criticalAppsService: CriticalAppsService,
|
||||
private organizationService: OrganizationService,
|
||||
private reportService: RiskInsightsReportService,
|
||||
) {
|
||||
// Reload report if critical applications change
|
||||
// This also handles the original report load
|
||||
this.criticalAppsService.criticalAppsList$
|
||||
.pipe(withLatestFrom(this.organizationDetails$, this.userId$))
|
||||
.subscribe({
|
||||
next: ([_criticalApps, organizationDetails, userId]) => {
|
||||
if (organizationDetails?.organizationId && userId) {
|
||||
this.fetchLastReport(organizationDetails?.organizationId, userId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Setup critical application data and summary generation for live critical application usage
|
||||
this.criticalReportResults$ = this.reportResults$.pipe(
|
||||
filter((report) => !!report),
|
||||
map((r) => {
|
||||
const criticalApplications = r.reportData.filter(
|
||||
(application) => application.isMarkedAsCritical,
|
||||
);
|
||||
const summary = this.reportService.generateApplicationsSummary(criticalApplications);
|
||||
|
||||
return {
|
||||
...r,
|
||||
summaryData: summary,
|
||||
reportData: criticalApplications,
|
||||
};
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async initializeForOrganization(organizationId: OrganizationId) {
|
||||
// Fetch current user
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
if (userId) {
|
||||
this.userIdSubject.next(userId);
|
||||
}
|
||||
|
||||
// [FIXME] getOrganizationById is now deprecated - replace with appropriate method
|
||||
// Fetch organization details
|
||||
const org = await firstValueFrom(
|
||||
this.organizationService.organizations$(userId).pipe(getOrganizationById(organizationId)),
|
||||
);
|
||||
if (org) {
|
||||
this.organizationDetailsSubject.next({
|
||||
organizationId: organizationId,
|
||||
organizationName: org.name,
|
||||
});
|
||||
}
|
||||
|
||||
// Load critical applications for organization
|
||||
await this.criticalAppsService.loadOrganizationContext(organizationId, userId);
|
||||
|
||||
// Setup new report generation
|
||||
this._runApplicationsReport().subscribe({
|
||||
next: (result) => {
|
||||
this.isRunningReportSubject.next(false);
|
||||
},
|
||||
error: () => {
|
||||
this.errorSubject.next("Failed to save report");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the applications report and updates the applicationsSubject.
|
||||
* @param organizationId The ID of the organization.
|
||||
*/
|
||||
LEGACY_fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void {
|
||||
if (isRefresh) {
|
||||
this.isRefreshingSubject.next(true);
|
||||
} else {
|
||||
this.isLoadingSubject.next(true);
|
||||
}
|
||||
this.reportService
|
||||
.LEGACY_generateApplicationsReport$(organizationId)
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.isLoadingSubject.next(false);
|
||||
this.isRefreshingSubject.next(false);
|
||||
this.LEGACY_dataLastUpdatedSubject.next(new Date());
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: (reports: ApplicationHealthReportDetail[]) => {
|
||||
this.LEGACY_applicationsSubject.next(reports);
|
||||
this.errorSubject.next(null);
|
||||
},
|
||||
error: () => {
|
||||
this.LEGACY_applicationsSubject.next([]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------- Enrichment methods -------------------------------
|
||||
/**
|
||||
* Takes the basic application health report details and enriches them to include
|
||||
* critical app status and associated ciphers.
|
||||
*
|
||||
* @param applications The list of application health report details to enrich
|
||||
* @returns The enriched application health report details with critical app status and ciphers
|
||||
*/
|
||||
enrichReportData$(
|
||||
applications: ApplicationHealthReportDetail[],
|
||||
): Observable<ApplicationHealthReportDetailEnriched[]> {
|
||||
// TODO Compare applications on report to updated critical applications
|
||||
// TODO Compare applications on report to any new applications
|
||||
return of(applications).pipe(
|
||||
withLatestFrom(this.organizationDetails$, this.criticalAppsService.criticalAppsList$),
|
||||
switchMap(async ([apps, orgDetails, criticalApps]) => {
|
||||
if (!orgDetails) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get ciphers for application
|
||||
const cipherMap = await this.reportService.getApplicationCipherMap(
|
||||
apps,
|
||||
orgDetails.organizationId,
|
||||
);
|
||||
|
||||
// Find critical apps
|
||||
const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.uri));
|
||||
|
||||
// Return enriched application data
|
||||
return apps.map((app) => ({
|
||||
...app,
|
||||
ciphers: cipherMap.get(app.applicationName) || [],
|
||||
isMarkedAsCritical: criticalApplicationNames.has(app.applicationName),
|
||||
})) as ApplicationHealthReportDetailEnriched[];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------- Drawer functions -----------------------------
|
||||
isActiveDrawerType = (drawerType: DrawerType): boolean => {
|
||||
return this.drawerDetailsSubject.value.activeDrawerType === drawerType;
|
||||
};
|
||||
|
||||
isDrawerOpenForInvoker = (applicationName: string): boolean => {
|
||||
return this.drawerDetailsSubject.value.invokerId === applicationName;
|
||||
};
|
||||
|
||||
closeDrawer = (): void => {
|
||||
this.drawerDetailsSubject.next({
|
||||
open: false,
|
||||
invokerId: "",
|
||||
activeDrawerType: DrawerType.None,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
});
|
||||
};
|
||||
|
||||
setDrawerForOrgAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
|
||||
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
|
||||
const shouldClose =
|
||||
open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId;
|
||||
|
||||
if (shouldClose) {
|
||||
this.closeDrawer();
|
||||
} else {
|
||||
const reportResults = await firstValueFrom(this.reportResults$);
|
||||
if (!reportResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const atRiskMemberDetails = this.reportService.generateAtRiskMemberList(
|
||||
reportResults.reportData,
|
||||
);
|
||||
|
||||
this.drawerDetailsSubject.next({
|
||||
open: true,
|
||||
invokerId,
|
||||
activeDrawerType: DrawerType.OrgAtRiskMembers,
|
||||
atRiskMemberDetails,
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setDrawerForAppAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
|
||||
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
|
||||
const shouldClose =
|
||||
open && activeDrawerType === DrawerType.AppAtRiskMembers && currentInvokerId === invokerId;
|
||||
|
||||
if (shouldClose) {
|
||||
this.closeDrawer();
|
||||
} else {
|
||||
const reportResults = await firstValueFrom(this.reportResults$);
|
||||
if (!reportResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const atRiskMembers = {
|
||||
members:
|
||||
reportResults.reportData.find((app) => app.applicationName === invokerId)
|
||||
?.atRiskMemberDetails ?? [],
|
||||
applicationName: invokerId,
|
||||
};
|
||||
this.drawerDetailsSubject.next({
|
||||
open: true,
|
||||
invokerId,
|
||||
activeDrawerType: DrawerType.AppAtRiskMembers,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: atRiskMembers,
|
||||
atRiskAppDetails: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setDrawerForOrgAtRiskApps = async (invokerId: string = ""): Promise<void> => {
|
||||
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
|
||||
const shouldClose =
|
||||
open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId;
|
||||
|
||||
if (shouldClose) {
|
||||
this.closeDrawer();
|
||||
} else {
|
||||
const reportResults = await firstValueFrom(this.reportResults$);
|
||||
if (!reportResults) {
|
||||
return;
|
||||
}
|
||||
const atRiskAppDetails = this.reportService.generateAtRiskApplicationList(
|
||||
reportResults.reportData,
|
||||
);
|
||||
|
||||
this.drawerDetailsSubject.next({
|
||||
open: true,
|
||||
invokerId,
|
||||
activeDrawerType: DrawerType.OrgAtRiskApps,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------- Trigger Report Generation -------------------
|
||||
/** Trigger generating a report based on the current applications */
|
||||
triggerReport(): void {
|
||||
this.isRunningReportSubject.next(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the applications report and updates the applicationsSubject.
|
||||
* @param organizationId The ID of the organization.
|
||||
*/
|
||||
fetchLastReport(organizationId: OrganizationId, userId: UserId): void {
|
||||
this.isLoadingSubject.next(true);
|
||||
|
||||
this.reportService
|
||||
.getRiskInsightsReport$(organizationId, userId)
|
||||
.pipe(
|
||||
switchMap((report) => {
|
||||
// Take fetched report data and merge with critical applications
|
||||
return this.enrichReportData$(report.reportData).pipe(
|
||||
map((enrichedReport) => ({
|
||||
report: enrichedReport,
|
||||
summary: report.summaryData,
|
||||
applications: report.applicationData,
|
||||
creationDate: report.creationDate,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
// console.error("An error occurred when fetching the last report", error);
|
||||
return EMPTY;
|
||||
}),
|
||||
finalize(() => {
|
||||
this.isLoadingSubject.next(false);
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: ({ report, summary, applications, creationDate }) => {
|
||||
this.reportResultsSubject.next({
|
||||
reportData: report,
|
||||
summaryData: summary,
|
||||
applicationData: applications,
|
||||
creationDate: creationDate,
|
||||
});
|
||||
this.errorSubject.next(null);
|
||||
this.isLoadingSubject.next(false);
|
||||
},
|
||||
error: () => {
|
||||
this.errorSubject.next("Failed to fetch report");
|
||||
this.reportResultsSubject.next(null);
|
||||
this.isLoadingSubject.next(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _runApplicationsReport() {
|
||||
return this.isRunningReport$.pipe(
|
||||
distinctUntilChanged(),
|
||||
// Only run this report if the flag for running is true
|
||||
filter((isRunning) => isRunning),
|
||||
withLatestFrom(this.organizationDetails$, this.userId$),
|
||||
exhaustMap(([_, organizationDetails, userId]) => {
|
||||
const organizationId = organizationDetails?.organizationId;
|
||||
if (!organizationId || !userId) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
// Generate the report
|
||||
return this.reportService.generateApplicationsReport$(organizationId).pipe(
|
||||
map((report) => ({
|
||||
report,
|
||||
summary: this.reportService.generateApplicationsSummary(report),
|
||||
applications: this.reportService.generateOrganizationApplications(report),
|
||||
})),
|
||||
// Enrich report with critical markings
|
||||
switchMap(({ report, summary, applications }) =>
|
||||
this.enrichReportData$(report).pipe(
|
||||
map((enrichedReport) => ({ report: enrichedReport, summary, applications })),
|
||||
),
|
||||
),
|
||||
// Load the updated data into the UI
|
||||
tap(({ report, summary, applications }) => {
|
||||
this.reportResultsSubject.next({
|
||||
reportData: report,
|
||||
summaryData: summary,
|
||||
applicationData: applications,
|
||||
creationDate: new Date(),
|
||||
});
|
||||
this.errorSubject.next(null);
|
||||
}),
|
||||
switchMap(({ report, summary, applications }) => {
|
||||
// Save the generated data
|
||||
return this.reportService.saveRiskInsightsReport$(report, summary, applications, {
|
||||
organizationId,
|
||||
userId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------ Critical application methods --------------
|
||||
|
||||
saveCriticalApplications(selectedUrls: string[]) {
|
||||
return this.organizationDetails$.pipe(
|
||||
exhaustMap((organizationDetails) => {
|
||||
if (!organizationDetails?.organizationId) {
|
||||
return EMPTY;
|
||||
}
|
||||
return this.criticalAppsService.setCriticalApps(
|
||||
organizationDetails?.organizationId,
|
||||
selectedUrls,
|
||||
);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.errorSubject.next("Failed to save critical applications");
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
removeCriticalApplication(hostname: string) {
|
||||
return this.organizationDetails$.pipe(
|
||||
exhaustMap((organizationDetails) => {
|
||||
if (!organizationDetails?.organizationId) {
|
||||
return EMPTY;
|
||||
}
|
||||
return this.criticalAppsService.dropCriticalApp(
|
||||
organizationDetails?.organizationId,
|
||||
hostname,
|
||||
);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.errorSubject.next("Failed to remove critical application");
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
import {
|
||||
catchError,
|
||||
concatMap,
|
||||
EMPTY,
|
||||
first,
|
||||
firstValueFrom,
|
||||
forkJoin,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
switchMap,
|
||||
throwError,
|
||||
zip,
|
||||
} from "rxjs";
|
||||
|
||||
import { OrganizationId, 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 {
|
||||
createNewReportData,
|
||||
flattenMemberDetails,
|
||||
getApplicationReportDetail,
|
||||
getFlattenedCipherDetails,
|
||||
getMemberDetailsFlat,
|
||||
getTrimmedCipherUris,
|
||||
getUniqueMembers,
|
||||
} from "../helpers/risk-insights-data-mappers";
|
||||
import {
|
||||
isSaveRiskInsightsReportResponse,
|
||||
SaveRiskInsightsReportResponse,
|
||||
} from "../models/api-models.types";
|
||||
import {
|
||||
LEGACY_CipherHealthReportDetail,
|
||||
LEGACY_CipherHealthReportUriDetail,
|
||||
LEGACY_MemberDetailsFlat,
|
||||
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||
} from "../models/password-health";
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
OrganizationReportSummary,
|
||||
AtRiskApplicationDetail,
|
||||
AtRiskMemberDetail,
|
||||
CipherHealthReport,
|
||||
MemberDetails,
|
||||
PasswordHealthData,
|
||||
OrganizationReportApplication,
|
||||
RiskInsightsData,
|
||||
} from "../models/report-models";
|
||||
|
||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
||||
import { PasswordHealthService } from "./password-health.service";
|
||||
import { RiskInsightsApiService } from "./risk-insights-api.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 from raw cipher health data.
|
||||
* Can be used in the Raw Data diagnostic tab (just exclude the members in the view)
|
||||
* and can be used in the raw data + members tab when including the members in the view
|
||||
* @param organizationId
|
||||
* @returns Cipher health report data with members and trimmed uris
|
||||
*/
|
||||
LEGACY_generateRawDataReport$(
|
||||
organizationId: OrganizationId,
|
||||
): Observable<LEGACY_CipherHealthReportDetail[]> {
|
||||
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId));
|
||||
const memberCiphers$ = from(
|
||||
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
|
||||
);
|
||||
|
||||
const results$ = zip(allCiphers$, memberCiphers$).pipe(
|
||||
map(([allCiphers, memberCiphers]) => {
|
||||
const details: LEGACY_MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
|
||||
dtl.cipherIds.map((c) => getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c)),
|
||||
);
|
||||
return [allCiphers, details] as const;
|
||||
}),
|
||||
concatMap(([ciphers, flattenedDetails]) =>
|
||||
this.LEGACY_getCipherDetails(ciphers, flattenedDetails),
|
||||
),
|
||||
first(),
|
||||
);
|
||||
|
||||
return results$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report data for raw cipher health broken out into the uris
|
||||
* Can be used in the raw data + members + uri diagnostic report
|
||||
* @param organizationId Id of the organization
|
||||
* @returns Cipher health report data flattened to the uris
|
||||
*/
|
||||
generateRawDataUriReport$(
|
||||
organizationId: OrganizationId,
|
||||
): Observable<LEGACY_CipherHealthReportUriDetail[]> {
|
||||
const cipherHealthDetails$ = this.LEGACY_generateRawDataReport$(organizationId);
|
||||
const results$ = cipherHealthDetails$.pipe(
|
||||
map((healthDetails) => this.getCipherUriDetails(healthDetails)),
|
||||
first(),
|
||||
);
|
||||
|
||||
return results$;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @returns The all applications health report data
|
||||
*/
|
||||
LEGACY_generateApplicationsReport$(
|
||||
organizationId: OrganizationId,
|
||||
): Observable<ApplicationHealthReportDetail[]> {
|
||||
const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId);
|
||||
const results$ = cipherHealthUriReport$.pipe(
|
||||
map((uriDetails) => this.LEGACY_getApplicationHealthReport(uriDetails)),
|
||||
first(),
|
||||
);
|
||||
|
||||
return results$;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @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)));
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the summary from the application health report. Returns total members and applications as well
|
||||
* as the total at risk members and at risk applications
|
||||
* @param reports The previously calculated application health report data
|
||||
* @returns A summary object containing report totals
|
||||
*/
|
||||
generateApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary {
|
||||
const totalMembers = reports.flatMap((x) => x.memberDetails);
|
||||
const uniqueMembers = getUniqueMembers(totalMembers);
|
||||
|
||||
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
|
||||
const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers);
|
||||
|
||||
// TODO: Replace with actual new applications detection logic (PM-26185)
|
||||
const dummyNewApplications = [
|
||||
"github.com",
|
||||
"google.com",
|
||||
"stackoverflow.com",
|
||||
"gitlab.com",
|
||||
"bitbucket.org",
|
||||
"npmjs.com",
|
||||
"docker.com",
|
||||
"aws.amazon.com",
|
||||
"azure.microsoft.com",
|
||||
"jenkins.io",
|
||||
"terraform.io",
|
||||
"kubernetes.io",
|
||||
"atlassian.net",
|
||||
];
|
||||
|
||||
return {
|
||||
totalMemberCount: uniqueMembers.length,
|
||||
totalAtRiskMemberCount: uniqueAtRiskMembers.length,
|
||||
totalApplicationCount: reports.length,
|
||||
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
|
||||
totalCriticalMemberCount: 0,
|
||||
totalCriticalAtRiskMemberCount: 0,
|
||||
totalCriticalApplicationCount: 0,
|
||||
totalCriticalAtRiskApplicationCount: 0,
|
||||
newApplications: dummyNewApplications,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a snapshot of applications and related data associated to this report
|
||||
*
|
||||
* @param reports
|
||||
* @returns A list of applications with a critical marking flag
|
||||
*/
|
||||
generateOrganizationApplications(
|
||||
reports: ApplicationHealthReportDetail[],
|
||||
): OrganizationReportApplication[] {
|
||||
return reports.map((report) => ({
|
||||
applicationName: report.applicationName,
|
||||
isCritical: false,
|
||||
}));
|
||||
}
|
||||
|
||||
async identifyCiphers(
|
||||
data: ApplicationHealthReportDetail[],
|
||||
organizationId: OrganizationId,
|
||||
): Promise<LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[]> {
|
||||
const cipherViews = await this.cipherService.getAllFromApiForOrganization(organizationId);
|
||||
|
||||
const dataWithCiphers = data.map(
|
||||
(app, index) =>
|
||||
({
|
||||
...app,
|
||||
ciphers: cipherViews.filter((c) => app.cipherIds.some((a) => a === c.id)),
|
||||
}) as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||
);
|
||||
return dataWithCiphers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the risk insights report for a specific organization and user.
|
||||
*
|
||||
* @param organizationId
|
||||
* @param userId
|
||||
* @returns An observable that emits the decrypted risk insights report data.
|
||||
*/
|
||||
getRiskInsightsReport$(
|
||||
organizationId: OrganizationId,
|
||||
userId: UserId,
|
||||
): Observable<RiskInsightsData> {
|
||||
return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe(
|
||||
switchMap((response) => {
|
||||
if (!response) {
|
||||
// Return an empty report and summary if response is falsy
|
||||
return of<RiskInsightsData>(createNewReportData());
|
||||
}
|
||||
if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") {
|
||||
return throwError(() => new Error("Report key not found"));
|
||||
}
|
||||
if (!response.reportData) {
|
||||
return throwError(() => new Error("Report data not found"));
|
||||
}
|
||||
if (!response.summaryData) {
|
||||
return throwError(() => new Error("Summary data not found"));
|
||||
}
|
||||
if (!response.applicationData) {
|
||||
return throwError(() => new Error("Application data not found"));
|
||||
}
|
||||
|
||||
return from(
|
||||
this.riskInsightsEncryptionService.decryptRiskInsightsReport(
|
||||
{
|
||||
organizationId,
|
||||
userId,
|
||||
},
|
||||
{
|
||||
encryptedReportData: response.reportData,
|
||||
encryptedSummaryData: response.summaryData,
|
||||
encryptedApplicationData: response.applicationData,
|
||||
},
|
||||
response.contentEncryptionKey,
|
||||
),
|
||||
).pipe(
|
||||
map((decryptedData) => ({
|
||||
reportData: decryptedData.reportData,
|
||||
summaryData: decryptedData.summaryData,
|
||||
applicationData: decryptedData.applicationData,
|
||||
creationDate: response.creationDate,
|
||||
})),
|
||||
catchError((error: unknown) => {
|
||||
// TODO Handle errors appropriately
|
||||
// console.error("An error occurred when decrypting report", error);
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
// console.error("An error occurred when fetching the last report", error);
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts the risk insights report data for a specific organization.
|
||||
* @param organizationId The ID of the organization.
|
||||
* @param userId The ID of the user.
|
||||
* @param report The report data to encrypt.
|
||||
* @returns A promise that resolves to an object containing the encrypted data and encryption key.
|
||||
*/
|
||||
saveRiskInsightsReport$(
|
||||
report: ApplicationHealthReportDetail[],
|
||||
summary: OrganizationReportSummary,
|
||||
applications: OrganizationReportApplication[],
|
||||
encryptionParameters: {
|
||||
organizationId: OrganizationId;
|
||||
userId: UserId;
|
||||
},
|
||||
): Observable<SaveRiskInsightsReportResponse> {
|
||||
return from(
|
||||
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
||||
{
|
||||
organizationId: encryptionParameters.organizationId,
|
||||
userId: encryptionParameters.userId,
|
||||
},
|
||||
{
|
||||
reportData: report,
|
||||
summaryData: summary,
|
||||
applicationData: applications,
|
||||
},
|
||||
),
|
||||
).pipe(
|
||||
map(
|
||||
({
|
||||
encryptedReportData,
|
||||
encryptedSummaryData,
|
||||
encryptedApplicationData,
|
||||
contentEncryptionKey,
|
||||
}) => ({
|
||||
data: {
|
||||
organizationId: encryptionParameters.organizationId,
|
||||
creationDate: new Date().toISOString(),
|
||||
reportData: encryptedReportData.toSdk(),
|
||||
summaryData: encryptedSummaryData.toSdk(),
|
||||
applicationData: encryptedApplicationData.toSdk(),
|
||||
contentEncryptionKey: contentEncryptionKey.toSdk(),
|
||||
},
|
||||
}),
|
||||
),
|
||||
switchMap((encryptedReport) =>
|
||||
this.riskInsightsApiService.saveRiskInsightsReport$(
|
||||
encryptedReport,
|
||||
encryptionParameters.organizationId,
|
||||
),
|
||||
),
|
||||
catchError((error: unknown) => {
|
||||
return EMPTY;
|
||||
}),
|
||||
map((response) => {
|
||||
if (!isSaveRiskInsightsReportResponse(response)) {
|
||||
throw new Error("Invalid response from API");
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 async LEGACY_getCipherDetails(
|
||||
ciphers: CipherView[],
|
||||
memberDetails: LEGACY_MemberDetailsFlat[],
|
||||
): Promise<LEGACY_CipherHealthReportDetail[]> {
|
||||
const cipherHealthReports: LEGACY_CipherHealthReportDetail[] = [];
|
||||
const passwordUseMap = new Map<string, number>();
|
||||
const exposedDetails = await firstValueFrom(
|
||||
this.passwordHealthService.auditPasswordLeaks$(ciphers),
|
||||
);
|
||||
for (const cipher of ciphers) {
|
||||
if (this.passwordHealthService.isValidCipher(cipher)) {
|
||||
const weakPassword = this.passwordHealthService.findWeakPasswordDetails(cipher);
|
||||
// Looping over all ciphers needs to happen first to determine reused passwords over all ciphers.
|
||||
// Store in the set and evaluate later
|
||||
if (passwordUseMap.has(cipher.login.password!)) {
|
||||
passwordUseMap.set(
|
||||
cipher.login.password!,
|
||||
(passwordUseMap.get(cipher.login.password!) || 0) + 1,
|
||||
);
|
||||
} else {
|
||||
passwordUseMap.set(cipher.login.password!, 1);
|
||||
}
|
||||
|
||||
const exposedPassword = exposedDetails.find((x) => x?.cipherId === cipher.id);
|
||||
|
||||
// Get the cipher members
|
||||
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
|
||||
|
||||
// Trim uris to host name and create the cipher health report
|
||||
const cipherTrimmedUris = getTrimmedCipherUris(cipher);
|
||||
const cipherHealth = {
|
||||
...cipher,
|
||||
weakPasswordDetail: weakPassword,
|
||||
exposedPasswordDetail: exposedPassword,
|
||||
cipherMembers: cipherMembers,
|
||||
trimmedUris: cipherTrimmedUris,
|
||||
} as LEGACY_CipherHealthReportDetail;
|
||||
|
||||
cipherHealthReports.push(cipherHealth);
|
||||
}
|
||||
}
|
||||
|
||||
// loop for reused passwords
|
||||
cipherHealthReports.forEach((detail) => {
|
||||
detail.reusedPasswordCount = passwordUseMap.get(detail.login.password!) ?? 0;
|
||||
});
|
||||
return cipherHealthReports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the cipher to trimmed uris. Used for the raw data + uri
|
||||
* @param cipherHealthReport Cipher health report with uris and members
|
||||
* @returns Flattened cipher health details to uri
|
||||
*/
|
||||
private getCipherUriDetails(
|
||||
cipherHealthReport: LEGACY_CipherHealthReportDetail[],
|
||||
): LEGACY_CipherHealthReportUriDetail[] {
|
||||
return cipherHealthReport.flatMap((rpt) =>
|
||||
rpt.trimmedUris.map((u) => getFlattenedCipherDetails(rpt, u)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
|
||||
* If the item is new, create and add the object with the flattened details
|
||||
* @param cipherHealthUriReport Cipher and password health info broken out into their uris
|
||||
* @returns Application health reports
|
||||
*/
|
||||
private LEGACY_getApplicationHealthReport(
|
||||
cipherHealthUriReport: LEGACY_CipherHealthReportUriDetail[],
|
||||
): ApplicationHealthReportDetail[] {
|
||||
const appReports: ApplicationHealthReportDetail[] = [];
|
||||
cipherHealthUriReport.forEach((uri) => {
|
||||
const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri);
|
||||
|
||||
let atRisk: boolean = false;
|
||||
if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) {
|
||||
atRisk = true;
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
appReports.push(getApplicationReportDetail(uri, atRisk));
|
||||
} else {
|
||||
appReports[index] = getApplicationReportDetail(uri, atRisk, appReports[index]);
|
||||
}
|
||||
});
|
||||
return appReports;
|
||||
}
|
||||
|
||||
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);
|
||||
applicationMap.set(application, existingApplication);
|
||||
});
|
||||
});
|
||||
|
||||
return applicationMap;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param applications The list of application health report details to map ciphers to
|
||||
* @param organizationId
|
||||
* @returns
|
||||
*/
|
||||
async getApplicationCipherMap(
|
||||
applications: ApplicationHealthReportDetail[],
|
||||
organizationId: OrganizationId,
|
||||
): Promise<Map<string, CipherView[]>> {
|
||||
// [FIXME] CipherData
|
||||
// This call is made multiple times. We can optimize this
|
||||
// by loading the ciphers once via a load method to avoid multiple API calls
|
||||
// for the same organization
|
||||
const allCiphers = await this.cipherService.getAllFromApiForOrganization(organizationId);
|
||||
const cipherMap = new Map<string, CipherView[]>();
|
||||
|
||||
applications.forEach((app) => {
|
||||
const filteredCiphers = allCiphers.filter((c) => app.cipherIds.includes(c.id));
|
||||
cipherMap.set(app.applicationName, filteredCiphers);
|
||||
});
|
||||
return cipherMap;
|
||||
}
|
||||
|
||||
// --------------------------- Aggregation methods ---------------------------
|
||||
/**
|
||||
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
|
||||
* If the item is new, create and add the object with the flattened details
|
||||
* @param cipherHealthReport Cipher and password health info broken out into their uris
|
||||
* @returns Application health reports
|
||||
*/
|
||||
private _getApplicationHealthReport(
|
||||
application: string,
|
||||
ciphers: CipherHealthReport[],
|
||||
): ApplicationHealthReportDetail {
|
||||
let aggregatedReport: ApplicationHealthReportDetail | undefined;
|
||||
|
||||
ciphers.forEach((cipher) => {
|
||||
const isAtRisk = this._isPasswordAtRisk(cipher.healthData);
|
||||
aggregatedReport = this._aggregateReport(application, cipher, isAtRisk, aggregatedReport);
|
||||
});
|
||||
|
||||
return aggregatedReport!;
|
||||
}
|
||||
|
||||
private _aggregateReport(
|
||||
application: string,
|
||||
newCipherReport: CipherHealthReport,
|
||||
isAtRisk: boolean,
|
||||
existingReport?: ApplicationHealthReportDetail,
|
||||
): ApplicationHealthReportDetail {
|
||||
let baseReport = existingReport
|
||||
? this._updateExistingReport(existingReport, newCipherReport)
|
||||
: this._createNewReport(application, newCipherReport);
|
||||
if (isAtRisk) {
|
||||
baseReport = { ...baseReport, ...this._getAtRiskData(baseReport, newCipherReport) };
|
||||
}
|
||||
|
||||
baseReport.memberCount = baseReport.memberDetails.length;
|
||||
baseReport.atRiskMemberCount = baseReport.atRiskMemberDetails.length;
|
||||
|
||||
return baseReport;
|
||||
}
|
||||
private _createNewReport(
|
||||
application: string,
|
||||
cipherReport: CipherHealthReport,
|
||||
): ApplicationHealthReportDetail {
|
||||
return {
|
||||
applicationName: application,
|
||||
cipherIds: [cipherReport.cipher.id],
|
||||
passwordCount: 1,
|
||||
memberDetails: [...cipherReport.cipherMembers],
|
||||
memberCount: cipherReport.cipherMembers.length,
|
||||
atRiskCipherIds: [],
|
||||
atRiskMemberCount: 0,
|
||||
atRiskMemberDetails: [],
|
||||
atRiskPasswordCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private _updateExistingReport(
|
||||
existingReport: ApplicationHealthReportDetail,
|
||||
newCipherReport: CipherHealthReport,
|
||||
): ApplicationHealthReportDetail {
|
||||
return {
|
||||
...existingReport,
|
||||
passwordCount: existingReport.passwordCount + 1,
|
||||
memberDetails: getUniqueMembers(
|
||||
existingReport.memberDetails.concat(newCipherReport.cipherMembers),
|
||||
),
|
||||
cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id),
|
||||
};
|
||||
}
|
||||
|
||||
private _getAtRiskData(report: ApplicationHealthReportDetail, cipherReport: CipherHealthReport) {
|
||||
const atRiskMemberDetails = getUniqueMembers(
|
||||
report.atRiskMemberDetails.concat(cipherReport.cipherMembers),
|
||||
);
|
||||
return {
|
||||
atRiskPasswordCount: report.atRiskPasswordCount + 1,
|
||||
atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id),
|
||||
atRiskMemberDetails,
|
||||
atRiskMemberCount: atRiskMemberDetails.length,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO Move to health service
|
||||
private _isPasswordAtRisk(healthData: PasswordHealthData): boolean {
|
||||
return !!(
|
||||
healthData.exposedPasswordDetail ||
|
||||
healthData.weakPasswordDetail ||
|
||||
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;
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApplicationHealthReportDetailEnriched } from "../models";
|
||||
import { OrganizationReportSummary } from "../models/report-models";
|
||||
import { ApplicationHealthReportDetailEnriched } from "../../models";
|
||||
import { OrganizationReportSummary } from "../../models/report-models";
|
||||
|
||||
import { RiskInsightsDataService } from "./risk-insights-data.service";
|
||||
|
||||
@@ -40,7 +40,7 @@ export class AllActivitiesService {
|
||||
|
||||
constructor(private dataService: RiskInsightsDataService) {
|
||||
// All application summary changes
|
||||
this.dataService.reportResults$.subscribe((report) => {
|
||||
this.dataService.enrichedReportData$.subscribe((report) => {
|
||||
if (report) {
|
||||
this.setAllAppsReportSummary(report.summaryData);
|
||||
this.setAllAppsReportDetails(report.reportData);
|
||||
@@ -0,0 +1,186 @@
|
||||
import { BehaviorSubject, firstValueFrom, Observable, of, Subject } from "rxjs";
|
||||
import { distinctUntilChanged, map } from "rxjs/operators";
|
||||
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers";
|
||||
import { ReportState, DrawerDetails, DrawerType, RiskInsightsEnrichedData } from "../../models";
|
||||
import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service";
|
||||
|
||||
export class RiskInsightsDataService {
|
||||
private _destroy$ = new Subject<void>();
|
||||
|
||||
// -------------------------- Context state --------------------------
|
||||
// Organization the user is currently viewing
|
||||
readonly organizationDetails$: Observable<{
|
||||
organizationId: OrganizationId;
|
||||
organizationName: string;
|
||||
} | null> = of(null);
|
||||
|
||||
// --------------------------- UI State ------------------------------------
|
||||
private errorSubject = new BehaviorSubject<string | null>(null);
|
||||
error$ = this.errorSubject.asObservable();
|
||||
|
||||
// -------------------------- Orchestrator-driven state -------------
|
||||
// The full report state (for internal facade use or complex components)
|
||||
private readonly reportState$: Observable<ReportState>;
|
||||
readonly isLoading$: Observable<boolean> = of(false);
|
||||
readonly enrichedReportData$: Observable<RiskInsightsEnrichedData | null> = of(null);
|
||||
readonly isGeneratingReport$: Observable<boolean> = of(false);
|
||||
readonly criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
|
||||
|
||||
// ------------------------- Drawer Variables ---------------------
|
||||
// Drawer variables unified into a single BehaviorSubject
|
||||
private drawerDetailsSubject = new BehaviorSubject<DrawerDetails>({
|
||||
open: false,
|
||||
invokerId: "",
|
||||
activeDrawerType: DrawerType.None,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
});
|
||||
drawerDetails$ = this.drawerDetailsSubject.asObservable();
|
||||
|
||||
// --------------------------- Critical Application data ---------------------
|
||||
constructor(private orchestrator: RiskInsightsOrchestratorService) {
|
||||
this.reportState$ = this.orchestrator.rawReportData$;
|
||||
this.isGeneratingReport$ = this.orchestrator.generatingReport$;
|
||||
this.organizationDetails$ = this.orchestrator.organizationDetails$;
|
||||
this.enrichedReportData$ = this.orchestrator.enrichedReportData$;
|
||||
this.criticalReportResults$ = this.orchestrator.criticalReportResults$;
|
||||
|
||||
// Expose the loading state
|
||||
this.isLoading$ = this.reportState$.pipe(
|
||||
map((state) => state.loading),
|
||||
distinctUntilChanged(), // Prevent unnecessary component re-renders
|
||||
);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this._destroy$.next();
|
||||
this._destroy$.complete();
|
||||
}
|
||||
|
||||
// ----- UI-triggered methods (delegate to orchestrator) -----
|
||||
initializeForOrganization(organizationId: OrganizationId) {
|
||||
this.orchestrator.initializeForOrganization(organizationId);
|
||||
}
|
||||
|
||||
triggerReport(): void {
|
||||
this.orchestrator.generateReport();
|
||||
}
|
||||
|
||||
fetchReport(): void {
|
||||
this.orchestrator.fetchReport();
|
||||
}
|
||||
|
||||
// ------------------------- Drawer functions -----------------------------
|
||||
isActiveDrawerType = (drawerType: DrawerType): boolean => {
|
||||
return this.drawerDetailsSubject.value.activeDrawerType === drawerType;
|
||||
};
|
||||
|
||||
isDrawerOpenForInvoker = (applicationName: string): boolean => {
|
||||
return this.drawerDetailsSubject.value.invokerId === applicationName;
|
||||
};
|
||||
|
||||
closeDrawer = (): void => {
|
||||
this.drawerDetailsSubject.next({
|
||||
open: false,
|
||||
invokerId: "",
|
||||
activeDrawerType: DrawerType.None,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
});
|
||||
};
|
||||
|
||||
setDrawerForOrgAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
|
||||
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
|
||||
const shouldClose =
|
||||
open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId;
|
||||
|
||||
if (shouldClose) {
|
||||
this.closeDrawer();
|
||||
} else {
|
||||
const reportResults = await firstValueFrom(this.enrichedReportData$);
|
||||
if (!reportResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const atRiskMemberDetails = getAtRiskMemberList(reportResults.reportData);
|
||||
|
||||
this.drawerDetailsSubject.next({
|
||||
open: true,
|
||||
invokerId,
|
||||
activeDrawerType: DrawerType.OrgAtRiskMembers,
|
||||
atRiskMemberDetails,
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setDrawerForAppAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
|
||||
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
|
||||
const shouldClose =
|
||||
open && activeDrawerType === DrawerType.AppAtRiskMembers && currentInvokerId === invokerId;
|
||||
|
||||
if (shouldClose) {
|
||||
this.closeDrawer();
|
||||
} else {
|
||||
const reportResults = await firstValueFrom(this.enrichedReportData$);
|
||||
if (!reportResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const atRiskMembers = {
|
||||
members:
|
||||
reportResults.reportData.find((app) => app.applicationName === invokerId)
|
||||
?.atRiskMemberDetails ?? [],
|
||||
applicationName: invokerId,
|
||||
};
|
||||
this.drawerDetailsSubject.next({
|
||||
open: true,
|
||||
invokerId,
|
||||
activeDrawerType: DrawerType.AppAtRiskMembers,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: atRiskMembers,
|
||||
atRiskAppDetails: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setDrawerForOrgAtRiskApps = async (invokerId: string = ""): Promise<void> => {
|
||||
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
|
||||
const shouldClose =
|
||||
open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId;
|
||||
|
||||
if (shouldClose) {
|
||||
this.closeDrawer();
|
||||
} else {
|
||||
const reportResults = await firstValueFrom(this.enrichedReportData$);
|
||||
if (!reportResults) {
|
||||
return;
|
||||
}
|
||||
const atRiskAppDetails = getAtRiskApplicationList(reportResults.reportData);
|
||||
|
||||
this.drawerDetailsSubject.next({
|
||||
open: true,
|
||||
invokerId,
|
||||
activeDrawerType: DrawerType.OrgAtRiskApps,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------------------ Critical application methods --------------
|
||||
saveCriticalApplications(selectedUrls: string[]) {
|
||||
return this.orchestrator.saveCriticalApplications$(selectedUrls);
|
||||
}
|
||||
|
||||
removeCriticalApplication(hostname: string) {
|
||||
return this.orchestrator.removeCriticalApplication$(hostname);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
RiskInsightsReportService,
|
||||
SecurityTasksApiService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
|
||||
import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service";
|
||||
import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service";
|
||||
import { RiskInsightsOrchestratorService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
@@ -24,6 +25,7 @@ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/pass
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service";
|
||||
|
||||
@@ -52,28 +54,31 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.
|
||||
safeProvider({
|
||||
provide: RiskInsightsReportService,
|
||||
useClass: RiskInsightsReportService,
|
||||
deps: [RiskInsightsApiService, RiskInsightsEncryptionService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: RiskInsightsOrchestratorService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
CipherService,
|
||||
CriticalAppsService,
|
||||
LogService,
|
||||
MemberCipherDetailsApiService,
|
||||
OrganizationService,
|
||||
PasswordHealthService,
|
||||
RiskInsightsApiService,
|
||||
RiskInsightsReportService,
|
||||
RiskInsightsEncryptionService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: RiskInsightsDataService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
CriticalAppsService,
|
||||
OrganizationService,
|
||||
RiskInsightsReportService,
|
||||
],
|
||||
deps: [RiskInsightsOrchestratorService],
|
||||
}),
|
||||
{
|
||||
safeProvider({
|
||||
provide: RiskInsightsEncryptionService,
|
||||
useClass: RiskInsightsEncryptionService,
|
||||
deps: [KeyService, EncryptService, KeyGenerationService],
|
||||
},
|
||||
deps: [KeyService, EncryptService, KeyGenerationService, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CriticalAppsService,
|
||||
useClass: CriticalAppsService,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { Component, OnInit, ChangeDetectionStrategy } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { Subject, switchMap, takeUntil, of, BehaviorSubject, combineLatest } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
AllActivitiesService,
|
||||
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||
ApplicationHealthReportDetailEnriched,
|
||||
SecurityTasksApiService,
|
||||
TaskMetrics,
|
||||
OrganizationReportSummary,
|
||||
@@ -14,17 +14,12 @@ import {
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components";
|
||||
|
||||
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
|
||||
import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service";
|
||||
|
||||
export const RenderMode = {
|
||||
noCriticalApps: "noCriticalApps",
|
||||
criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks",
|
||||
criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks",
|
||||
} as const;
|
||||
export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode];
|
||||
import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service";
|
||||
import { RenderMode } from "../../models/activity.models";
|
||||
import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service";
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "dirt-password-change-metric",
|
||||
imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule],
|
||||
templateUrl: "./password-change-metric.component.html",
|
||||
@@ -34,8 +29,7 @@ export class PasswordChangeMetricComponent implements OnInit {
|
||||
protected taskMetrics$ = new BehaviorSubject<TaskMetrics>({ totalTasks: 0, completedTasks: 0 });
|
||||
private completedTasks: number = 0;
|
||||
private totalTasks: number = 0;
|
||||
private allApplicationsDetails: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[] =
|
||||
[];
|
||||
private allApplicationsDetails: ApplicationHealthReportDetailEnriched[] = [];
|
||||
|
||||
atRiskAppsCount: number = 0;
|
||||
atRiskPasswordsCount: number = 0;
|
||||
@@ -43,6 +37,13 @@ export class PasswordChangeMetricComponent implements OnInit {
|
||||
private destroyRef = new Subject<void>();
|
||||
renderMode: RenderMode = "noCriticalApps";
|
||||
|
||||
constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private securityTasksApiService: SecurityTasksApiService,
|
||||
private allActivitiesService: AllActivitiesService,
|
||||
protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$])
|
||||
.pipe(
|
||||
@@ -83,13 +84,6 @@ export class PasswordChangeMetricComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private securityTasksApiService: SecurityTasksApiService,
|
||||
private allActivitiesService: AllActivitiesService,
|
||||
protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
|
||||
) {}
|
||||
|
||||
private determineRenderMode(
|
||||
summary: OrganizationReportSummary,
|
||||
taskMetrics: TaskMetrics,
|
||||
@@ -11,16 +11,16 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { ToastService, DialogService } from "@bitwarden/components";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { RiskInsightsTabType } from "../models/risk-insights.models";
|
||||
import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component";
|
||||
|
||||
import { ActivityCardComponent } from "./activity-card.component";
|
||||
import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component";
|
||||
import { NewApplicationsDialogComponent } from "./new-applications-dialog.component";
|
||||
import { ApplicationsLoadingComponent } from "./risk-insights-loading.component";
|
||||
import { RiskInsightsTabType } from "./risk-insights.component";
|
||||
|
||||
@Component({
|
||||
selector: "dirt-all-activity",
|
||||
@@ -43,6 +43,15 @@ export class AllActivityComponent implements OnInit {
|
||||
|
||||
destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
protected activatedRoute: ActivatedRoute,
|
||||
protected allActivitiesService: AllActivitiesService,
|
||||
protected dataService: RiskInsightsDataService,
|
||||
private dialogService: DialogService,
|
||||
protected organizationService: OrganizationService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId");
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
@@ -71,17 +80,6 @@ export class AllActivityComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected activatedRoute: ActivatedRoute,
|
||||
private accountService: AccountService,
|
||||
protected organizationService: OrganizationService,
|
||||
protected dataService: RiskInsightsDataService,
|
||||
protected allActivitiesService: AllActivitiesService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
|
||||
get RiskInsightsTabType() {
|
||||
return RiskInsightsTabType;
|
||||
}
|
||||
@@ -25,8 +25,8 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
|
||||
import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.component";
|
||||
import { ApplicationsLoadingComponent } from "./risk-insights-loading.component";
|
||||
import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component";
|
||||
import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component";
|
||||
|
||||
@Component({
|
||||
selector: "dirt-all-applications",
|
||||
@@ -67,7 +67,7 @@ export class AllApplicationsComponent implements OnInit {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.dataService.reportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (report) => {
|
||||
this.applicationSummary = report?.summaryData ?? createNewSummaryData();
|
||||
this.dataSource.data = report?.reportData ?? [];
|
||||
@@ -23,11 +23,10 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
|
||||
import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service";
|
||||
|
||||
import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.component";
|
||||
import { RiskInsightsTabType } from "./risk-insights.component";
|
||||
import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service";
|
||||
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
|
||||
import { RiskInsightsTabType } from "../models/risk-insights.models";
|
||||
import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component";
|
||||
import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service";
|
||||
|
||||
@Component({
|
||||
selector: "dirt-critical-applications",
|
||||
@@ -0,0 +1,7 @@
|
||||
export const RenderMode = {
|
||||
noCriticalApps: "noCriticalApps",
|
||||
criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks",
|
||||
criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks",
|
||||
} as const;
|
||||
|
||||
export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode];
|
||||
@@ -0,0 +1,8 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum RiskInsightsTabType {
|
||||
AllActivity = 0,
|
||||
AllApps = 1,
|
||||
CriticalApps = 2,
|
||||
NotifiedMembers = 3,
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<!-- <bit-table [dataSource]="dataSource"> -->
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell>{{ "member" | i18n }}</th>
|
||||
<th bitCell>{{ "atRiskPasswords" | i18n }}</th>
|
||||
<th bitCell>{{ "totalPasswords" | i18n }}</th>
|
||||
<th bitCell>{{ "atRiskApplications" | i18n }}</th>
|
||||
<th bitCell>{{ "totalApplications" | i18n }}</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<!-- </bit-table> -->
|
||||
@@ -1,18 +0,0 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { TableDataSource, TableModule } from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
selector: "tools-notified-members-table",
|
||||
templateUrl: "./notified-members-table.component.html",
|
||||
imports: [CommonModule, JslibModule, TableModule],
|
||||
})
|
||||
export class NotifiedMembersTableComponent {
|
||||
dataSource = new TableDataSource<any>();
|
||||
|
||||
constructor() {
|
||||
this.dataSource.data = [];
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
} @else {
|
||||
<span class="tw-mx-4">{{ "noReportRan" | i18n }}</span>
|
||||
}
|
||||
@let isRunningReport = dataService.isRunningReport$ | async;
|
||||
@let isRunningReport = dataService.isGeneratingReport$ | async;
|
||||
<span class="tw-flex tw-justify-center">
|
||||
<button
|
||||
*ngIf="!isRunningReport"
|
||||
@@ -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>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, OnInit, inject } from "@angular/core";
|
||||
import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { EMPTY } from "rxjs";
|
||||
import { map, switchMap } from "rxjs/operators";
|
||||
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";
|
||||
@@ -21,18 +23,10 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
|
||||
import { AllActivityComponent } from "./all-activity.component";
|
||||
import { AllApplicationsComponent } from "./all-applications.component";
|
||||
import { CriticalApplicationsComponent } from "./critical-applications.component";
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum RiskInsightsTabType {
|
||||
AllActivity = 0,
|
||||
AllApps = 1,
|
||||
CriticalApps = 2,
|
||||
NotifiedMembers = 3,
|
||||
}
|
||||
import { AllActivityComponent } from "./activity/all-activity.component";
|
||||
import { AllApplicationsComponent } from "./all-applications/all-applications.component";
|
||||
import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component";
|
||||
import { RiskInsightsTabType } from "./models/risk-insights.models";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./risk-insights.component.html",
|
||||
@@ -51,7 +45,7 @@ export enum RiskInsightsTabType {
|
||||
AllActivityComponent,
|
||||
],
|
||||
})
|
||||
export class RiskInsightsComponent implements OnInit {
|
||||
export class RiskInsightsComponent implements OnInit, OnDestroy {
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private _isDrawerOpen: boolean = false;
|
||||
|
||||
@@ -65,7 +59,6 @@ export class RiskInsightsComponent implements OnInit {
|
||||
private organizationId: OrganizationId = "" as OrganizationId;
|
||||
|
||||
dataLastUpdated: Date | null = null;
|
||||
refetching: boolean = false;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -91,11 +84,10 @@ export class RiskInsightsComponent implements OnInit {
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map((params) => params.get("organizationId")),
|
||||
switchMap(async (orgId) => {
|
||||
tap((orgId) => {
|
||||
if (orgId) {
|
||||
// Initialize Data Service
|
||||
await this.dataService.initializeForOrganization(orgId as OrganizationId);
|
||||
|
||||
this.dataService.initializeForOrganization(orgId as OrganizationId);
|
||||
this.organizationId = orgId as OrganizationId;
|
||||
} else {
|
||||
return EMPTY;
|
||||
@@ -105,7 +97,7 @@ export class RiskInsightsComponent implements OnInit {
|
||||
.subscribe();
|
||||
|
||||
// Subscribe to report result details
|
||||
this.dataService.reportResults$
|
||||
this.dataService.enrichedReportData$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((report) => {
|
||||
this.appsCount = report?.reportData.length ?? 0;
|
||||
@@ -119,15 +111,16 @@ export class RiskInsightsComponent implements OnInit {
|
||||
this._isDrawerOpen = details.open;
|
||||
});
|
||||
}
|
||||
runReport = () => {
|
||||
this.dataService.triggerReport();
|
||||
};
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.dataService.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mock } from "jest-mock-extended";
|
||||
|
||||
import {
|
||||
AllActivitiesService,
|
||||
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||
ApplicationHealthReportDetailEnriched,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
@@ -43,7 +43,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
|
||||
isMarkedAsCritical: true,
|
||||
atRiskPasswordCount: 1,
|
||||
atRiskCipherIds: ["cid1"],
|
||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||
} as ApplicationHealthReportDetailEnriched,
|
||||
];
|
||||
const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2);
|
||||
await service.assignTasks(organizationId, apps);
|
||||
@@ -60,12 +60,12 @@ describe("AccessIntelligenceSecurityTasksService", () => {
|
||||
isMarkedAsCritical: true,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cid1", "cid2"],
|
||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||
} as ApplicationHealthReportDetailEnriched,
|
||||
{
|
||||
isMarkedAsCritical: true,
|
||||
atRiskPasswordCount: 1,
|
||||
atRiskCipherIds: ["cid2"],
|
||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||
} as ApplicationHealthReportDetailEnriched,
|
||||
];
|
||||
defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined);
|
||||
i18nServiceSpy.t.mockImplementation((key) => key);
|
||||
@@ -91,7 +91,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
|
||||
isMarkedAsCritical: true,
|
||||
atRiskPasswordCount: 1,
|
||||
atRiskCipherIds: ["cid3"],
|
||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||
} as ApplicationHealthReportDetailEnriched,
|
||||
];
|
||||
defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail"));
|
||||
i18nServiceSpy.t.mockImplementation((key) => key);
|
||||
@@ -113,7 +113,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
|
||||
isMarkedAsCritical: true,
|
||||
atRiskPasswordCount: 0,
|
||||
atRiskCipherIds: ["cid4"],
|
||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||
} as ApplicationHealthReportDetailEnriched,
|
||||
];
|
||||
const result = await service.requestPasswordChange(organizationId, apps);
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
|
||||
isMarkedAsCritical: false,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cid5", "cid6"],
|
||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||
} as ApplicationHealthReportDetailEnriched,
|
||||
];
|
||||
const result = await service.requestPasswordChange(organizationId, apps);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from "@angular/core";
|
||||
|
||||
import {
|
||||
AllActivitiesService,
|
||||
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
||||
ApplicationHealthReportDetailEnriched,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
@@ -20,10 +20,7 @@ export class AccessIntelligenceSecurityTasksService {
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
async assignTasks(
|
||||
organizationId: OrganizationId,
|
||||
apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[],
|
||||
) {
|
||||
async assignTasks(organizationId: OrganizationId, apps: ApplicationHealthReportDetailEnriched[]) {
|
||||
const taskCount = await this.requestPasswordChange(organizationId, apps);
|
||||
this.allActivitiesService.setTaskCreatedCount(taskCount);
|
||||
}
|
||||
@@ -31,7 +28,7 @@ export class AccessIntelligenceSecurityTasksService {
|
||||
// TODO: this method is shared between here and critical-applications.component.ts
|
||||
async requestPasswordChange(
|
||||
organizationId: OrganizationId,
|
||||
apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[],
|
||||
apps: ApplicationHealthReportDetailEnriched[],
|
||||
): Promise<number> {
|
||||
// Only create tasks for CRITICAL applications with at-risk passwords
|
||||
const cipherIds = apps
|
||||
|
||||
Reference in New Issue
Block a user