From 03d636108d1d5a2fa23a6b11920a2a5b5fc76cf7 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:36:51 -0500 Subject: [PATCH] [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 --- .../helpers/risk-insights-data-mappers.ts | 176 ++--- .../risk-insights/models/api-models.types.ts | 36 +- .../models/drawer-models.types.ts | 44 ++ .../reports/risk-insights/models/index.ts | 1 + .../mocks}/ciphers.mock.ts | 0 .../member-cipher-details-response.mock.ts} | 86 +- .../models/{ => mocks}/mock-data.ts | 49 +- .../risk-insights/models/password-health.ts | 37 - .../risk-insights/models/report-models.ts | 92 +-- .../member-cipher-details.response.ts | 18 - .../critical-apps-api.service.spec.ts | 4 +- .../{ => api}/critical-apps-api.service.ts | 2 +- .../member-cipher-details-api.service.spec.ts | 38 + .../member-cipher-details-api.service.ts | 2 +- .../risk-insights-api.service.spec.ts | 20 +- .../{ => api}/risk-insights-api.service.ts | 20 +- .../security-tasks-api.service.spec.ts | 0 .../{ => api}/security-tasks-api.service.ts | 0 .../critical-apps.service.spec.ts | 10 +- .../{ => domain}/critical-apps.service.ts | 45 +- .../password-health.service.spec.ts | 0 .../{ => domain}/password-health.service.ts | 2 +- .../risk-insights-encryption.service.spec.ts | 7 +- .../risk-insights-encryption.service.ts | 131 +++- ...risk-insights-orchestrator.service.spec.ts | 224 ++++++ .../risk-insights-orchestrator.service.ts | 742 ++++++++++++++++++ .../risk-insights-report.service.spec.ts | 157 +--- .../domain/risk-insights-report.service.ts | 385 +++++++++ .../reports/risk-insights/services/index.ts | 20 +- .../member-cipher-details-response.mock.ts | 79 -- .../services/risk-insights-data.service.ts | 469 ----------- .../services/risk-insights-report.service.ts | 698 ---------------- .../{ => view}/all-activities.service.ts | 6 +- .../view/risk-insights-data.service.ts | 186 +++++ .../access-intelligence.module.ts | 27 +- .../activity-card.component.html | 0 .../{ => activity}/activity-card.component.ts | 0 .../password-change-metric.component.html | 0 .../password-change-metric.component.ts | 34 +- .../all-activity.component.html | 0 .../{ => activity}/all-activity.component.ts | 28 +- .../new-applications-dialog.component.html | 0 .../new-applications-dialog.component.ts | 0 .../all-applications.component.html | 0 .../all-applications.component.ts | 6 +- .../critical-applications.component.html | 0 .../critical-applications.component.ts | 9 +- .../models/activity.models.ts | 7 + .../models/risk-insights.models.ts | 8 + .../notified-members-table.component.html | 11 - .../notified-members-table.component.ts | 18 - .../risk-insights.component.html | 4 +- .../risk-insights.component.ts | 45 +- .../app-table-row-scrollable.component.html | 0 .../app-table-row-scrollable.component.ts | 0 .../risk-insights-loading.component.html | 0 .../risk-insights-loading.component.ts | 0 .../shared/security-tasks.service.spec.ts | 14 +- .../shared/security-tasks.service.ts | 9 +- 59 files changed, 2142 insertions(+), 1864 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/{services => models/mocks}/ciphers.mock.ts (100%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/{services/member-cipher-details-api.service.spec.ts => models/mocks/member-cipher-details-response.mock.ts} (56%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/{ => mocks}/mock-data.ts (75%) delete mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/critical-apps-api.service.spec.ts (96%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/critical-apps-api.service.ts (97%) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.spec.ts rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/member-cipher-details-api.service.ts (88%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/risk-insights-api.service.spec.ts (93%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/risk-insights-api.service.ts (87%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/security-tasks-api.service.spec.ts (100%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => api}/security-tasks-api.service.ts (100%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/critical-apps.service.spec.ts (96%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/critical-apps.service.ts (87%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/password-health.service.spec.ts (100%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/password-health.service.ts (99%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/risk-insights-encryption.service.spec.ts (97%) rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/risk-insights-encryption.service.ts (50%) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => domain}/risk-insights-report.service.spec.ts (57%) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-response.mock.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts rename bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/{ => view}/all-activities.service.ts (94%) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/activity-card.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/activity-card.component.ts (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/activity-cards/password-change-metric.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/activity-cards/password-change-metric.component.ts (88%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/all-activity.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/all-activity.component.ts (91%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/new-applications-dialog.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => activity}/new-applications-dialog.component.ts (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => all-applications}/all-applications.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => all-applications}/all-applications.component.ts (94%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => critical-applications}/critical-applications.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => critical-applications}/critical-applications.component.ts (93%) create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/risk-insights.models.ts delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.html delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.ts rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => shared}/app-table-row-scrollable.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => shared}/app-table-row-scrollable.component.ts (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => shared}/risk-insights-loading.component.html (100%) rename bitwarden_license/bit-web/src/app/dirt/access-intelligence/{ => shared}/risk-insights-loading.component.ts (100%) diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts index 6afb0ee6815..624b695e6be 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts @@ -2,20 +2,19 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { - LEGACY_MemberDetailsFlat, - LEGACY_CipherHealthReportDetail, - LEGACY_CipherHealthReportUriDetail, -} from "../models/password-health"; + AtRiskApplicationDetail, + AtRiskMemberDetail, + MemberCipherDetailsResponse, +} from "../models"; import { ApplicationHealthReportDetail, + MemberDetails, OrganizationReportSummary, - RiskInsightsData, } from "../models/report-models"; -import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; export function flattenMemberDetails( memberCiphers: MemberCipherDetailsResponse[], -): LEGACY_MemberDetailsFlat[] { +): MemberDetails[] { return memberCiphers.flatMap((member) => member.cipherIds.map((cipherId) => ({ userGuid: member.userGuid, @@ -48,9 +47,7 @@ export function getTrimmedCipherUris(cipher: CipherView): string[] { } // Returns a deduplicated array of members by email -export function getUniqueMembers( - orgMembers: LEGACY_MemberDetailsFlat[], -): LEGACY_MemberDetailsFlat[] { +export function getUniqueMembers(orgMembers: MemberDetails[]): MemberDetails[] { const existingEmails = new Set(); return orgMembers.filter((member) => { if (existingEmails.has(member.email)) { @@ -61,108 +58,6 @@ export function getUniqueMembers( }); } -/** - * Creates a flattened member details object - * @param userGuid User GUID - * @param userName User name - * @param email User email - * @param cipherId Cipher ID - * @returns Flattened member details - */ -export function getMemberDetailsFlat( - userGuid: string, - userName: string, - email: string, - cipherId: string, -): LEGACY_MemberDetailsFlat { - return { - userGuid: userGuid, - userName: userName, - email: email, - cipherId: cipherId, - }; -} - -/** - * Creates a flattened cipher details object for URI reporting - * @param detail Cipher health report detail - * @param uri Trimmed URI - * @returns Flattened cipher health details to URI - */ -export function getFlattenedCipherDetails( - detail: LEGACY_CipherHealthReportDetail, - uri: string, -): LEGACY_CipherHealthReportUriDetail { - return { - cipherId: detail.id, - reusedPasswordCount: detail.reusedPasswordCount, - weakPasswordDetail: detail.weakPasswordDetail, - exposedPasswordDetail: detail.exposedPasswordDetail, - cipherMembers: detail.cipherMembers, - trimmedUri: uri, - cipher: detail as CipherView, - }; -} - -/** - * Create the new application health report detail object with the details from the cipher health report uri detail object - * update or create the at risk values if the item is at risk. - * @param newUriDetail New cipher uri detail - * @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk - * @param existingUriDetail The previously processed Uri item - * @returns The new or updated application health report detail - */ -export function getApplicationReportDetail( - newUriDetail: LEGACY_CipherHealthReportUriDetail, - isAtRisk: boolean, - existingUriDetail?: ApplicationHealthReportDetail, -): ApplicationHealthReportDetail { - const reportDetail = { - applicationName: existingUriDetail - ? existingUriDetail.applicationName - : newUriDetail.trimmedUri, - passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1, - memberDetails: existingUriDetail - ? getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers)) - : newUriDetail.cipherMembers, - atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [], - atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0, - atRiskCipherIds: existingUriDetail ? existingUriDetail.atRiskCipherIds : [], - atRiskMemberCount: existingUriDetail ? existingUriDetail.atRiskMemberDetails.length : 0, - cipherIds: existingUriDetail - ? existingUriDetail.cipherIds.concat(newUriDetail.cipherId) - : [newUriDetail.cipherId], - } as ApplicationHealthReportDetail; - - if (isAtRisk) { - reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1; - reportDetail.atRiskCipherIds.push(newUriDetail.cipherId); - - reportDetail.atRiskMemberDetails = getUniqueMembers( - reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers), - ); - reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length; - } - - reportDetail.memberCount = reportDetail.memberDetails.length; - - return reportDetail; -} - -/** - * Create a new Risk Insights Report - * - * @returns An empty report - */ -export function createNewReportData(): RiskInsightsData { - return { - creationDate: new Date(), - reportData: [], - summaryData: createNewSummaryData(), - applicationData: [], - }; -} - /** * Create a new Risk Insights Report Summary * @@ -181,3 +76,60 @@ export function createNewSummaryData(): OrganizationReportSummary { newApplications: [], }; } +export function getAtRiskApplicationList( + cipherHealthReportDetails: ApplicationHealthReportDetail[], +): AtRiskApplicationDetail[] { + const applicationPasswordRiskMap = new Map(); + + 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(); + + 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 { + const passwordUseMap = new Map(); + ciphers.forEach((cipher) => { + const password = cipher.login.password!; + passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1); + }); + return passwordUseMap; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts index 871db2b68ac..529789ebb8d 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts @@ -1,6 +1,6 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { BaseResponse } from "@bitwarden/common/models/response/base.response"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { createNewSummaryData } from "../helpers"; @@ -46,11 +46,11 @@ export interface SaveRiskInsightsReportRequest { } export class SaveRiskInsightsReportResponse extends BaseResponse { - id: string; + id: OrganizationReportId; constructor(response: any) { super(response); - this.id = this.getResponseProperty("organizationId"); + this.id = this.getResponseProperty("id"); } } export function isSaveRiskInsightsReportResponse(obj: any): obj is SaveRiskInsightsReportResponse { @@ -69,7 +69,7 @@ export class GetRiskInsightsReportResponse extends BaseResponse { constructor(response: any) { super(response); - this.id = this.getResponseProperty("organizationId"); + this.id = this.getResponseProperty("id"); this.organizationId = this.getResponseProperty("organizationId"); this.creationDate = new Date(this.getResponseProperty("creationDate")); this.reportData = new EncString(this.getResponseProperty("reportData")); @@ -113,3 +113,31 @@ export class GetRiskInsightsApplicationDataResponse extends BaseResponse { this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); } } + +export class MemberCipherDetailsResponse extends BaseResponse { + userGuid: string; + userName: string; + email: string; + useKeyConnector: boolean; + cipherIds: string[] = []; + + constructor(response: any) { + super(response); + this.userGuid = this.getResponseProperty("UserGuid"); + this.userName = this.getResponseProperty("UserName"); + this.email = this.getResponseProperty("Email"); + this.useKeyConnector = this.getResponseProperty("UseKeyConnector"); + this.cipherIds = this.getResponseProperty("CipherIds"); + } +} + +export interface UpdateRiskInsightsApplicationDataRequest { + data: { + applicationData: string; + }; +} +export class UpdateRiskInsightsApplicationDataResponse extends BaseResponse { + constructor(response: any) { + super(response); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts new file mode 100644 index 00000000000..dffb22af3ee --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts @@ -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; +}; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts index abe1f7200dc..c0364b12f87 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts @@ -3,3 +3,4 @@ export * from "./password-health"; export * from "./report-data-service.types"; export * from "./report-encryption.types"; export * from "./report-models"; +export * from "./drawer-models.types"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/ciphers.mock.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/ciphers.mock.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/ciphers.mock.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/ciphers.mock.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/member-cipher-details-response.mock.ts similarity index 56% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/member-cipher-details-response.mock.ts index d6474c2c9c4..0f14642dc6b 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/member-cipher-details-response.mock.ts @@ -1,109 +1,83 @@ import { mock } from "jest-mock-extended"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { MemberCipherDetailsResponse } from ".."; -import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; - -export const mockMemberCipherDetails: any = [ - { +export const mockMemberCipherDetailsResponse: MemberCipherDetailsResponse[] = [ + mock({ + userGuid: "user-1", userName: "David Brent", email: "david.brent@wernhamhogg.uk", - usesKeyConnector: true, + useKeyConnector: true, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228ab1", "cbea34a8-bde4-46ad-9d19-b05001228ab2", "cbea34a8-bde4-46ad-9d19-b05001228xy4", "cbea34a8-bde4-46ad-9d19-b05001227nm5", ], - }, - { + }), + mock({ + userGuid: "user-2", userName: "Tim Canterbury", email: "tim.canterbury@wernhamhogg.uk", - usesKeyConnector: false, + useKeyConnector: false, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228ab2", "cbea34a8-bde4-46ad-9d19-b05001228cd3", "cbea34a8-bde4-46ad-9d19-b05001228xy4", "cbea34a8-bde4-46ad-9d19-b05001227nm5", ], - }, - { + }), + mock({ + userGuid: "user-3", userName: "Gareth Keenan", email: "gareth.keenan@wernhamhogg.uk", - usesKeyConnector: true, + useKeyConnector: true, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228cd3", "cbea34a8-bde4-46ad-9d19-b05001228xy4", "cbea34a8-bde4-46ad-9d19-b05001227nm5", "cbea34a8-bde4-46ad-9d19-b05001227nm7", ], - }, - { + }), + mock({ + userGuid: "user-4", userName: "Dawn Tinsley", email: "dawn.tinsley@wernhamhogg.uk", - usesKeyConnector: true, + useKeyConnector: true, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228ab2", "cbea34a8-bde4-46ad-9d19-b05001228cd3", "cbea34a8-bde4-46ad-9d19-b05001228xy4", ], - }, - { + }), + mock({ + userGuid: "user-5", userName: "Keith Bishop", email: "keith.bishop@wernhamhogg.uk", - usesKeyConnector: false, + useKeyConnector: false, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228ab1", "cbea34a8-bde4-46ad-9d19-b05001228cd3", "cbea34a8-bde4-46ad-9d19-b05001228xy4", "cbea34a8-bde4-46ad-9d19-b05001227nm5", ], - }, - { + }), + mock({ + userGuid: "user-1", userName: "Chris Finch", email: "chris.finch@wernhamhogg.uk", - usesKeyConnector: true, + useKeyConnector: true, cipherIds: [ "cbea34a8-bde4-46ad-9d19-b05001228ab2", "cbea34a8-bde4-46ad-9d19-b05001228cd3", "cbea34a8-bde4-46ad-9d19-b05001228xy4", ], - }, - { + }), + mock({ + userGuid: "user-1", userName: "Mister Secure", email: "mister.secure@secureco.com", - usesKeyConnector: true, + useKeyConnector: true, cipherIds: ["cbea34a8-bde4-46ad-9d19-b05001227tt1"], - }, + }), ]; - -describe("Member Cipher Details API Service", () => { - let memberCipherDetailsApiService: MemberCipherDetailsApiService; - - const apiService = mock(); - - 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, - ); - }); -}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts similarity index 75% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts index c790fc327a9..1a30ad754c3 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts @@ -3,14 +3,15 @@ import { mock } from "jest-mock-extended"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; - -import { ApplicationHealthReportDetailEnriched } from "./report-data-service.types"; +import { MemberCipherDetailsResponse } from ".."; +import { ApplicationHealthReportDetailEnriched } from "../report-data-service.types"; import { ApplicationHealthReportDetail, + CipherHealthReport, OrganizationReportApplication, OrganizationReportSummary, -} from "./report-models"; + PasswordHealthData, +} from "../report-models"; const mockApplication1: ApplicationHealthReportDetail = { applicationName: "application1.com", @@ -82,10 +83,12 @@ export const mockApplicationData: OrganizationReportApplication[] = [ { applicationName: "application1.com", isCritical: true, + reviewedDate: new Date(), }, { applicationName: "application2.com", isCritical: false, + reviewedDate: null, }, ]; @@ -138,3 +141,41 @@ export const mockMemberDetails = [ email: "user3@other.com", }), ]; + +export const mockCipherHealthReports: CipherHealthReport[] = [ + { + applications: ["app.com"], + cipherMembers: [], + healthData: createPasswordHealthData(0), + cipher: mockCipherViews[0], + }, + { + applications: ["app.com"], + cipherMembers: [], + healthData: createPasswordHealthData(1), + cipher: mockCipherViews[1], + }, + { + applications: ["other.com"], + cipherMembers: [], + healthData: createPasswordHealthData(2), + cipher: mockCipherViews[2], + }, +]; + +function createPasswordHealthData(reusedPasswordCount: number | null): PasswordHealthData { + return { + reusedPasswordCount: reusedPasswordCount ?? 0, + weakPasswordDetail: { + score: 0, + detailValue: { + label: "", + badgeVariant: "info", + }, + }, + exposedPasswordDetail: { + cipherId: "", + exposedXTimes: 0, + }, + }; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts index 8127ea41085..8406a9107b8 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts @@ -1,10 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant } from "@bitwarden/components"; -import { ApplicationHealthReportDetail } from "./report-models"; - /** * Weak password details containing the score * and the score type for the label and badge @@ -30,37 +27,3 @@ export type ExposedPasswordDetail = { cipherId: string; exposedXTimes: number; } | null; - -export type LEGACY_MemberDetailsFlat = { - userGuid: string; - userName: string; - email: string; - cipherId: string; -}; - -export type LEGACY_ApplicationHealthReportDetailWithCriticalFlag = ApplicationHealthReportDetail & { - isMarkedAsCritical: boolean; -}; - -export type LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher = - LEGACY_ApplicationHealthReportDetailWithCriticalFlag & { - ciphers: CipherView[]; - }; - -export type LEGACY_CipherHealthReportDetail = CipherView & { - reusedPasswordCount: number; - weakPasswordDetail: WeakPasswordDetail; - exposedPasswordDetail: ExposedPasswordDetail; - cipherMembers: LEGACY_MemberDetailsFlat[]; - trimmedUris: string[]; -}; - -export type LEGACY_CipherHealthReportUriDetail = { - cipherId: string; - reusedPasswordCount: number; - weakPasswordDetail: WeakPasswordDetail; - exposedPasswordDetail: ExposedPasswordDetail; - cipherMembers: LEGACY_MemberDetailsFlat[]; - trimmedUri: string; - cipher: CipherView; -}; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts index 564f483813a..93955c7dbfb 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts @@ -1,44 +1,13 @@ import { Opaque } from "type-fest"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { OrganizationReportId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant } from "@bitwarden/components"; import { ExposedPasswordDetail, WeakPasswordDetail } from "./password-health"; -// -------------------- Drawer and UI Models -------------------- -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum DrawerType { - None = 0, - AppAtRiskMembers = 1, - OrgAtRiskMembers = 2, - OrgAtRiskApps = 3, -} - -export type DrawerDetails = { - open: boolean; - invokerId: string; - activeDrawerType: DrawerType; - atRiskMemberDetails?: AtRiskMemberDetail[]; - appAtRiskMembers?: AppAtRiskMembersDialogParams | null; - atRiskAppDetails?: AtRiskApplicationDetail[] | null; -}; - -export type AppAtRiskMembersDialogParams = { - members: MemberDetails[]; - applicationName: string; -}; - // -------------------- Member Models -------------------- -/** - * Member email with the number of at risk passwords - * At risk member detail that contains the email - * and the count of at risk ciphers - */ -export type AtRiskMemberDetail = { - email: string; - atRiskPasswordCount: number; -}; /** * Flattened member details that associates an @@ -71,18 +40,6 @@ export type CipherHealthReport = { cipher: CipherView; }; -/** - * Breaks the cipher health info out by uri and passes - * along the password health and member info - */ -export type CipherApplicationView = { - cipherId: string; - cipher: CipherView; - cipherMembers: MemberDetails[]; - application: string; - healthData: PasswordHealthData; -}; - // -------------------- Application Health Report Models -------------------- /** * All applications report summary. The total members, @@ -91,21 +48,16 @@ export type CipherApplicationView = { */ export type OrganizationReportSummary = { totalMemberCount: number; - totalCriticalMemberCount: number; - totalAtRiskMemberCount: number; - totalCriticalAtRiskMemberCount: number; totalApplicationCount: number; - totalCriticalApplicationCount: number; + totalAtRiskMemberCount: number; totalAtRiskApplicationCount: number; + totalCriticalApplicationCount: number; + totalCriticalMemberCount: number; + totalCriticalAtRiskMemberCount: number; totalCriticalAtRiskApplicationCount: number; newApplications: string[]; }; -export type CriticalSummaryDetails = { - totalCriticalMembersCount: number; - totalCriticalApplicationsCount: number; -}; - /** * An entry for an organization application and if it is * marked as critical @@ -113,6 +65,11 @@ export type CriticalSummaryDetails = { export type OrganizationReportApplication = { applicationName: string; isCritical: boolean; + /** + * Captures when a report has been reviewed by a user and + * can be filtered on to check for new applications + * */ + reviewedDate: Date | null; }; /** @@ -131,15 +88,6 @@ export type ApplicationHealthReportDetail = { cipherIds: string[]; }; -/* - * A list of applications and the count of - * at risk passwords for each application - */ -export type AtRiskApplicationDetail = { - applicationName: string; - atRiskPasswordCount: number; -}; - // -------------------- Password Health Report Models -------------------- export type PasswordHealthReportApplicationId = Opaque; @@ -152,8 +100,26 @@ export type ReportResult = CipherView & { }; export interface RiskInsightsData { + id: OrganizationReportId; creationDate: Date; + contentEncryptionKey: EncString; reportData: ApplicationHealthReportDetail[]; summaryData: OrganizationReportSummary; applicationData: OrganizationReportApplication[]; } + +export interface ReportState { + loading: boolean; + error: string | null; + data: RiskInsightsData | null; +} + +// TODO Make Versioned models for structure changes +// export type VersionedRiskInsightsData = RiskInsightsDataV1 | RiskInsightsDataV2; +// export interface RiskInsightsDataV1 { +// version: 1; +// creationDate: Date; +// reportData: ApplicationHealthReportDetail[]; +// summaryData: OrganizationReportSummary; +// applicationData: OrganizationReportApplication[]; +// } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts deleted file mode 100644 index 7aa52330663..00000000000 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts +++ /dev/null @@ -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"); - } -} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.spec.ts similarity index 96% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.spec.ts index f53bf92c47f..5880d81fed2 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.spec.ts @@ -7,8 +7,8 @@ import { PasswordHealthReportApplicationDropRequest, PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/api-models.types"; -import { PasswordHealthReportApplicationId } from "../models/report-models"; +} from "../../models/api-models.types"; +import { PasswordHealthReportApplicationId } from "../../models/report-models"; import { CriticalAppsApiService } from "./critical-apps-api.service"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.ts similarity index 97% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.ts index 29d2364f302..b3e378389d6 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/critical-apps-api.service.ts @@ -7,7 +7,7 @@ import { PasswordHealthReportApplicationDropRequest, PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/api-models.types"; +} from "../../models/api-models.types"; export class CriticalAppsApiService { constructor(private apiService: ApiService) {} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.spec.ts new file mode 100644 index 00000000000..beb19c91c13 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.spec.ts @@ -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(); + + 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, + ); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.ts similarity index 88% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.ts index b38f8712add..66f40ffc5a4 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/member-cipher-details-api.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; +import { MemberCipherDetailsResponse } from "../../models"; @Injectable() export class MemberCipherDetailsApiService { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.spec.ts similarity index 93% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.spec.ts index 56246f3c3b6..879563af526 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.spec.ts @@ -7,17 +7,16 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response" import { makeEncString } from "@bitwarden/common/spec"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; -import { EncryptedDataWithKey } from "../models"; +import { EncryptedDataWithKey } from "../../models"; import { GetRiskInsightsApplicationDataResponse, GetRiskInsightsReportResponse, GetRiskInsightsSummaryResponse, SaveRiskInsightsReportRequest, SaveRiskInsightsReportResponse, -} from "../models/api-models.types"; -import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data"; - -import { RiskInsightsApiService } from "./risk-insights-api.service"; +} from "../../models/api-models.types"; +import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data"; +import { RiskInsightsApiService } from "../api/risk-insights-api.service"; describe("RiskInsightsApiService", () => { let service: RiskInsightsApiService; @@ -229,19 +228,22 @@ describe("RiskInsightsApiService", () => { it("updateRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => { const reportId = "report123" as OrganizationReportId; - const mockApplication = mockApplicationData[0]; + // TODO Update to be encrypted test + const mockApplication = makeEncString("application-data"); mockApiService.send.mockResolvedValueOnce(undefined); const result = await firstValueFrom( - service.updateRiskInsightsApplicationData$(mockApplication, orgId, reportId), + service.updateRiskInsightsApplicationData$(reportId, orgId, { + data: { applicationData: mockApplication.encryptedString! }, + }), ); expect(mockApiService.send).toHaveBeenCalledWith( "PATCH", `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, - mockApplication, + { applicationData: mockApplication.encryptedString!, id: reportId, organizationId: orgId }, true, true, ); - expect(result).toBeUndefined(); + expect(result).toBeTruthy(); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.ts similarity index 87% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.ts index 99bf27506be..d1896f487b2 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/risk-insights-api.service.ts @@ -4,14 +4,18 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; -import { EncryptedDataWithKey, OrganizationReportApplication } from "../models"; +import { + EncryptedDataWithKey, + UpdateRiskInsightsApplicationDataRequest, + UpdateRiskInsightsApplicationDataResponse, +} from "../../models"; import { GetRiskInsightsApplicationDataResponse, GetRiskInsightsReportResponse, GetRiskInsightsSummaryResponse, SaveRiskInsightsReportRequest, SaveRiskInsightsReportResponse, -} from "../models/api-models.types"; +} from "../../models/api-models.types"; export class RiskInsightsApiService { constructor(private apiService: ApiService) {} @@ -102,18 +106,20 @@ export class RiskInsightsApiService { } updateRiskInsightsApplicationData$( - applicationData: OrganizationReportApplication, - orgId: OrganizationId, reportId: OrganizationReportId, - ): Observable { + orgId: OrganizationId, + request: UpdateRiskInsightsApplicationDataRequest, + ): Observable { const dbResponse = this.apiService.send( "PATCH", `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, - applicationData, + { ...request.data, id: reportId, organizationId: orgId }, true, true, ); - return from(dbResponse as Promise); + return from(dbResponse).pipe( + map((response) => new UpdateRiskInsightsApplicationDataResponse(response)), + ); } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.spec.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.spec.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/security-tasks-api.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/api/security-tasks-api.service.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.spec.ts similarity index 96% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.spec.ts index 28d670f226d..d69814572c7 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.spec.ts @@ -14,10 +14,10 @@ import { KeyService } from "@bitwarden/key-management"; import { PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/api-models.types"; -import { PasswordHealthReportApplicationId } from "../models/report-models"; +} from "../../models/api-models.types"; +import { PasswordHealthReportApplicationId } from "../../models/report-models"; +import { CriticalAppsApiService } from "../api/critical-apps-api.service"; -import { CriticalAppsApiService } from "./critical-apps-api.service"; import { CriticalAppsService } from "./critical-apps.service"; const SomeCsprngArray = new Uint8Array(64) as CsprngArray; @@ -181,7 +181,7 @@ describe("CriticalAppsService", () => { privateCriticalAppsSubject.next(initialList); // act - await service.dropCriticalApp(SomeOrganization, selectedUrl); + await service.dropCriticalAppByUrl(SomeOrganization, selectedUrl); // expectations expect(criticalAppsApiService.dropCriticalApp).toHaveBeenCalledWith({ @@ -213,7 +213,7 @@ describe("CriticalAppsService", () => { privateCriticalAppsSubject.next(initialList); // act - await service.dropCriticalApp(SomeOrganization, selectedUrl); + await service.dropCriticalAppByUrl(SomeOrganization, selectedUrl); // expectations expect(criticalAppsApiService.dropCriticalApp).not.toHaveBeenCalled(); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.ts similarity index 87% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.ts index b3b2f7c44e8..d310b3aeaac 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/critical-apps.service.ts @@ -1,13 +1,17 @@ import { BehaviorSubject, + catchError, filter, first, firstValueFrom, forkJoin, + from, map, Observable, of, switchMap, + tap, + throwError, zip, } from "rxjs"; @@ -20,9 +24,8 @@ import { KeyService } from "@bitwarden/key-management"; import { PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "../models/api-models.types"; - -import { CriticalAppsApiService } from "./critical-apps-api.service"; +} from "../../models/api-models.types"; +import { CriticalAppsApiService } from "../api/critical-apps-api.service"; /* Retrieves and decrypts critical apps for a given organization * Encrypts and saves data for a given organization @@ -125,9 +128,14 @@ export class CriticalAppsService { this.criticalAppsListSubject$.next(updatedList); } - // Drop a critical app for a given organization - // Only one app may be dropped at a time - async dropCriticalApp(orgId: OrganizationId, selectedUrl: string) { + /** + * Drop a critical application by url + * + * @param orgId + * @param selectedUrl + * @returns + */ + async dropCriticalAppByUrl(orgId: OrganizationId, selectedUrl: string) { if (orgId != this.organizationId.value) { throw new Error("Organization ID mismatch"); } @@ -150,6 +158,31 @@ export class CriticalAppsService { ); } + /** + * Drop multiple critical applications by id + * + * @param orgId + * @param ids + * @returns + */ + dropCriticalAppsById(orgId: OrganizationId, ids: string[]) { + return from( + this.criticalAppsApiService.dropCriticalApp({ + organizationId: orgId, + passwordHealthReportApplicationIds: ids, + }), + ).pipe( + tap((response) => { + this.criticalAppsListSubject$.next( + this.criticalAppsListSubject$.value.filter((f) => ids.some((id) => id === f.id)), + ); + }), + catchError((error: unknown) => { + return throwError(() => error); + }), + ); + } + private retrieveCriticalApps( orgId: OrganizationId | null, ): Observable { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.spec.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.spec.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts similarity index 99% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts index 2ad9f1c7cfd..267c1dc9563 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts @@ -10,7 +10,7 @@ import { ExposedPasswordDetail, WeakPasswordDetail, WeakPasswordScore, -} from "../models/password-health"; +} from "../../models/password-health"; export class PasswordHealthService { constructor( diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts similarity index 97% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts index e2c92ad4b9b..2efd97b3c30 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts @@ -9,9 +9,10 @@ import { makeSymmetricCryptoKey } from "@bitwarden/common/spec"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; -import { EncryptedReportData, DecryptedReportData } from "../models"; -import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data"; +import { EncryptedReportData, DecryptedReportData } from "../../models"; +import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; @@ -20,6 +21,7 @@ describe("RiskInsightsEncryptionService", () => { const mockKeyService = mock(); const mockEncryptService = mock(); const mockKeyGenerationService = mock(); + const mockLogService = mock(); const ENCRYPTED_TEXT = "This data has been encrypted"; const ENCRYPTED_KEY = "Re-encrypted Cipher Key"; @@ -43,6 +45,7 @@ describe("RiskInsightsEncryptionService", () => { mockKeyService, mockEncryptService, mockKeyGenerationService, + mockLogService, ); jest.clearAllMocks(); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts similarity index 50% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts index 04811f9cfcd..5206cd1ecff 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts @@ -6,14 +6,24 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; -import { DecryptedReportData, EncryptedReportData, EncryptedDataWithKey } from "../models"; +import { createNewSummaryData } from "../../helpers"; +import { + DecryptedReportData, + EncryptedReportData, + EncryptedDataWithKey, + ApplicationHealthReportDetail, + OrganizationReportSummary, + OrganizationReportApplication, +} from "../../models"; export class RiskInsightsEncryptionService { constructor( private keyService: KeyService, private encryptService: EncryptService, private keyGeneratorService: KeyGenerationService, + private logService: LogService, ) {} async encryptRiskInsightsReport( @@ -24,6 +34,7 @@ export class RiskInsightsEncryptionService { data: DecryptedReportData, wrappedKey?: EncString, ): Promise { + this.logService.info("[RiskInsightsEncryptionService] Encrypting risk insights report"); const { userId, organizationId } = context; const orgKey = await firstValueFrom( this.keyService @@ -36,16 +47,24 @@ export class RiskInsightsEncryptionService { ); if (!orgKey) { + this.logService.warning( + "[RiskInsightsEncryptionService] Attempted to encrypt report data without org id", + ); throw new Error("Organization key not found"); } let contentEncryptionKey: SymmetricCryptoKey; - if (!wrappedKey) { - // Generate a new key - contentEncryptionKey = await this.keyGeneratorService.createKey(512); - } else { - // Unwrap the existing key - contentEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey); + try { + if (!wrappedKey) { + // Generate a new key + contentEncryptionKey = await this.keyGeneratorService.createKey(512); + } else { + // Unwrap the existing key + contentEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey); + } + } catch (error: unknown) { + this.logService.error("[RiskInsightsEncryptionService] Failed to get encryption key", error); + throw new Error("Failed to get encryption key"); } const { reportData, summaryData, applicationData } = data; @@ -75,6 +94,9 @@ export class RiskInsightsEncryptionService { !encryptedApplicationData.encryptedString || !wrappedEncryptionKey.encryptedString ) { + this.logService.error( + "[RiskInsightsEncryptionService] Encryption failed, encrypted strings are null", + ); throw new Error("Encryption failed, encrypted strings are null"); } @@ -97,6 +119,8 @@ export class RiskInsightsEncryptionService { encryptedData: EncryptedReportData, wrappedKey: EncString, ): Promise { + this.logService.info("[RiskInsightsEncryptionService] Decrypting risk insights report"); + const { userId, organizationId } = context; const orgKey = await firstValueFrom( this.keyService @@ -109,47 +133,106 @@ export class RiskInsightsEncryptionService { ); if (!orgKey) { + this.logService.warning( + "[RiskInsightsEncryptionService] Attempted to decrypt report data without org id", + ); throw new Error("Organization key not found"); } const unwrappedEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey); if (!unwrappedEncryptionKey) { + this.logService.error("[RiskInsightsEncryptionService] Encryption key not found"); throw Error("Encryption key not found"); } const { encryptedReportData, encryptedSummaryData, encryptedApplicationData } = encryptedData; - if (!encryptedReportData || !encryptedSummaryData || !encryptedApplicationData) { - throw new Error("Missing data"); - } // Decrypt the data - const decryptedReportData = await this.encryptService.decryptString( + const decryptedReportData = await this._handleDecryptReport( encryptedReportData, unwrappedEncryptionKey, ); - const decryptedSummaryData = await this.encryptService.decryptString( + const decryptedSummaryData = await this._handleDecryptSummary( encryptedSummaryData, unwrappedEncryptionKey, ); - const decryptedApplicationData = await this.encryptService.decryptString( + const decryptedApplicationData = await this._handleDecryptApplication( encryptedApplicationData, unwrappedEncryptionKey, ); - if (!decryptedReportData || !decryptedSummaryData || !decryptedApplicationData) { - throw new Error("Decryption failed, decrypted strings are null"); - } - - const decryptedReportDataJson = JSON.parse(decryptedReportData); - const decryptedSummaryDataJson = JSON.parse(decryptedSummaryData); - const decryptedApplicationDataJson = JSON.parse(decryptedApplicationData); - const decryptedFullReport = { - reportData: decryptedReportDataJson, - summaryData: decryptedSummaryDataJson, - applicationData: decryptedApplicationDataJson, + reportData: decryptedReportData, + summaryData: decryptedSummaryData, + applicationData: decryptedApplicationData, }; return decryptedFullReport; } + + private async _handleDecryptReport( + encryptedData: EncString | null, + key: SymmetricCryptoKey, + ): Promise { + 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 { + 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 { + 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 []; + } + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts new file mode 100644 index 00000000000..7606e3af7f3 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.spec.ts @@ -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({ + activeAccount$: of(mock({ id: mockUserId })), + }); + const mockCriticalAppsService = mock({ + criticalAppsList$: of([]), + }); + const mockOrganizationService = mock(); + const mockCipherService = mock(); + const mockMemberCipherDetailsApiService = mock(); + let mockPasswordHealthService: PasswordHealthService; + const mockReportApiService = mock(); + let mockReportService: RiskInsightsReportService; + const mockRiskInsightsEncryptionService = mock(); + const mockLogService = mock(); + + beforeEach(() => { + // Mock pipes from constructor + mockReportService = mock({ + 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({ + 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(); + }); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts new file mode 100644 index 00000000000..b9df2748e85 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts @@ -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(); + + // -------------------------- Context state -------------------------- + // Current user viewing risk insights + private _userIdSubject = new BehaviorSubject(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(null); + private _ciphers$ = this._ciphersSubject.asObservable(); + + // ------------------------- Report Variables ---------------- + private _rawReportDataSubject = new BehaviorSubject({ + loading: true, + error: null, + data: null, + }); + rawReportData$ = this._rawReportDataSubject.asObservable(); + private _enrichedReportDataSubject = new BehaviorSubject(null); + enrichedReportData$ = this._enrichedReportDataSubject.asObservable(); + + // Generate report trigger and state + private _generateReportTriggerSubject = new BehaviorSubject(false); + generatingReport$ = this._generateReportTriggerSubject.asObservable(); + + // --------------------------- Critical Application data --------------------- + criticalReportResults$: Observable = of(null); + + // --------------------------- Trigger subjects --------------------- + private _initializeOrganizationTriggerSubject = new Subject(); + private _fetchReportTriggerSubject = new Subject(); + + 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 { + 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 { + 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 { + 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 { + // 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 { + 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, + ): 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); + }); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts similarity index 57% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts index 5f8fdaa244a..3211b44322a 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts @@ -6,24 +6,25 @@ import { makeEncString } from "@bitwarden/common/spec"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { DecryptedReportData, EncryptedDataWithKey } from "../models"; +import { DecryptedReportData, EncryptedDataWithKey } from "../../models"; import { GetRiskInsightsReportResponse, SaveRiskInsightsReportResponse, -} from "../models/api-models.types"; +} from "../../models/api-models.types"; +import { mockCiphers } from "../../models/mocks/ciphers.mock"; +import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock"; import { mockApplicationData, + mockCipherHealthReports, mockCipherViews, mockMemberDetails, mockReportData, mockSummaryData, -} from "../models/mock-data"; +} from "../../models/mocks/mock-data"; +import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service"; +import { RiskInsightsApiService } from "../api/risk-insights-api.service"; -import { mockCiphers } from "./ciphers.mock"; -import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; -import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec"; import { PasswordHealthService } from "./password-health.service"; -import { RiskInsightsApiService } from "./risk-insights-api.service"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; import { RiskInsightsReportService } from "./risk-insights-report.service"; @@ -54,7 +55,9 @@ describe("RiskInsightsReportService", () => { beforeEach(() => { cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers); - memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberCipherDetails); + memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue( + mockMemberCipherDetailsResponse, + ); // Mock PasswordHealthService methods mockPasswordHealthService.isValidCipher.mockImplementation((cipher: any) => { @@ -79,9 +82,6 @@ describe("RiskInsightsReportService", () => { }); service = new RiskInsightsReportService( - cipherService, - memberCipherDetailsService, - mockPasswordHealthService, mockRiskInsightsApiService, mockRiskInsightsEncryptionService, ); @@ -93,127 +93,21 @@ describe("RiskInsightsReportService", () => { }; }); - it("should group and aggregate application health reports correctly", (done) => { + it("should group and aggregate application health reports correctly", () => { // Mock the service methods cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCipherViews); memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberDetails); - service.generateApplicationsReport$("orgId" as any).subscribe((result) => { - expect(Array.isArray(result)).toBe(true); + const result = service.generateApplicationsReport(mockCipherHealthReports); + expect(Array.isArray(result)).toBe(true); - // Should group by application name (trimmedUris) - const appCom = result.find((r) => r.applicationName === "app.com"); - const otherCom = result.find((r) => r.applicationName === "other.com"); - expect(appCom).toBeTruthy(); - expect(appCom?.passwordCount).toBe(2); - expect(otherCom).toBeTruthy(); - expect(otherCom?.passwordCount).toBe(1); - done(); - }); - }); - - it("should generate the raw data report correctly", async () => { - const result = await firstValueFrom(service.LEGACY_generateRawDataReport$(mockOrganizationId)); - - expect(result).toHaveLength(6); - - let testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001228ab1"); - expect(testCaseResults).toHaveLength(1); - let testCase = testCaseResults[0]; - expect(testCase).toBeTruthy(); - expect(testCase.cipherMembers).toHaveLength(2); - expect(testCase.trimmedUris).toHaveLength(5); - expect(testCase.weakPasswordDetail).toBeTruthy(); - expect(testCase.exposedPasswordDetail).toBeTruthy(); - expect(testCase.reusedPasswordCount).toEqual(2); - - testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001227tt1"); - expect(testCaseResults).toHaveLength(1); - testCase = testCaseResults[0]; - expect(testCase).toBeTruthy(); - expect(testCase.cipherMembers).toHaveLength(1); - expect(testCase.trimmedUris).toHaveLength(1); - expect(testCase.weakPasswordDetail).toBeFalsy(); - expect(testCase.exposedPasswordDetail).toBeFalsy(); - expect(testCase.reusedPasswordCount).toEqual(1); - }); - - it("should generate the raw data + uri report correctly", async () => { - const result = await firstValueFrom(service.generateRawDataUriReport$(mockOrganizationId)); - - expect(result).toHaveLength(11); - - // Two ciphers that have google.com as their uri. There should be 2 results - const googleResults = result.filter((x) => x.trimmedUri === "google.com"); - expect(googleResults).toHaveLength(2); - - // There is an invalid uri and it should not be trimmed - const invalidUriResults = result.filter((x) => x.trimmedUri === "this_is-not|a-valid-uri123@+"); - expect(invalidUriResults).toHaveLength(1); - - // Verify the details for one of the googles matches the password health info - // expected - const firstGoogle = googleResults.filter( - (x) => x.cipherId === "cbea34a8-bde4-46ad-9d19-b05001228ab1" && x.trimmedUri === "google.com", - )[0]; - expect(firstGoogle.weakPasswordDetail).toBeTruthy(); - expect(firstGoogle.exposedPasswordDetail).toBeTruthy(); - expect(firstGoogle.reusedPasswordCount).toEqual(2); - }); - - it("should generate applications health report data correctly", async () => { - const result = await firstValueFrom( - service.LEGACY_generateApplicationsReport$(mockOrganizationId), - ); - - expect(result).toHaveLength(8); - - // Two ciphers have google.com associated with them. The first cipher - // has 2 members and the second has 4. However, the 2 members in the first - // cipher are also associated with the second. The total amount of members - // should be 4 not 6 - const googleTestResults = result.filter((x) => x.applicationName === "google.com"); - expect(googleTestResults).toHaveLength(1); - const googleTest = googleTestResults[0]; - expect(googleTest.memberCount).toEqual(4); - - // Both ciphers have at risk passwords - expect(googleTest.passwordCount).toEqual(2); - - // All members are at risk since both ciphers are at risk - expect(googleTest.atRiskMemberDetails).toHaveLength(4); - expect(googleTest.atRiskPasswordCount).toEqual(2); - - // There are 2 ciphers associated with 101domain.com - const domain101TestResults = result.filter((x) => x.applicationName === "101domain.com"); - expect(domain101TestResults).toHaveLength(1); - const domain101Test = domain101TestResults[0]; - expect(domain101Test.passwordCount).toEqual(2); - - // The first cipher is at risk. The second cipher is not at risk - expect(domain101Test.atRiskPasswordCount).toEqual(1); - - // The first cipher has 2 members. The second cipher the second - // cipher has 4. One of the members in the first cipher is associated - // with the second. So there should be 5 members total. - expect(domain101Test.memberCount).toEqual(5); - - // The first cipher is at risk. The total at risk members is 2 and - // at risk password count is 1. - expect(domain101Test.atRiskMemberDetails).toHaveLength(2); - expect(domain101Test.atRiskPasswordCount).toEqual(1); - }); - - it("should generate applications summary data correctly", async () => { - const reportResult = await firstValueFrom( - service.LEGACY_generateApplicationsReport$(mockOrganizationId), - ); - const reportSummary = service.generateApplicationsSummary(reportResult); - - expect(reportSummary.totalMemberCount).toEqual(7); - expect(reportSummary.totalAtRiskMemberCount).toEqual(6); - expect(reportSummary.totalApplicationCount).toEqual(8); - expect(reportSummary.totalAtRiskApplicationCount).toEqual(7); + // Should group by application name (trimmedUris) + const appCom = result.find((r) => r.applicationName === "app.com"); + const otherCom = result.find((r) => r.applicationName === "other.com"); + expect(appCom).toBeTruthy(); + expect(appCom?.passwordCount).toBe(2); + expect(otherCom).toBeTruthy(); + expect(otherCom?.passwordCount).toBe(1); }); describe("saveRiskInsightsReport$", () => { @@ -249,8 +143,6 @@ describe("RiskInsightsReportService", () => { }, }); }); - - it("should encrypt and save report, then update subjects", async () => {}); }); describe("getRiskInsightsReport$", () => { @@ -338,7 +230,12 @@ describe("RiskInsightsReportService", () => { expect.anything(), expect.anything(), ); - expect(result).toEqual({ ...mockDecryptedData, creationDate: mockResponse.creationDate }); + expect(result).toEqual({ + ...mockDecryptedData, + id: mockResponse.id, + creationDate: mockResponse.creationDate, + contentEncryptionKey: mockEncryptedKey, + }); }); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts new file mode 100644 index 00000000000..470442a811b --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts @@ -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 { + 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 { + const applicationMap = new Map(); + + 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 { + const cipherMap = new Map(); + 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 + ); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts index 53ee3ffa892..1e14c09d089 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts @@ -1,9 +1,11 @@ -export * from "./member-cipher-details-api.service"; -export * from "./password-health.service"; -export * from "./critical-apps.service"; -export * from "./critical-apps-api.service"; -export * from "./risk-insights-api.service"; -export * from "./risk-insights-report.service"; -export * from "./risk-insights-data.service"; -export * from "./all-activities.service"; -export * from "./security-tasks-api.service"; +export * from "./api/critical-apps-api.service"; +export * from "./api/member-cipher-details-api.service"; +export * from "./api/risk-insights-api.service"; +export * from "./api/security-tasks-api.service"; +export * from "./domain/critical-apps.service"; +export * from "./domain/password-health.service"; +export * from "./domain/risk-insights-encryption.service"; +export * from "./domain/risk-insights-orchestrator.service"; +export * from "./domain/risk-insights-report.service"; +export * from "./view/all-activities.service"; +export * from "./view/risk-insights-data.service"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-response.mock.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-response.mock.ts deleted file mode 100644 index 92d87175974..00000000000 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/member-cipher-details-response.mock.ts +++ /dev/null @@ -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", - ], - }, - ], -}; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts deleted file mode 100644 index 6b775f8432e..00000000000 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts +++ /dev/null @@ -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(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( - null, - ); - LEGACY_applications$ = this.LEGACY_applicationsSubject.asObservable(); - - // TODO: Remove. Will use date from report results - private LEGACY_dataLastUpdatedSubject = new BehaviorSubject(null); - dataLastUpdated$ = this.LEGACY_dataLastUpdatedSubject.asObservable(); - - // --------------------------- UI State ------------------------------------ - private isLoadingSubject = new BehaviorSubject(false); - isLoading$ = this.isLoadingSubject.asObservable(); - - private isRefreshingSubject = new BehaviorSubject(false); - isRefreshing$ = this.isRefreshingSubject.asObservable(); - - private errorSubject = new BehaviorSubject(null); - error$ = this.errorSubject.asObservable(); - - // ------------------------- Drawer Variables ---------------- - // Drawer variables unified into a single BehaviorSubject - private drawerDetailsSubject = new BehaviorSubject({ - 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(null); - reportResults$ = this.reportResultsSubject.asObservable(); - // Is a report being generated - private isRunningReportSubject = new BehaviorSubject(false); - isRunningReport$ = this.isRunningReportSubject.asObservable(); - - // --------------------------- Critical Application data --------------------- - criticalReportResults$: Observable = 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 { - // 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 => { - 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 => { - 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 => { - 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); - }), - ); - } -} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts deleted file mode 100644 index fcfc7a255df..00000000000 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts +++ /dev/null @@ -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(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 { - // 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 { - 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 { - 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 { - 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 { - 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(); - - 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(); - - 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 { - 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 { - return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe( - switchMap((response) => { - if (!response) { - // Return an empty report and summary if response is falsy - return of(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 { - 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 { - const cipherHealthReports: LEGACY_CipherHealthReportDetail[] = []; - const passwordUseMap = new Map(); - 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 { - const passwordUseMap = new Map(); - ciphers.forEach((cipher) => { - const password = cipher.login.password!; - passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1); - }); - return passwordUseMap; - } - - private _groupCiphersByApplication( - cipherHealthData: CipherHealthReport[], - ): Map { - const applicationMap = new Map(); - - 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> { - // [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(); - - 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 { - 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; - }); - }), - ); - } -} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts similarity index 94% rename from bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts rename to bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts index 4044a01926c..97db491823c 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts @@ -1,7 +1,7 @@ import { BehaviorSubject } from "rxjs"; -import { ApplicationHealthReportDetailEnriched } from "../models"; -import { OrganizationReportSummary } from "../models/report-models"; +import { ApplicationHealthReportDetailEnriched } from "../../models"; +import { OrganizationReportSummary } from "../../models/report-models"; import { RiskInsightsDataService } from "./risk-insights-data.service"; @@ -40,7 +40,7 @@ export class AllActivitiesService { constructor(private dataService: RiskInsightsDataService) { // All application summary changes - this.dataService.reportResults$.subscribe((report) => { + this.dataService.enrichedReportData$.subscribe((report) => { if (report) { this.setAllAppsReportSummary(report.summaryData); this.setAllAppsReportDetails(report.reportData); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts new file mode 100644 index 00000000000..89f120cbded --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts @@ -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(); + + // -------------------------- Context state -------------------------- + // Organization the user is currently viewing + readonly organizationDetails$: Observable<{ + organizationId: OrganizationId; + organizationName: string; + } | null> = of(null); + + // --------------------------- UI State ------------------------------------ + private errorSubject = new BehaviorSubject(null); + error$ = this.errorSubject.asObservable(); + + // -------------------------- Orchestrator-driven state ------------- + // The full report state (for internal facade use or complex components) + private readonly reportState$: Observable; + readonly isLoading$: Observable = of(false); + readonly enrichedReportData$: Observable = of(null); + readonly isGeneratingReport$: Observable = of(false); + readonly criticalReportResults$: Observable = of(null); + + // ------------------------- Drawer Variables --------------------- + // Drawer variables unified into a single BehaviorSubject + private drawerDetailsSubject = new BehaviorSubject({ + 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 => { + 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 => { + 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 => { + 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); + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts index 9e9a695cd1e..2cb9140f174 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts @@ -12,7 +12,8 @@ import { RiskInsightsReportService, SecurityTasksApiService, } from "@bitwarden/bit-common/dirt/reports/risk-insights/services"; -import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service"; +import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service"; +import { RiskInsightsOrchestratorService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -24,6 +25,7 @@ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/pass import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service"; @@ -52,28 +54,31 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks. safeProvider({ provide: RiskInsightsReportService, useClass: RiskInsightsReportService, + deps: [RiskInsightsApiService, RiskInsightsEncryptionService], + }), + safeProvider({ + provide: RiskInsightsOrchestratorService, deps: [ + AccountServiceAbstraction, CipherService, + CriticalAppsService, + LogService, MemberCipherDetailsApiService, + OrganizationService, PasswordHealthService, RiskInsightsApiService, + RiskInsightsReportService, RiskInsightsEncryptionService, ], }), safeProvider({ provide: RiskInsightsDataService, - deps: [ - AccountServiceAbstraction, - CriticalAppsService, - OrganizationService, - RiskInsightsReportService, - ], + deps: [RiskInsightsOrchestratorService], }), - { + safeProvider({ provide: RiskInsightsEncryptionService, - useClass: RiskInsightsEncryptionService, - deps: [KeyService, EncryptService, KeyGenerationService], - }, + deps: [KeyService, EncryptService, KeyGenerationService, LogService], + }), safeProvider({ provide: CriticalAppsService, useClass: CriticalAppsService, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts similarity index 88% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts index 4d2085d7c3a..910b326c662 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts @@ -1,12 +1,12 @@ import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, ChangeDetectionStrategy } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { Subject, switchMap, takeUntil, of, BehaviorSubject, combineLatest } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AllActivitiesService, - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ApplicationHealthReportDetailEnriched, SecurityTasksApiService, TaskMetrics, OrganizationReportSummary, @@ -14,17 +14,12 @@ import { import { OrganizationId } from "@bitwarden/common/types/guid"; import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components"; -import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; -import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; - -export const RenderMode = { - noCriticalApps: "noCriticalApps", - criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks", - criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks", -} as const; -export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode]; +import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service"; +import { RenderMode } from "../../models/activity.models"; +import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "dirt-password-change-metric", imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule], templateUrl: "./password-change-metric.component.html", @@ -34,8 +29,7 @@ export class PasswordChangeMetricComponent implements OnInit { protected taskMetrics$ = new BehaviorSubject({ totalTasks: 0, completedTasks: 0 }); private completedTasks: number = 0; private totalTasks: number = 0; - private allApplicationsDetails: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[] = - []; + private allApplicationsDetails: ApplicationHealthReportDetailEnriched[] = []; atRiskAppsCount: number = 0; atRiskPasswordsCount: number = 0; @@ -43,6 +37,13 @@ export class PasswordChangeMetricComponent implements OnInit { private destroyRef = new Subject(); renderMode: RenderMode = "noCriticalApps"; + constructor( + private activatedRoute: ActivatedRoute, + private securityTasksApiService: SecurityTasksApiService, + private allActivitiesService: AllActivitiesService, + protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, + ) {} + async ngOnInit(): Promise { combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$]) .pipe( @@ -83,13 +84,6 @@ export class PasswordChangeMetricComponent implements OnInit { }); } - constructor( - private activatedRoute: ActivatedRoute, - private securityTasksApiService: SecurityTasksApiService, - private allActivitiesService: AllActivitiesService, - protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, - ) {} - private determineRenderMode( summary: OrganizationReportSummary, taskMetrics: TaskMetrics, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts similarity index 91% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts index e4942344b0e..9e3dff3144c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts @@ -11,16 +11,16 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { getById } from "@bitwarden/common/platform/misc"; -import { ToastService, DialogService } from "@bitwarden/components"; +import { DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { RiskInsightsTabType } from "../models/risk-insights.models"; +import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component"; + import { ActivityCardComponent } from "./activity-card.component"; import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component"; import { NewApplicationsDialogComponent } from "./new-applications-dialog.component"; -import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; -import { RiskInsightsTabType } from "./risk-insights.component"; @Component({ selector: "dirt-all-activity", @@ -43,6 +43,15 @@ export class AllActivityComponent implements OnInit { destroyRef = inject(DestroyRef); + constructor( + private accountService: AccountService, + protected activatedRoute: ActivatedRoute, + protected allActivitiesService: AllActivitiesService, + protected dataService: RiskInsightsDataService, + private dialogService: DialogService, + protected organizationService: OrganizationService, + ) {} + async ngOnInit(): Promise { const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); @@ -71,17 +80,6 @@ export class AllActivityComponent implements OnInit { } } - constructor( - protected activatedRoute: ActivatedRoute, - private accountService: AccountService, - protected organizationService: OrganizationService, - protected dataService: RiskInsightsDataService, - protected allActivitiesService: AllActivitiesService, - private toastService: ToastService, - private i18nService: I18nService, - private dialogService: DialogService, - ) {} - get RiskInsightsTabType() { return RiskInsightsTabType; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts similarity index 94% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index bc04884c799..57ee0b20360 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -25,8 +25,8 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; -import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.component"; -import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; +import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component"; +import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component"; @Component({ selector: "dirt-all-applications", @@ -67,7 +67,7 @@ export class AllApplicationsComponent implements OnInit { } async ngOnInit() { - this.dataService.reportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (report) => { this.applicationSummary = report?.summaryData ?? createNewSummaryData(); this.dataSource.data = report?.reportData ?? []; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts similarity index 93% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts index 0ea273546b5..dffc493e51d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts @@ -23,11 +23,10 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; -import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service"; - -import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.component"; -import { RiskInsightsTabType } from "./risk-insights.component"; -import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service"; +import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; +import { RiskInsightsTabType } from "../models/risk-insights.models"; +import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component"; +import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; @Component({ selector: "dirt-critical-applications", diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts new file mode 100644 index 00000000000..6f108a46029 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts @@ -0,0 +1,7 @@ +export const RenderMode = { + noCriticalApps: "noCriticalApps", + criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks", + criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks", +} as const; + +export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode]; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/risk-insights.models.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/risk-insights.models.ts new file mode 100644 index 00000000000..18493a386dd --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/risk-insights.models.ts @@ -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, +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.html deleted file mode 100644 index dc94f28f944..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - {{ "member" | i18n }} - {{ "atRiskPasswords" | i18n }} - {{ "totalPasswords" | i18n }} - {{ "atRiskApplications" | i18n }} - {{ "totalApplications" | i18n }} - - - diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.ts deleted file mode 100644 index 15dc80a1b00..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/notified-members-table.component.ts +++ /dev/null @@ -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(); - - constructor() { - this.dataSource.data = []; - } -} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 49ccfb73c5d..18df046b82c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -17,7 +17,7 @@ } @else { {{ "noReportRan" | i18n }} } - @let isRunningReport = dataService.isRunningReport$ | async; + @let isRunningReport = dataService.isGeneratingReport$ | async; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 308cc351dc3..e1264b009b8 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -1,13 +1,15 @@ import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, OnInit, inject } from "@angular/core"; +import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { EMPTY } from "rxjs"; -import { map, switchMap } from "rxjs/operators"; +import { map, tap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; +import { + DrawerType, + RiskInsightsDataService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -21,18 +23,10 @@ import { } from "@bitwarden/components"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; -import { AllActivityComponent } from "./all-activity.component"; -import { AllApplicationsComponent } from "./all-applications.component"; -import { CriticalApplicationsComponent } from "./critical-applications.component"; - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum RiskInsightsTabType { - AllActivity = 0, - AllApps = 1, - CriticalApps = 2, - NotifiedMembers = 3, -} +import { AllActivityComponent } from "./activity/all-activity.component"; +import { AllApplicationsComponent } from "./all-applications/all-applications.component"; +import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; +import { RiskInsightsTabType } from "./models/risk-insights.models"; @Component({ templateUrl: "./risk-insights.component.html", @@ -51,7 +45,7 @@ export enum RiskInsightsTabType { AllActivityComponent, ], }) -export class RiskInsightsComponent implements OnInit { +export class RiskInsightsComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); private _isDrawerOpen: boolean = false; @@ -65,7 +59,6 @@ export class RiskInsightsComponent implements OnInit { private organizationId: OrganizationId = "" as OrganizationId; dataLastUpdated: Date | null = null; - refetching: boolean = false; constructor( private route: ActivatedRoute, @@ -91,11 +84,10 @@ export class RiskInsightsComponent implements OnInit { .pipe( takeUntilDestroyed(this.destroyRef), map((params) => params.get("organizationId")), - switchMap(async (orgId) => { + tap((orgId) => { if (orgId) { // Initialize Data Service - await this.dataService.initializeForOrganization(orgId as OrganizationId); - + this.dataService.initializeForOrganization(orgId as OrganizationId); this.organizationId = orgId as OrganizationId; } else { return EMPTY; @@ -105,7 +97,7 @@ export class RiskInsightsComponent implements OnInit { .subscribe(); // Subscribe to report result details - this.dataService.reportResults$ + this.dataService.enrichedReportData$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((report) => { this.appsCount = report?.reportData.length ?? 0; @@ -119,15 +111,16 @@ export class RiskInsightsComponent implements OnInit { this._isDrawerOpen = details.open; }); } - runReport = () => { - this.dataService.triggerReport(); - }; + + ngOnDestroy(): void { + this.dataService.destroy(); + } /** * Refreshes the data by re-fetching the applications report. * This will automatically notify child components subscribed to the RiskInsightsDataService observables. */ - refreshData(): void { + generateReport(): void { if (this.organizationId) { this.dataService.triggerReport(); } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.ts diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts index a57bdfc279c..22f8ea55f51 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts @@ -3,7 +3,7 @@ import { mock } from "jest-mock-extended"; import { AllActivitiesService, - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ApplicationHealthReportDetailEnriched, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -43,7 +43,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid1"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2); await service.assignTasks(organizationId, apps); @@ -60,12 +60,12 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 2, atRiskCipherIds: ["cid1", "cid2"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, { isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid2"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined); i18nServiceSpy.t.mockImplementation((key) => key); @@ -91,7 +91,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid3"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail")); i18nServiceSpy.t.mockImplementation((key) => key); @@ -113,7 +113,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 0, atRiskCipherIds: ["cid4"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; const result = await service.requestPasswordChange(organizationId, apps); @@ -128,7 +128,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: false, atRiskPasswordCount: 2, atRiskCipherIds: ["cid5", "cid6"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; const result = await service.requestPasswordChange(organizationId, apps); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts index 34fd6daa2b0..4d7a41007eb 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { AllActivitiesService, - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ApplicationHealthReportDetailEnriched, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; @@ -20,10 +20,7 @@ export class AccessIntelligenceSecurityTasksService { private toastService: ToastService, private i18nService: I18nService, ) {} - async assignTasks( - organizationId: OrganizationId, - apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], - ) { + async assignTasks(organizationId: OrganizationId, apps: ApplicationHealthReportDetailEnriched[]) { const taskCount = await this.requestPasswordChange(organizationId, apps); this.allActivitiesService.setTaskCreatedCount(taskCount); } @@ -31,7 +28,7 @@ export class AccessIntelligenceSecurityTasksService { // TODO: this method is shared between here and critical-applications.component.ts async requestPasswordChange( organizationId: OrganizationId, - apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], + apps: ApplicationHealthReportDetailEnriched[], ): Promise { // Only create tasks for CRITICAL applications with at-risk passwords const cipherIds = apps