mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 14:23:32 +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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LEGACY_MemberDetailsFlat,
|
AtRiskApplicationDetail,
|
||||||
LEGACY_CipherHealthReportDetail,
|
AtRiskMemberDetail,
|
||||||
LEGACY_CipherHealthReportUriDetail,
|
MemberCipherDetailsResponse,
|
||||||
} from "../models/password-health";
|
} from "../models";
|
||||||
import {
|
import {
|
||||||
ApplicationHealthReportDetail,
|
ApplicationHealthReportDetail,
|
||||||
|
MemberDetails,
|
||||||
OrganizationReportSummary,
|
OrganizationReportSummary,
|
||||||
RiskInsightsData,
|
|
||||||
} from "../models/report-models";
|
} from "../models/report-models";
|
||||||
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
|
|
||||||
|
|
||||||
export function flattenMemberDetails(
|
export function flattenMemberDetails(
|
||||||
memberCiphers: MemberCipherDetailsResponse[],
|
memberCiphers: MemberCipherDetailsResponse[],
|
||||||
): LEGACY_MemberDetailsFlat[] {
|
): MemberDetails[] {
|
||||||
return memberCiphers.flatMap((member) =>
|
return memberCiphers.flatMap((member) =>
|
||||||
member.cipherIds.map((cipherId) => ({
|
member.cipherIds.map((cipherId) => ({
|
||||||
userGuid: member.userGuid,
|
userGuid: member.userGuid,
|
||||||
@@ -48,9 +47,7 @@ export function getTrimmedCipherUris(cipher: CipherView): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Returns a deduplicated array of members by email
|
// Returns a deduplicated array of members by email
|
||||||
export function getUniqueMembers(
|
export function getUniqueMembers(orgMembers: MemberDetails[]): MemberDetails[] {
|
||||||
orgMembers: LEGACY_MemberDetailsFlat[],
|
|
||||||
): LEGACY_MemberDetailsFlat[] {
|
|
||||||
const existingEmails = new Set<string>();
|
const existingEmails = new Set<string>();
|
||||||
return orgMembers.filter((member) => {
|
return orgMembers.filter((member) => {
|
||||||
if (existingEmails.has(member.email)) {
|
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
|
* Create a new Risk Insights Report Summary
|
||||||
*
|
*
|
||||||
@@ -181,3 +76,60 @@ export function createNewSummaryData(): OrganizationReportSummary {
|
|||||||
newApplications: [],
|
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 { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
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";
|
import { createNewSummaryData } from "../helpers";
|
||||||
|
|
||||||
@@ -46,11 +46,11 @@ export interface SaveRiskInsightsReportRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SaveRiskInsightsReportResponse extends BaseResponse {
|
export class SaveRiskInsightsReportResponse extends BaseResponse {
|
||||||
id: string;
|
id: OrganizationReportId;
|
||||||
|
|
||||||
constructor(response: any) {
|
constructor(response: any) {
|
||||||
super(response);
|
super(response);
|
||||||
this.id = this.getResponseProperty("organizationId");
|
this.id = this.getResponseProperty("id");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export function isSaveRiskInsightsReportResponse(obj: any): obj is SaveRiskInsightsReportResponse {
|
export function isSaveRiskInsightsReportResponse(obj: any): obj is SaveRiskInsightsReportResponse {
|
||||||
@@ -69,7 +69,7 @@ export class GetRiskInsightsReportResponse extends BaseResponse {
|
|||||||
constructor(response: any) {
|
constructor(response: any) {
|
||||||
super(response);
|
super(response);
|
||||||
|
|
||||||
this.id = this.getResponseProperty("organizationId");
|
this.id = this.getResponseProperty("id");
|
||||||
this.organizationId = this.getResponseProperty("organizationId");
|
this.organizationId = this.getResponseProperty("organizationId");
|
||||||
this.creationDate = new Date(this.getResponseProperty("creationDate"));
|
this.creationDate = new Date(this.getResponseProperty("creationDate"));
|
||||||
this.reportData = new EncString(this.getResponseProperty("reportData"));
|
this.reportData = new EncString(this.getResponseProperty("reportData"));
|
||||||
@@ -113,3 +113,31 @@ export class GetRiskInsightsApplicationDataResponse extends BaseResponse {
|
|||||||
this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey");
|
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-data-service.types";
|
||||||
export * from "./report-encryption.types";
|
export * from "./report-encryption.types";
|
||||||
export * from "./report-models";
|
export * from "./report-models";
|
||||||
|
export * from "./drawer-models.types";
|
||||||
|
|||||||
@@ -1,109 +1,83 @@
|
|||||||
import { mock } from "jest-mock-extended";
|
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 mockMemberCipherDetailsResponse: MemberCipherDetailsResponse[] = [
|
||||||
|
mock<MemberCipherDetailsResponse>({
|
||||||
export const mockMemberCipherDetails: any = [
|
userGuid: "user-1",
|
||||||
{
|
|
||||||
userName: "David Brent",
|
userName: "David Brent",
|
||||||
email: "david.brent@wernhamhogg.uk",
|
email: "david.brent@wernhamhogg.uk",
|
||||||
usesKeyConnector: true,
|
useKeyConnector: true,
|
||||||
cipherIds: [
|
cipherIds: [
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
{
|
mock<MemberCipherDetailsResponse>({
|
||||||
|
userGuid: "user-2",
|
||||||
userName: "Tim Canterbury",
|
userName: "Tim Canterbury",
|
||||||
email: "tim.canterbury@wernhamhogg.uk",
|
email: "tim.canterbury@wernhamhogg.uk",
|
||||||
usesKeyConnector: false,
|
useKeyConnector: false,
|
||||||
cipherIds: [
|
cipherIds: [
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
{
|
mock<MemberCipherDetailsResponse>({
|
||||||
|
userGuid: "user-3",
|
||||||
userName: "Gareth Keenan",
|
userName: "Gareth Keenan",
|
||||||
email: "gareth.keenan@wernhamhogg.uk",
|
email: "gareth.keenan@wernhamhogg.uk",
|
||||||
usesKeyConnector: true,
|
useKeyConnector: true,
|
||||||
cipherIds: [
|
cipherIds: [
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001227nm7",
|
"cbea34a8-bde4-46ad-9d19-b05001227nm7",
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
{
|
mock<MemberCipherDetailsResponse>({
|
||||||
|
userGuid: "user-4",
|
||||||
userName: "Dawn Tinsley",
|
userName: "Dawn Tinsley",
|
||||||
email: "dawn.tinsley@wernhamhogg.uk",
|
email: "dawn.tinsley@wernhamhogg.uk",
|
||||||
usesKeyConnector: true,
|
useKeyConnector: true,
|
||||||
cipherIds: [
|
cipherIds: [
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
{
|
mock<MemberCipherDetailsResponse>({
|
||||||
|
userGuid: "user-5",
|
||||||
userName: "Keith Bishop",
|
userName: "Keith Bishop",
|
||||||
email: "keith.bishop@wernhamhogg.uk",
|
email: "keith.bishop@wernhamhogg.uk",
|
||||||
usesKeyConnector: false,
|
useKeyConnector: false,
|
||||||
cipherIds: [
|
cipherIds: [
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
{
|
mock<MemberCipherDetailsResponse>({
|
||||||
|
userGuid: "user-1",
|
||||||
userName: "Chris Finch",
|
userName: "Chris Finch",
|
||||||
email: "chris.finch@wernhamhogg.uk",
|
email: "chris.finch@wernhamhogg.uk",
|
||||||
usesKeyConnector: true,
|
useKeyConnector: true,
|
||||||
cipherIds: [
|
cipherIds: [
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
{
|
mock<MemberCipherDetailsResponse>({
|
||||||
|
userGuid: "user-1",
|
||||||
userName: "Mister Secure",
|
userName: "Mister Secure",
|
||||||
email: "mister.secure@secureco.com",
|
email: "mister.secure@secureco.com",
|
||||||
usesKeyConnector: true,
|
useKeyConnector: true,
|
||||||
cipherIds: ["cbea34a8-bde4-46ad-9d19-b05001227tt1"],
|
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 { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
|
import { MemberCipherDetailsResponse } from "..";
|
||||||
|
import { ApplicationHealthReportDetailEnriched } from "../report-data-service.types";
|
||||||
import { ApplicationHealthReportDetailEnriched } from "./report-data-service.types";
|
|
||||||
import {
|
import {
|
||||||
ApplicationHealthReportDetail,
|
ApplicationHealthReportDetail,
|
||||||
|
CipherHealthReport,
|
||||||
OrganizationReportApplication,
|
OrganizationReportApplication,
|
||||||
OrganizationReportSummary,
|
OrganizationReportSummary,
|
||||||
} from "./report-models";
|
PasswordHealthData,
|
||||||
|
} from "../report-models";
|
||||||
|
|
||||||
const mockApplication1: ApplicationHealthReportDetail = {
|
const mockApplication1: ApplicationHealthReportDetail = {
|
||||||
applicationName: "application1.com",
|
applicationName: "application1.com",
|
||||||
@@ -82,10 +83,12 @@ export const mockApplicationData: OrganizationReportApplication[] = [
|
|||||||
{
|
{
|
||||||
applicationName: "application1.com",
|
applicationName: "application1.com",
|
||||||
isCritical: true,
|
isCritical: true,
|
||||||
|
reviewedDate: new Date(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
applicationName: "application2.com",
|
applicationName: "application2.com",
|
||||||
isCritical: false,
|
isCritical: false,
|
||||||
|
reviewedDate: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -138,3 +141,41 @@ export const mockMemberDetails = [
|
|||||||
email: "user3@other.com",
|
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
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
|
||||||
import { BadgeVariant } from "@bitwarden/components";
|
import { BadgeVariant } from "@bitwarden/components";
|
||||||
|
|
||||||
import { ApplicationHealthReportDetail } from "./report-models";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Weak password details containing the score
|
* Weak password details containing the score
|
||||||
* and the score type for the label and badge
|
* and the score type for the label and badge
|
||||||
@@ -30,37 +27,3 @@ export type ExposedPasswordDetail = {
|
|||||||
cipherId: string;
|
cipherId: string;
|
||||||
exposedXTimes: number;
|
exposedXTimes: number;
|
||||||
} | null;
|
} | 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 { 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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { BadgeVariant } from "@bitwarden/components";
|
import { BadgeVariant } from "@bitwarden/components";
|
||||||
|
|
||||||
import { ExposedPasswordDetail, WeakPasswordDetail } from "./password-health";
|
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 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
|
* Flattened member details that associates an
|
||||||
@@ -71,18 +40,6 @@ export type CipherHealthReport = {
|
|||||||
cipher: CipherView;
|
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 --------------------
|
// -------------------- Application Health Report Models --------------------
|
||||||
/**
|
/**
|
||||||
* All applications report summary. The total members,
|
* All applications report summary. The total members,
|
||||||
@@ -91,21 +48,16 @@ export type CipherApplicationView = {
|
|||||||
*/
|
*/
|
||||||
export type OrganizationReportSummary = {
|
export type OrganizationReportSummary = {
|
||||||
totalMemberCount: number;
|
totalMemberCount: number;
|
||||||
totalCriticalMemberCount: number;
|
|
||||||
totalAtRiskMemberCount: number;
|
|
||||||
totalCriticalAtRiskMemberCount: number;
|
|
||||||
totalApplicationCount: number;
|
totalApplicationCount: number;
|
||||||
totalCriticalApplicationCount: number;
|
totalAtRiskMemberCount: number;
|
||||||
totalAtRiskApplicationCount: number;
|
totalAtRiskApplicationCount: number;
|
||||||
|
totalCriticalApplicationCount: number;
|
||||||
|
totalCriticalMemberCount: number;
|
||||||
|
totalCriticalAtRiskMemberCount: number;
|
||||||
totalCriticalAtRiskApplicationCount: number;
|
totalCriticalAtRiskApplicationCount: number;
|
||||||
newApplications: string[];
|
newApplications: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CriticalSummaryDetails = {
|
|
||||||
totalCriticalMembersCount: number;
|
|
||||||
totalCriticalApplicationsCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An entry for an organization application and if it is
|
* An entry for an organization application and if it is
|
||||||
* marked as critical
|
* marked as critical
|
||||||
@@ -113,6 +65,11 @@ export type CriticalSummaryDetails = {
|
|||||||
export type OrganizationReportApplication = {
|
export type OrganizationReportApplication = {
|
||||||
applicationName: string;
|
applicationName: string;
|
||||||
isCritical: boolean;
|
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[];
|
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 --------------------
|
// -------------------- Password Health Report Models --------------------
|
||||||
export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">;
|
export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">;
|
||||||
|
|
||||||
@@ -152,8 +100,26 @@ export type ReportResult = CipherView & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface RiskInsightsData {
|
export interface RiskInsightsData {
|
||||||
|
id: OrganizationReportId;
|
||||||
creationDate: Date;
|
creationDate: Date;
|
||||||
|
contentEncryptionKey: EncString;
|
||||||
reportData: ApplicationHealthReportDetail[];
|
reportData: ApplicationHealthReportDetail[];
|
||||||
summaryData: OrganizationReportSummary;
|
summaryData: OrganizationReportSummary;
|
||||||
applicationData: OrganizationReportApplication[];
|
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,
|
PasswordHealthReportApplicationDropRequest,
|
||||||
PasswordHealthReportApplicationsRequest,
|
PasswordHealthReportApplicationsRequest,
|
||||||
PasswordHealthReportApplicationsResponse,
|
PasswordHealthReportApplicationsResponse,
|
||||||
} from "../models/api-models.types";
|
} from "../../models/api-models.types";
|
||||||
import { PasswordHealthReportApplicationId } from "../models/report-models";
|
import { PasswordHealthReportApplicationId } from "../../models/report-models";
|
||||||
|
|
||||||
import { CriticalAppsApiService } from "./critical-apps-api.service";
|
import { CriticalAppsApiService } from "./critical-apps-api.service";
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
PasswordHealthReportApplicationDropRequest,
|
PasswordHealthReportApplicationDropRequest,
|
||||||
PasswordHealthReportApplicationsRequest,
|
PasswordHealthReportApplicationsRequest,
|
||||||
PasswordHealthReportApplicationsResponse,
|
PasswordHealthReportApplicationsResponse,
|
||||||
} from "../models/api-models.types";
|
} from "../../models/api-models.types";
|
||||||
|
|
||||||
export class CriticalAppsApiService {
|
export class CriticalAppsApiService {
|
||||||
constructor(private apiService: ApiService) {}
|
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 { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
|
|
||||||
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
|
import { MemberCipherDetailsResponse } from "../../models";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MemberCipherDetailsApiService {
|
export class MemberCipherDetailsApiService {
|
||||||
@@ -7,17 +7,16 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response"
|
|||||||
import { makeEncString } from "@bitwarden/common/spec";
|
import { makeEncString } from "@bitwarden/common/spec";
|
||||||
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import { EncryptedDataWithKey } from "../models";
|
import { EncryptedDataWithKey } from "../../models";
|
||||||
import {
|
import {
|
||||||
GetRiskInsightsApplicationDataResponse,
|
GetRiskInsightsApplicationDataResponse,
|
||||||
GetRiskInsightsReportResponse,
|
GetRiskInsightsReportResponse,
|
||||||
GetRiskInsightsSummaryResponse,
|
GetRiskInsightsSummaryResponse,
|
||||||
SaveRiskInsightsReportRequest,
|
SaveRiskInsightsReportRequest,
|
||||||
SaveRiskInsightsReportResponse,
|
SaveRiskInsightsReportResponse,
|
||||||
} from "../models/api-models.types";
|
} from "../../models/api-models.types";
|
||||||
import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data";
|
import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data";
|
||||||
|
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
||||||
import { RiskInsightsApiService } from "./risk-insights-api.service";
|
|
||||||
|
|
||||||
describe("RiskInsightsApiService", () => {
|
describe("RiskInsightsApiService", () => {
|
||||||
let service: RiskInsightsApiService;
|
let service: RiskInsightsApiService;
|
||||||
@@ -229,19 +228,22 @@ describe("RiskInsightsApiService", () => {
|
|||||||
|
|
||||||
it("updateRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => {
|
it("updateRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => {
|
||||||
const reportId = "report123" as OrganizationReportId;
|
const reportId = "report123" as OrganizationReportId;
|
||||||
const mockApplication = mockApplicationData[0];
|
// TODO Update to be encrypted test
|
||||||
|
const mockApplication = makeEncString("application-data");
|
||||||
|
|
||||||
mockApiService.send.mockResolvedValueOnce(undefined);
|
mockApiService.send.mockResolvedValueOnce(undefined);
|
||||||
const result = await firstValueFrom(
|
const result = await firstValueFrom(
|
||||||
service.updateRiskInsightsApplicationData$(mockApplication, orgId, reportId),
|
service.updateRiskInsightsApplicationData$(reportId, orgId, {
|
||||||
|
data: { applicationData: mockApplication.encryptedString! },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||||
"PATCH",
|
"PATCH",
|
||||||
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
|
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
|
||||||
mockApplication,
|
{ applicationData: mockApplication.encryptedString!, id: reportId, organizationId: orgId },
|
||||||
true,
|
true,
|
||||||
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 { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||||
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import { EncryptedDataWithKey, OrganizationReportApplication } from "../models";
|
import {
|
||||||
|
EncryptedDataWithKey,
|
||||||
|
UpdateRiskInsightsApplicationDataRequest,
|
||||||
|
UpdateRiskInsightsApplicationDataResponse,
|
||||||
|
} from "../../models";
|
||||||
import {
|
import {
|
||||||
GetRiskInsightsApplicationDataResponse,
|
GetRiskInsightsApplicationDataResponse,
|
||||||
GetRiskInsightsReportResponse,
|
GetRiskInsightsReportResponse,
|
||||||
GetRiskInsightsSummaryResponse,
|
GetRiskInsightsSummaryResponse,
|
||||||
SaveRiskInsightsReportRequest,
|
SaveRiskInsightsReportRequest,
|
||||||
SaveRiskInsightsReportResponse,
|
SaveRiskInsightsReportResponse,
|
||||||
} from "../models/api-models.types";
|
} from "../../models/api-models.types";
|
||||||
|
|
||||||
export class RiskInsightsApiService {
|
export class RiskInsightsApiService {
|
||||||
constructor(private apiService: ApiService) {}
|
constructor(private apiService: ApiService) {}
|
||||||
@@ -102,18 +106,20 @@ export class RiskInsightsApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateRiskInsightsApplicationData$(
|
updateRiskInsightsApplicationData$(
|
||||||
applicationData: OrganizationReportApplication,
|
|
||||||
orgId: OrganizationId,
|
|
||||||
reportId: OrganizationReportId,
|
reportId: OrganizationReportId,
|
||||||
): Observable<void> {
|
orgId: OrganizationId,
|
||||||
|
request: UpdateRiskInsightsApplicationDataRequest,
|
||||||
|
): Observable<UpdateRiskInsightsApplicationDataResponse> {
|
||||||
const dbResponse = this.apiService.send(
|
const dbResponse = this.apiService.send(
|
||||||
"PATCH",
|
"PATCH",
|
||||||
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
|
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
|
||||||
applicationData,
|
{ ...request.data, id: reportId, organizationId: orgId },
|
||||||
true,
|
true,
|
||||||
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 {
|
import {
|
||||||
PasswordHealthReportApplicationsRequest,
|
PasswordHealthReportApplicationsRequest,
|
||||||
PasswordHealthReportApplicationsResponse,
|
PasswordHealthReportApplicationsResponse,
|
||||||
} from "../models/api-models.types";
|
} from "../../models/api-models.types";
|
||||||
import { PasswordHealthReportApplicationId } from "../models/report-models";
|
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";
|
import { CriticalAppsService } from "./critical-apps.service";
|
||||||
|
|
||||||
const SomeCsprngArray = new Uint8Array(64) as CsprngArray;
|
const SomeCsprngArray = new Uint8Array(64) as CsprngArray;
|
||||||
@@ -181,7 +181,7 @@ describe("CriticalAppsService", () => {
|
|||||||
privateCriticalAppsSubject.next(initialList);
|
privateCriticalAppsSubject.next(initialList);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await service.dropCriticalApp(SomeOrganization, selectedUrl);
|
await service.dropCriticalAppByUrl(SomeOrganization, selectedUrl);
|
||||||
|
|
||||||
// expectations
|
// expectations
|
||||||
expect(criticalAppsApiService.dropCriticalApp).toHaveBeenCalledWith({
|
expect(criticalAppsApiService.dropCriticalApp).toHaveBeenCalledWith({
|
||||||
@@ -213,7 +213,7 @@ describe("CriticalAppsService", () => {
|
|||||||
privateCriticalAppsSubject.next(initialList);
|
privateCriticalAppsSubject.next(initialList);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await service.dropCriticalApp(SomeOrganization, selectedUrl);
|
await service.dropCriticalAppByUrl(SomeOrganization, selectedUrl);
|
||||||
|
|
||||||
// expectations
|
// expectations
|
||||||
expect(criticalAppsApiService.dropCriticalApp).not.toHaveBeenCalled();
|
expect(criticalAppsApiService.dropCriticalApp).not.toHaveBeenCalled();
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
|
catchError,
|
||||||
filter,
|
filter,
|
||||||
first,
|
first,
|
||||||
firstValueFrom,
|
firstValueFrom,
|
||||||
forkJoin,
|
forkJoin,
|
||||||
|
from,
|
||||||
map,
|
map,
|
||||||
Observable,
|
Observable,
|
||||||
of,
|
of,
|
||||||
switchMap,
|
switchMap,
|
||||||
|
tap,
|
||||||
|
throwError,
|
||||||
zip,
|
zip,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
@@ -20,9 +24,8 @@ import { KeyService } from "@bitwarden/key-management";
|
|||||||
import {
|
import {
|
||||||
PasswordHealthReportApplicationsRequest,
|
PasswordHealthReportApplicationsRequest,
|
||||||
PasswordHealthReportApplicationsResponse,
|
PasswordHealthReportApplicationsResponse,
|
||||||
} from "../models/api-models.types";
|
} from "../../models/api-models.types";
|
||||||
|
import { CriticalAppsApiService } from "../api/critical-apps-api.service";
|
||||||
import { CriticalAppsApiService } from "./critical-apps-api.service";
|
|
||||||
|
|
||||||
/* Retrieves and decrypts critical apps for a given organization
|
/* Retrieves and decrypts critical apps for a given organization
|
||||||
* Encrypts and saves data for a given organization
|
* Encrypts and saves data for a given organization
|
||||||
@@ -125,9 +128,14 @@ export class CriticalAppsService {
|
|||||||
this.criticalAppsListSubject$.next(updatedList);
|
this.criticalAppsListSubject$.next(updatedList);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop a critical app for a given organization
|
/**
|
||||||
// Only one app may be dropped at a time
|
* Drop a critical application by url
|
||||||
async dropCriticalApp(orgId: OrganizationId, selectedUrl: string) {
|
*
|
||||||
|
* @param orgId
|
||||||
|
* @param selectedUrl
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async dropCriticalAppByUrl(orgId: OrganizationId, selectedUrl: string) {
|
||||||
if (orgId != this.organizationId.value) {
|
if (orgId != this.organizationId.value) {
|
||||||
throw new Error("Organization ID mismatch");
|
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(
|
private retrieveCriticalApps(
|
||||||
orgId: OrganizationId | null,
|
orgId: OrganizationId | null,
|
||||||
): Observable<PasswordHealthReportApplicationsResponse[]> {
|
): Observable<PasswordHealthReportApplicationsResponse[]> {
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
ExposedPasswordDetail,
|
ExposedPasswordDetail,
|
||||||
WeakPasswordDetail,
|
WeakPasswordDetail,
|
||||||
WeakPasswordScore,
|
WeakPasswordScore,
|
||||||
} from "../models/password-health";
|
} from "../../models/password-health";
|
||||||
|
|
||||||
export class PasswordHealthService {
|
export class PasswordHealthService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -9,9 +9,10 @@ import { makeSymmetricCryptoKey } from "@bitwarden/common/spec";
|
|||||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||||
import { OrgKey } from "@bitwarden/common/types/key";
|
import { OrgKey } from "@bitwarden/common/types/key";
|
||||||
import { KeyService } from "@bitwarden/key-management";
|
import { KeyService } from "@bitwarden/key-management";
|
||||||
|
import { LogService } from "@bitwarden/logging";
|
||||||
|
|
||||||
import { EncryptedReportData, DecryptedReportData } from "../models";
|
import { EncryptedReportData, DecryptedReportData } from "../../models";
|
||||||
import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data";
|
import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data";
|
||||||
|
|
||||||
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
|
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ describe("RiskInsightsEncryptionService", () => {
|
|||||||
const mockKeyService = mock<KeyService>();
|
const mockKeyService = mock<KeyService>();
|
||||||
const mockEncryptService = mock<EncryptService>();
|
const mockEncryptService = mock<EncryptService>();
|
||||||
const mockKeyGenerationService = mock<KeyGenerationService>();
|
const mockKeyGenerationService = mock<KeyGenerationService>();
|
||||||
|
const mockLogService = mock<LogService>();
|
||||||
|
|
||||||
const ENCRYPTED_TEXT = "This data has been encrypted";
|
const ENCRYPTED_TEXT = "This data has been encrypted";
|
||||||
const ENCRYPTED_KEY = "Re-encrypted Cipher Key";
|
const ENCRYPTED_KEY = "Re-encrypted Cipher Key";
|
||||||
@@ -43,6 +45,7 @@ describe("RiskInsightsEncryptionService", () => {
|
|||||||
mockKeyService,
|
mockKeyService,
|
||||||
mockEncryptService,
|
mockEncryptService,
|
||||||
mockKeyGenerationService,
|
mockKeyGenerationService,
|
||||||
|
mockLogService,
|
||||||
);
|
);
|
||||||
|
|
||||||
jest.clearAllMocks();
|
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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||||
import { KeyService } from "@bitwarden/key-management";
|
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 {
|
export class RiskInsightsEncryptionService {
|
||||||
constructor(
|
constructor(
|
||||||
private keyService: KeyService,
|
private keyService: KeyService,
|
||||||
private encryptService: EncryptService,
|
private encryptService: EncryptService,
|
||||||
private keyGeneratorService: KeyGenerationService,
|
private keyGeneratorService: KeyGenerationService,
|
||||||
|
private logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async encryptRiskInsightsReport(
|
async encryptRiskInsightsReport(
|
||||||
@@ -24,6 +34,7 @@ export class RiskInsightsEncryptionService {
|
|||||||
data: DecryptedReportData,
|
data: DecryptedReportData,
|
||||||
wrappedKey?: EncString,
|
wrappedKey?: EncString,
|
||||||
): Promise<EncryptedDataWithKey> {
|
): Promise<EncryptedDataWithKey> {
|
||||||
|
this.logService.info("[RiskInsightsEncryptionService] Encrypting risk insights report");
|
||||||
const { userId, organizationId } = context;
|
const { userId, organizationId } = context;
|
||||||
const orgKey = await firstValueFrom(
|
const orgKey = await firstValueFrom(
|
||||||
this.keyService
|
this.keyService
|
||||||
@@ -36,10 +47,14 @@ export class RiskInsightsEncryptionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!orgKey) {
|
if (!orgKey) {
|
||||||
|
this.logService.warning(
|
||||||
|
"[RiskInsightsEncryptionService] Attempted to encrypt report data without org id",
|
||||||
|
);
|
||||||
throw new Error("Organization key not found");
|
throw new Error("Organization key not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentEncryptionKey: SymmetricCryptoKey;
|
let contentEncryptionKey: SymmetricCryptoKey;
|
||||||
|
try {
|
||||||
if (!wrappedKey) {
|
if (!wrappedKey) {
|
||||||
// Generate a new key
|
// Generate a new key
|
||||||
contentEncryptionKey = await this.keyGeneratorService.createKey(512);
|
contentEncryptionKey = await this.keyGeneratorService.createKey(512);
|
||||||
@@ -47,6 +62,10 @@ export class RiskInsightsEncryptionService {
|
|||||||
// Unwrap the existing key
|
// Unwrap the existing key
|
||||||
contentEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey);
|
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;
|
const { reportData, summaryData, applicationData } = data;
|
||||||
|
|
||||||
@@ -75,6 +94,9 @@ export class RiskInsightsEncryptionService {
|
|||||||
!encryptedApplicationData.encryptedString ||
|
!encryptedApplicationData.encryptedString ||
|
||||||
!wrappedEncryptionKey.encryptedString
|
!wrappedEncryptionKey.encryptedString
|
||||||
) {
|
) {
|
||||||
|
this.logService.error(
|
||||||
|
"[RiskInsightsEncryptionService] Encryption failed, encrypted strings are null",
|
||||||
|
);
|
||||||
throw new Error("Encryption failed, encrypted strings are null");
|
throw new Error("Encryption failed, encrypted strings are null");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +119,8 @@ export class RiskInsightsEncryptionService {
|
|||||||
encryptedData: EncryptedReportData,
|
encryptedData: EncryptedReportData,
|
||||||
wrappedKey: EncString,
|
wrappedKey: EncString,
|
||||||
): Promise<DecryptedReportData> {
|
): Promise<DecryptedReportData> {
|
||||||
|
this.logService.info("[RiskInsightsEncryptionService] Decrypting risk insights report");
|
||||||
|
|
||||||
const { userId, organizationId } = context;
|
const { userId, organizationId } = context;
|
||||||
const orgKey = await firstValueFrom(
|
const orgKey = await firstValueFrom(
|
||||||
this.keyService
|
this.keyService
|
||||||
@@ -109,47 +133,106 @@ export class RiskInsightsEncryptionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!orgKey) {
|
if (!orgKey) {
|
||||||
|
this.logService.warning(
|
||||||
|
"[RiskInsightsEncryptionService] Attempted to decrypt report data without org id",
|
||||||
|
);
|
||||||
throw new Error("Organization key not found");
|
throw new Error("Organization key not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const unwrappedEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey);
|
const unwrappedEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey);
|
||||||
if (!unwrappedEncryptionKey) {
|
if (!unwrappedEncryptionKey) {
|
||||||
|
this.logService.error("[RiskInsightsEncryptionService] Encryption key not found");
|
||||||
throw Error("Encryption key not found");
|
throw Error("Encryption key not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { encryptedReportData, encryptedSummaryData, encryptedApplicationData } = encryptedData;
|
const { encryptedReportData, encryptedSummaryData, encryptedApplicationData } = encryptedData;
|
||||||
if (!encryptedReportData || !encryptedSummaryData || !encryptedApplicationData) {
|
|
||||||
throw new Error("Missing data");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt the data
|
// Decrypt the data
|
||||||
const decryptedReportData = await this.encryptService.decryptString(
|
const decryptedReportData = await this._handleDecryptReport(
|
||||||
encryptedReportData,
|
encryptedReportData,
|
||||||
unwrappedEncryptionKey,
|
unwrappedEncryptionKey,
|
||||||
);
|
);
|
||||||
const decryptedSummaryData = await this.encryptService.decryptString(
|
const decryptedSummaryData = await this._handleDecryptSummary(
|
||||||
encryptedSummaryData,
|
encryptedSummaryData,
|
||||||
unwrappedEncryptionKey,
|
unwrappedEncryptionKey,
|
||||||
);
|
);
|
||||||
const decryptedApplicationData = await this.encryptService.decryptString(
|
const decryptedApplicationData = await this._handleDecryptApplication(
|
||||||
encryptedApplicationData,
|
encryptedApplicationData,
|
||||||
unwrappedEncryptionKey,
|
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 = {
|
const decryptedFullReport = {
|
||||||
reportData: decryptedReportDataJson,
|
reportData: decryptedReportData,
|
||||||
summaryData: decryptedSummaryDataJson,
|
summaryData: decryptedSummaryData,
|
||||||
applicationData: decryptedApplicationDataJson,
|
applicationData: decryptedApplicationData,
|
||||||
};
|
};
|
||||||
|
|
||||||
return decryptedFullReport;
|
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 { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
|
|
||||||
import { DecryptedReportData, EncryptedDataWithKey } from "../models";
|
import { DecryptedReportData, EncryptedDataWithKey } from "../../models";
|
||||||
import {
|
import {
|
||||||
GetRiskInsightsReportResponse,
|
GetRiskInsightsReportResponse,
|
||||||
SaveRiskInsightsReportResponse,
|
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 {
|
import {
|
||||||
mockApplicationData,
|
mockApplicationData,
|
||||||
|
mockCipherHealthReports,
|
||||||
mockCipherViews,
|
mockCipherViews,
|
||||||
mockMemberDetails,
|
mockMemberDetails,
|
||||||
mockReportData,
|
mockReportData,
|
||||||
mockSummaryData,
|
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 { PasswordHealthService } from "./password-health.service";
|
||||||
import { RiskInsightsApiService } from "./risk-insights-api.service";
|
|
||||||
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
|
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
|
||||||
import { RiskInsightsReportService } from "./risk-insights-report.service";
|
import { RiskInsightsReportService } from "./risk-insights-report.service";
|
||||||
|
|
||||||
@@ -54,7 +55,9 @@ describe("RiskInsightsReportService", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers);
|
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers);
|
||||||
|
|
||||||
memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberCipherDetails);
|
memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(
|
||||||
|
mockMemberCipherDetailsResponse,
|
||||||
|
);
|
||||||
|
|
||||||
// Mock PasswordHealthService methods
|
// Mock PasswordHealthService methods
|
||||||
mockPasswordHealthService.isValidCipher.mockImplementation((cipher: any) => {
|
mockPasswordHealthService.isValidCipher.mockImplementation((cipher: any) => {
|
||||||
@@ -79,9 +82,6 @@ describe("RiskInsightsReportService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
service = new RiskInsightsReportService(
|
service = new RiskInsightsReportService(
|
||||||
cipherService,
|
|
||||||
memberCipherDetailsService,
|
|
||||||
mockPasswordHealthService,
|
|
||||||
mockRiskInsightsApiService,
|
mockRiskInsightsApiService,
|
||||||
mockRiskInsightsEncryptionService,
|
mockRiskInsightsEncryptionService,
|
||||||
);
|
);
|
||||||
@@ -93,12 +93,12 @@ 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
|
// Mock the service methods
|
||||||
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCipherViews);
|
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCipherViews);
|
||||||
memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberDetails);
|
memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberDetails);
|
||||||
|
|
||||||
service.generateApplicationsReport$("orgId" as any).subscribe((result) => {
|
const result = service.generateApplicationsReport(mockCipherHealthReports);
|
||||||
expect(Array.isArray(result)).toBe(true);
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
|
||||||
// Should group by application name (trimmedUris)
|
// Should group by application name (trimmedUris)
|
||||||
@@ -108,112 +108,6 @@ describe("RiskInsightsReportService", () => {
|
|||||||
expect(appCom?.passwordCount).toBe(2);
|
expect(appCom?.passwordCount).toBe(2);
|
||||||
expect(otherCom).toBeTruthy();
|
expect(otherCom).toBeTruthy();
|
||||||
expect(otherCom?.passwordCount).toBe(1);
|
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("saveRiskInsightsReport$", () => {
|
describe("saveRiskInsightsReport$", () => {
|
||||||
@@ -249,8 +143,6 @@ describe("RiskInsightsReportService", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should encrypt and save report, then update subjects", async () => {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getRiskInsightsReport$", () => {
|
describe("getRiskInsightsReport$", () => {
|
||||||
@@ -338,7 +230,12 @@ describe("RiskInsightsReportService", () => {
|
|||||||
expect.anything(),
|
expect.anything(),
|
||||||
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 "./api/critical-apps-api.service";
|
||||||
export * from "./password-health.service";
|
export * from "./api/member-cipher-details-api.service";
|
||||||
export * from "./critical-apps.service";
|
export * from "./api/risk-insights-api.service";
|
||||||
export * from "./critical-apps-api.service";
|
export * from "./api/security-tasks-api.service";
|
||||||
export * from "./risk-insights-api.service";
|
export * from "./domain/critical-apps.service";
|
||||||
export * from "./risk-insights-report.service";
|
export * from "./domain/password-health.service";
|
||||||
export * from "./risk-insights-data.service";
|
export * from "./domain/risk-insights-encryption.service";
|
||||||
export * from "./all-activities.service";
|
export * from "./domain/risk-insights-orchestrator.service";
|
||||||
export * from "./security-tasks-api.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 { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
import { ApplicationHealthReportDetailEnriched } from "../models";
|
import { ApplicationHealthReportDetailEnriched } from "../../models";
|
||||||
import { OrganizationReportSummary } from "../models/report-models";
|
import { OrganizationReportSummary } from "../../models/report-models";
|
||||||
|
|
||||||
import { RiskInsightsDataService } from "./risk-insights-data.service";
|
import { RiskInsightsDataService } from "./risk-insights-data.service";
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export class AllActivitiesService {
|
|||||||
|
|
||||||
constructor(private dataService: RiskInsightsDataService) {
|
constructor(private dataService: RiskInsightsDataService) {
|
||||||
// All application summary changes
|
// All application summary changes
|
||||||
this.dataService.reportResults$.subscribe((report) => {
|
this.dataService.enrichedReportData$.subscribe((report) => {
|
||||||
if (report) {
|
if (report) {
|
||||||
this.setAllAppsReportSummary(report.summaryData);
|
this.setAllAppsReportSummary(report.summaryData);
|
||||||
this.setAllAppsReportDetails(report.reportData);
|
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,
|
RiskInsightsReportService,
|
||||||
SecurityTasksApiService,
|
SecurityTasksApiService,
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
|
} 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 { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { ToastService } from "@bitwarden/components";
|
import { ToastService } from "@bitwarden/components";
|
||||||
import { KeyService } from "@bitwarden/key-management";
|
import { KeyService } from "@bitwarden/key-management";
|
||||||
|
import { LogService } from "@bitwarden/logging";
|
||||||
|
|
||||||
import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service";
|
import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service";
|
||||||
|
|
||||||
@@ -52,28 +54,31 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.
|
|||||||
safeProvider({
|
safeProvider({
|
||||||
provide: RiskInsightsReportService,
|
provide: RiskInsightsReportService,
|
||||||
useClass: RiskInsightsReportService,
|
useClass: RiskInsightsReportService,
|
||||||
|
deps: [RiskInsightsApiService, RiskInsightsEncryptionService],
|
||||||
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: RiskInsightsOrchestratorService,
|
||||||
deps: [
|
deps: [
|
||||||
|
AccountServiceAbstraction,
|
||||||
CipherService,
|
CipherService,
|
||||||
|
CriticalAppsService,
|
||||||
|
LogService,
|
||||||
MemberCipherDetailsApiService,
|
MemberCipherDetailsApiService,
|
||||||
|
OrganizationService,
|
||||||
PasswordHealthService,
|
PasswordHealthService,
|
||||||
RiskInsightsApiService,
|
RiskInsightsApiService,
|
||||||
|
RiskInsightsReportService,
|
||||||
RiskInsightsEncryptionService,
|
RiskInsightsEncryptionService,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: RiskInsightsDataService,
|
provide: RiskInsightsDataService,
|
||||||
deps: [
|
deps: [RiskInsightsOrchestratorService],
|
||||||
AccountServiceAbstraction,
|
|
||||||
CriticalAppsService,
|
|
||||||
OrganizationService,
|
|
||||||
RiskInsightsReportService,
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
{
|
safeProvider({
|
||||||
provide: RiskInsightsEncryptionService,
|
provide: RiskInsightsEncryptionService,
|
||||||
useClass: RiskInsightsEncryptionService,
|
deps: [KeyService, EncryptService, KeyGenerationService, LogService],
|
||||||
deps: [KeyService, EncryptService, KeyGenerationService],
|
}),
|
||||||
},
|
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: CriticalAppsService,
|
provide: CriticalAppsService,
|
||||||
useClass: CriticalAppsService,
|
useClass: CriticalAppsService,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, OnInit } from "@angular/core";
|
import { Component, OnInit, ChangeDetectionStrategy } from "@angular/core";
|
||||||
import { ActivatedRoute } from "@angular/router";
|
import { ActivatedRoute } from "@angular/router";
|
||||||
import { Subject, switchMap, takeUntil, of, BehaviorSubject, combineLatest } from "rxjs";
|
import { Subject, switchMap, takeUntil, of, BehaviorSubject, combineLatest } from "rxjs";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import {
|
import {
|
||||||
AllActivitiesService,
|
AllActivitiesService,
|
||||||
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
ApplicationHealthReportDetailEnriched,
|
||||||
SecurityTasksApiService,
|
SecurityTasksApiService,
|
||||||
TaskMetrics,
|
TaskMetrics,
|
||||||
OrganizationReportSummary,
|
OrganizationReportSummary,
|
||||||
@@ -14,17 +14,12 @@ import {
|
|||||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components";
|
import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components";
|
||||||
|
|
||||||
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
|
import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service";
|
||||||
import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service";
|
import { RenderMode } from "../../models/activity.models";
|
||||||
|
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];
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
selector: "dirt-password-change-metric",
|
selector: "dirt-password-change-metric",
|
||||||
imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule],
|
imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule],
|
||||||
templateUrl: "./password-change-metric.component.html",
|
templateUrl: "./password-change-metric.component.html",
|
||||||
@@ -34,8 +29,7 @@ export class PasswordChangeMetricComponent implements OnInit {
|
|||||||
protected taskMetrics$ = new BehaviorSubject<TaskMetrics>({ totalTasks: 0, completedTasks: 0 });
|
protected taskMetrics$ = new BehaviorSubject<TaskMetrics>({ totalTasks: 0, completedTasks: 0 });
|
||||||
private completedTasks: number = 0;
|
private completedTasks: number = 0;
|
||||||
private totalTasks: number = 0;
|
private totalTasks: number = 0;
|
||||||
private allApplicationsDetails: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[] =
|
private allApplicationsDetails: ApplicationHealthReportDetailEnriched[] = [];
|
||||||
[];
|
|
||||||
|
|
||||||
atRiskAppsCount: number = 0;
|
atRiskAppsCount: number = 0;
|
||||||
atRiskPasswordsCount: number = 0;
|
atRiskPasswordsCount: number = 0;
|
||||||
@@ -43,6 +37,13 @@ export class PasswordChangeMetricComponent implements OnInit {
|
|||||||
private destroyRef = new Subject<void>();
|
private destroyRef = new Subject<void>();
|
||||||
renderMode: RenderMode = "noCriticalApps";
|
renderMode: RenderMode = "noCriticalApps";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private activatedRoute: ActivatedRoute,
|
||||||
|
private securityTasksApiService: SecurityTasksApiService,
|
||||||
|
private allActivitiesService: AllActivitiesService,
|
||||||
|
protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$])
|
combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$])
|
||||||
.pipe(
|
.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(
|
private determineRenderMode(
|
||||||
summary: OrganizationReportSummary,
|
summary: OrganizationReportSummary,
|
||||||
taskMetrics: TaskMetrics,
|
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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/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 { 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 { 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 { ActivityCardComponent } from "./activity-card.component";
|
||||||
import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component";
|
import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component";
|
||||||
import { NewApplicationsDialogComponent } from "./new-applications-dialog.component";
|
import { NewApplicationsDialogComponent } from "./new-applications-dialog.component";
|
||||||
import { ApplicationsLoadingComponent } from "./risk-insights-loading.component";
|
|
||||||
import { RiskInsightsTabType } from "./risk-insights.component";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "dirt-all-activity",
|
selector: "dirt-all-activity",
|
||||||
@@ -43,6 +43,15 @@ export class AllActivityComponent implements OnInit {
|
|||||||
|
|
||||||
destroyRef = inject(DestroyRef);
|
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> {
|
async ngOnInit(): Promise<void> {
|
||||||
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId");
|
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId");
|
||||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
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() {
|
get RiskInsightsTabType() {
|
||||||
return 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 { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||||
|
|
||||||
import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.component";
|
import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component";
|
||||||
import { ApplicationsLoadingComponent } from "./risk-insights-loading.component";
|
import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "dirt-all-applications",
|
selector: "dirt-all-applications",
|
||||||
@@ -67,7 +67,7 @@ export class AllApplicationsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.dataService.reportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||||
next: (report) => {
|
next: (report) => {
|
||||||
this.applicationSummary = report?.summaryData ?? createNewSummaryData();
|
this.applicationSummary = report?.summaryData ?? createNewSummaryData();
|
||||||
this.dataSource.data = report?.reportData ?? [];
|
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 { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||||
|
|
||||||
import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service";
|
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
|
||||||
|
import { RiskInsightsTabType } from "../models/risk-insights.models";
|
||||||
import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.component";
|
import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component";
|
||||||
import { RiskInsightsTabType } from "./risk-insights.component";
|
import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service";
|
||||||
import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "dirt-critical-applications",
|
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 {
|
} @else {
|
||||||
<span class="tw-mx-4">{{ "noReportRan" | i18n }}</span>
|
<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">
|
<span class="tw-flex tw-justify-center">
|
||||||
<button
|
<button
|
||||||
*ngIf="!isRunningReport"
|
*ngIf="!isRunningReport"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
buttonType="secondary"
|
buttonType="secondary"
|
||||||
class="tw-border-none !tw-font-normal tw-cursor-pointer !tw-py-0"
|
class="tw-border-none !tw-font-normal tw-cursor-pointer !tw-py-0"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
[bitAction]="refreshData.bind(this)"
|
[bitAction]="generateReport.bind(this)"
|
||||||
>
|
>
|
||||||
{{ "riskInsightsRunReport" | i18n }}
|
{{ "riskInsightsRunReport" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
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 { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
import { EMPTY } from "rxjs";
|
import { EMPTY } from "rxjs";
|
||||||
import { map, switchMap } from "rxjs/operators";
|
import { map, tap } from "rxjs/operators";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
import {
|
||||||
import { DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
|
DrawerType,
|
||||||
|
RiskInsightsDataService,
|
||||||
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
@@ -21,18 +23,10 @@ import {
|
|||||||
} from "@bitwarden/components";
|
} from "@bitwarden/components";
|
||||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||||
|
|
||||||
import { AllActivityComponent } from "./all-activity.component";
|
import { AllActivityComponent } from "./activity/all-activity.component";
|
||||||
import { AllApplicationsComponent } from "./all-applications.component";
|
import { AllApplicationsComponent } from "./all-applications/all-applications.component";
|
||||||
import { CriticalApplicationsComponent } from "./critical-applications.component";
|
import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component";
|
||||||
|
import { RiskInsightsTabType } from "./models/risk-insights.models";
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
templateUrl: "./risk-insights.component.html",
|
templateUrl: "./risk-insights.component.html",
|
||||||
@@ -51,7 +45,7 @@ export enum RiskInsightsTabType {
|
|||||||
AllActivityComponent,
|
AllActivityComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class RiskInsightsComponent implements OnInit {
|
export class RiskInsightsComponent implements OnInit, OnDestroy {
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
private _isDrawerOpen: boolean = false;
|
private _isDrawerOpen: boolean = false;
|
||||||
|
|
||||||
@@ -65,7 +59,6 @@ export class RiskInsightsComponent implements OnInit {
|
|||||||
private organizationId: OrganizationId = "" as OrganizationId;
|
private organizationId: OrganizationId = "" as OrganizationId;
|
||||||
|
|
||||||
dataLastUpdated: Date | null = null;
|
dataLastUpdated: Date | null = null;
|
||||||
refetching: boolean = false;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@@ -91,11 +84,10 @@ export class RiskInsightsComponent implements OnInit {
|
|||||||
.pipe(
|
.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
map((params) => params.get("organizationId")),
|
map((params) => params.get("organizationId")),
|
||||||
switchMap(async (orgId) => {
|
tap((orgId) => {
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
// Initialize Data Service
|
// Initialize Data Service
|
||||||
await this.dataService.initializeForOrganization(orgId as OrganizationId);
|
this.dataService.initializeForOrganization(orgId as OrganizationId);
|
||||||
|
|
||||||
this.organizationId = orgId as OrganizationId;
|
this.organizationId = orgId as OrganizationId;
|
||||||
} else {
|
} else {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
@@ -105,7 +97,7 @@ export class RiskInsightsComponent implements OnInit {
|
|||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
// Subscribe to report result details
|
// Subscribe to report result details
|
||||||
this.dataService.reportResults$
|
this.dataService.enrichedReportData$
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe((report) => {
|
.subscribe((report) => {
|
||||||
this.appsCount = report?.reportData.length ?? 0;
|
this.appsCount = report?.reportData.length ?? 0;
|
||||||
@@ -119,15 +111,16 @@ export class RiskInsightsComponent implements OnInit {
|
|||||||
this._isDrawerOpen = details.open;
|
this._isDrawerOpen = details.open;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
runReport = () => {
|
|
||||||
this.dataService.triggerReport();
|
ngOnDestroy(): void {
|
||||||
};
|
this.dataService.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes the data by re-fetching the applications report.
|
* Refreshes the data by re-fetching the applications report.
|
||||||
* This will automatically notify child components subscribed to the RiskInsightsDataService observables.
|
* This will automatically notify child components subscribed to the RiskInsightsDataService observables.
|
||||||
*/
|
*/
|
||||||
refreshData(): void {
|
generateReport(): void {
|
||||||
if (this.organizationId) {
|
if (this.organizationId) {
|
||||||
this.dataService.triggerReport();
|
this.dataService.triggerReport();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { mock } from "jest-mock-extended";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AllActivitiesService,
|
AllActivitiesService,
|
||||||
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
ApplicationHealthReportDetailEnriched,
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
@@ -43,7 +43,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
|
|||||||
isMarkedAsCritical: true,
|
isMarkedAsCritical: true,
|
||||||
atRiskPasswordCount: 1,
|
atRiskPasswordCount: 1,
|
||||||
atRiskCipherIds: ["cid1"],
|
atRiskCipherIds: ["cid1"],
|
||||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
} as ApplicationHealthReportDetailEnriched,
|
||||||
];
|
];
|
||||||
const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2);
|
const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2);
|
||||||
await service.assignTasks(organizationId, apps);
|
await service.assignTasks(organizationId, apps);
|
||||||
@@ -60,12 +60,12 @@ describe("AccessIntelligenceSecurityTasksService", () => {
|
|||||||
isMarkedAsCritical: true,
|
isMarkedAsCritical: true,
|
||||||
atRiskPasswordCount: 2,
|
atRiskPasswordCount: 2,
|
||||||
atRiskCipherIds: ["cid1", "cid2"],
|
atRiskCipherIds: ["cid1", "cid2"],
|
||||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
} as ApplicationHealthReportDetailEnriched,
|
||||||
{
|
{
|
||||||
isMarkedAsCritical: true,
|
isMarkedAsCritical: true,
|
||||||
atRiskPasswordCount: 1,
|
atRiskPasswordCount: 1,
|
||||||
atRiskCipherIds: ["cid2"],
|
atRiskCipherIds: ["cid2"],
|
||||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
} as ApplicationHealthReportDetailEnriched,
|
||||||
];
|
];
|
||||||
defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined);
|
defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined);
|
||||||
i18nServiceSpy.t.mockImplementation((key) => key);
|
i18nServiceSpy.t.mockImplementation((key) => key);
|
||||||
@@ -91,7 +91,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
|
|||||||
isMarkedAsCritical: true,
|
isMarkedAsCritical: true,
|
||||||
atRiskPasswordCount: 1,
|
atRiskPasswordCount: 1,
|
||||||
atRiskCipherIds: ["cid3"],
|
atRiskCipherIds: ["cid3"],
|
||||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
} as ApplicationHealthReportDetailEnriched,
|
||||||
];
|
];
|
||||||
defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail"));
|
defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail"));
|
||||||
i18nServiceSpy.t.mockImplementation((key) => key);
|
i18nServiceSpy.t.mockImplementation((key) => key);
|
||||||
@@ -113,7 +113,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
|
|||||||
isMarkedAsCritical: true,
|
isMarkedAsCritical: true,
|
||||||
atRiskPasswordCount: 0,
|
atRiskPasswordCount: 0,
|
||||||
atRiskCipherIds: ["cid4"],
|
atRiskCipherIds: ["cid4"],
|
||||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
} as ApplicationHealthReportDetailEnriched,
|
||||||
];
|
];
|
||||||
const result = await service.requestPasswordChange(organizationId, apps);
|
const result = await service.requestPasswordChange(organizationId, apps);
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
|
|||||||
isMarkedAsCritical: false,
|
isMarkedAsCritical: false,
|
||||||
atRiskPasswordCount: 2,
|
atRiskPasswordCount: 2,
|
||||||
atRiskCipherIds: ["cid5", "cid6"],
|
atRiskCipherIds: ["cid5", "cid6"],
|
||||||
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
} as ApplicationHealthReportDetailEnriched,
|
||||||
];
|
];
|
||||||
const result = await service.requestPasswordChange(organizationId, apps);
|
const result = await service.requestPasswordChange(organizationId, apps);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from "@angular/core";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AllActivitiesService,
|
AllActivitiesService,
|
||||||
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
|
ApplicationHealthReportDetailEnriched,
|
||||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
@@ -20,10 +20,7 @@ export class AccessIntelligenceSecurityTasksService {
|
|||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
) {}
|
) {}
|
||||||
async assignTasks(
|
async assignTasks(organizationId: OrganizationId, apps: ApplicationHealthReportDetailEnriched[]) {
|
||||||
organizationId: OrganizationId,
|
|
||||||
apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[],
|
|
||||||
) {
|
|
||||||
const taskCount = await this.requestPasswordChange(organizationId, apps);
|
const taskCount = await this.requestPasswordChange(organizationId, apps);
|
||||||
this.allActivitiesService.setTaskCreatedCount(taskCount);
|
this.allActivitiesService.setTaskCreatedCount(taskCount);
|
||||||
}
|
}
|
||||||
@@ -31,7 +28,7 @@ export class AccessIntelligenceSecurityTasksService {
|
|||||||
// TODO: this method is shared between here and critical-applications.component.ts
|
// TODO: this method is shared between here and critical-applications.component.ts
|
||||||
async requestPasswordChange(
|
async requestPasswordChange(
|
||||||
organizationId: OrganizationId,
|
organizationId: OrganizationId,
|
||||||
apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[],
|
apps: ApplicationHealthReportDetailEnriched[],
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
// Only create tasks for CRITICAL applications with at-risk passwords
|
// Only create tasks for CRITICAL applications with at-risk passwords
|
||||||
const cipherIds = apps
|
const cipherIds = apps
|
||||||
|
|||||||
Reference in New Issue
Block a user