1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-23680] Report Applications data (#16819)

* Move files to folders. Delete unused component. Move model to file

* Move risk insights services to folder structure capturing domains, api, and view organization. Move mock data

* Remove legacy risk insight report code

* Move api model to file

* Separate data service and orchestration of data to make the data service a facade

* Add orchestration updates for fetching applications as well as migrating data.

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

* Update test cases

* Fixed test case after merge. Cleaned up per comments on review

* Fixed decryption and encryption issue when not using existing content key

* Fix type errors

* Fix test update

* Fixe remove critical applications

* Fix report generating flag not being reset

* Removed extra logs
This commit is contained in:
Leslie Tilton
2025-10-22 10:36:51 -05:00
committed by GitHub
parent cc954ed123
commit 03d636108d
59 changed files with 2142 additions and 1864 deletions

View File

@@ -2,20 +2,19 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
LEGACY_MemberDetailsFlat,
LEGACY_CipherHealthReportDetail,
LEGACY_CipherHealthReportUriDetail,
} from "../models/password-health";
AtRiskApplicationDetail,
AtRiskMemberDetail,
MemberCipherDetailsResponse,
} from "../models";
import {
ApplicationHealthReportDetail,
MemberDetails,
OrganizationReportSummary,
RiskInsightsData,
} from "../models/report-models";
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
export function flattenMemberDetails(
memberCiphers: MemberCipherDetailsResponse[],
): LEGACY_MemberDetailsFlat[] {
): MemberDetails[] {
return memberCiphers.flatMap((member) =>
member.cipherIds.map((cipherId) => ({
userGuid: member.userGuid,
@@ -48,9 +47,7 @@ export function getTrimmedCipherUris(cipher: CipherView): string[] {
}
// Returns a deduplicated array of members by email
export function getUniqueMembers(
orgMembers: LEGACY_MemberDetailsFlat[],
): LEGACY_MemberDetailsFlat[] {
export function getUniqueMembers(orgMembers: MemberDetails[]): MemberDetails[] {
const existingEmails = new Set<string>();
return orgMembers.filter((member) => {
if (existingEmails.has(member.email)) {
@@ -61,108 +58,6 @@ export function getUniqueMembers(
});
}
/**
* Creates a flattened member details object
* @param userGuid User GUID
* @param userName User name
* @param email User email
* @param cipherId Cipher ID
* @returns Flattened member details
*/
export function getMemberDetailsFlat(
userGuid: string,
userName: string,
email: string,
cipherId: string,
): LEGACY_MemberDetailsFlat {
return {
userGuid: userGuid,
userName: userName,
email: email,
cipherId: cipherId,
};
}
/**
* Creates a flattened cipher details object for URI reporting
* @param detail Cipher health report detail
* @param uri Trimmed URI
* @returns Flattened cipher health details to URI
*/
export function getFlattenedCipherDetails(
detail: LEGACY_CipherHealthReportDetail,
uri: string,
): LEGACY_CipherHealthReportUriDetail {
return {
cipherId: detail.id,
reusedPasswordCount: detail.reusedPasswordCount,
weakPasswordDetail: detail.weakPasswordDetail,
exposedPasswordDetail: detail.exposedPasswordDetail,
cipherMembers: detail.cipherMembers,
trimmedUri: uri,
cipher: detail as CipherView,
};
}
/**
* Create the new application health report detail object with the details from the cipher health report uri detail object
* update or create the at risk values if the item is at risk.
* @param newUriDetail New cipher uri detail
* @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk
* @param existingUriDetail The previously processed Uri item
* @returns The new or updated application health report detail
*/
export function getApplicationReportDetail(
newUriDetail: LEGACY_CipherHealthReportUriDetail,
isAtRisk: boolean,
existingUriDetail?: ApplicationHealthReportDetail,
): ApplicationHealthReportDetail {
const reportDetail = {
applicationName: existingUriDetail
? existingUriDetail.applicationName
: newUriDetail.trimmedUri,
passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1,
memberDetails: existingUriDetail
? getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
: newUriDetail.cipherMembers,
atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [],
atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0,
atRiskCipherIds: existingUriDetail ? existingUriDetail.atRiskCipherIds : [],
atRiskMemberCount: existingUriDetail ? existingUriDetail.atRiskMemberDetails.length : 0,
cipherIds: existingUriDetail
? existingUriDetail.cipherIds.concat(newUriDetail.cipherId)
: [newUriDetail.cipherId],
} as ApplicationHealthReportDetail;
if (isAtRisk) {
reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1;
reportDetail.atRiskCipherIds.push(newUriDetail.cipherId);
reportDetail.atRiskMemberDetails = getUniqueMembers(
reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers),
);
reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length;
}
reportDetail.memberCount = reportDetail.memberDetails.length;
return reportDetail;
}
/**
* Create a new Risk Insights Report
*
* @returns An empty report
*/
export function createNewReportData(): RiskInsightsData {
return {
creationDate: new Date(),
reportData: [],
summaryData: createNewSummaryData(),
applicationData: [],
};
}
/**
* Create a new Risk Insights Report Summary
*
@@ -181,3 +76,60 @@ export function createNewSummaryData(): OrganizationReportSummary {
newApplications: [],
};
}
export function getAtRiskApplicationList(
cipherHealthReportDetails: ApplicationHealthReportDetail[],
): AtRiskApplicationDetail[] {
const applicationPasswordRiskMap = new Map<string, number>();
cipherHealthReportDetails
.filter((app) => app.atRiskPasswordCount > 0)
.forEach((app) => {
const atRiskPasswordCount = applicationPasswordRiskMap.get(app.applicationName) ?? 0;
applicationPasswordRiskMap.set(
app.applicationName,
atRiskPasswordCount + app.atRiskPasswordCount,
);
});
return Array.from(applicationPasswordRiskMap.entries()).map(
([applicationName, atRiskPasswordCount]) => ({
applicationName,
atRiskPasswordCount,
}),
);
}
/**
* Generates a list of members with at-risk passwords along with the number of at-risk passwords.
*/
export function getAtRiskMemberList(
cipherHealthReportDetails: ApplicationHealthReportDetail[],
): AtRiskMemberDetail[] {
const memberRiskMap = new Map<string, number>();
cipherHealthReportDetails.forEach((app) => {
app.atRiskMemberDetails.forEach((member) => {
const currentCount = memberRiskMap.get(member.email) ?? 0;
memberRiskMap.set(member.email, currentCount + 1);
});
});
return Array.from(memberRiskMap.entries()).map(([email, atRiskPasswordCount]) => ({
email,
atRiskPasswordCount,
}));
}
/**
* Builds a map of passwords to the number of times they are used across ciphers
*
* @param ciphers List of ciphers to check for password reuse
* @returns A map where the key is the password and the value is the number of times it is used
*/
export function buildPasswordUseMap(ciphers: CipherView[]): Map<string, number> {
const passwordUseMap = new Map<string, number>();
ciphers.forEach((cipher) => {
const password = cipher.login.password!;
passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1);
});
return passwordUseMap;
}

View File

@@ -1,6 +1,6 @@
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
import { createNewSummaryData } from "../helpers";
@@ -46,11 +46,11 @@ export interface SaveRiskInsightsReportRequest {
}
export class SaveRiskInsightsReportResponse extends BaseResponse {
id: string;
id: OrganizationReportId;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("organizationId");
this.id = this.getResponseProperty("id");
}
}
export function isSaveRiskInsightsReportResponse(obj: any): obj is SaveRiskInsightsReportResponse {
@@ -69,7 +69,7 @@ export class GetRiskInsightsReportResponse extends BaseResponse {
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("organizationId");
this.id = this.getResponseProperty("id");
this.organizationId = this.getResponseProperty("organizationId");
this.creationDate = new Date(this.getResponseProperty("creationDate"));
this.reportData = new EncString(this.getResponseProperty("reportData"));
@@ -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);
}
}

View File

@@ -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;
};

View File

@@ -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";

View File

@@ -1,109 +1,83 @@
import { mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { MemberCipherDetailsResponse } from "..";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
export const mockMemberCipherDetails: any = [
{
export const mockMemberCipherDetailsResponse: MemberCipherDetailsResponse[] = [
mock<MemberCipherDetailsResponse>({
userGuid: "user-1",
userName: "David Brent",
email: "david.brent@wernhamhogg.uk",
usesKeyConnector: true,
useKeyConnector: true,
cipherIds: [
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
],
},
{
}),
mock<MemberCipherDetailsResponse>({
userGuid: "user-2",
userName: "Tim Canterbury",
email: "tim.canterbury@wernhamhogg.uk",
usesKeyConnector: false,
useKeyConnector: false,
cipherIds: [
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
],
},
{
}),
mock<MemberCipherDetailsResponse>({
userGuid: "user-3",
userName: "Gareth Keenan",
email: "gareth.keenan@wernhamhogg.uk",
usesKeyConnector: true,
useKeyConnector: true,
cipherIds: [
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
"cbea34a8-bde4-46ad-9d19-b05001227nm7",
],
},
{
}),
mock<MemberCipherDetailsResponse>({
userGuid: "user-4",
userName: "Dawn Tinsley",
email: "dawn.tinsley@wernhamhogg.uk",
usesKeyConnector: true,
useKeyConnector: true,
cipherIds: [
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
],
},
{
}),
mock<MemberCipherDetailsResponse>({
userGuid: "user-5",
userName: "Keith Bishop",
email: "keith.bishop@wernhamhogg.uk",
usesKeyConnector: false,
useKeyConnector: false,
cipherIds: [
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
"cbea34a8-bde4-46ad-9d19-b05001227nm5",
],
},
{
}),
mock<MemberCipherDetailsResponse>({
userGuid: "user-1",
userName: "Chris Finch",
email: "chris.finch@wernhamhogg.uk",
usesKeyConnector: true,
useKeyConnector: true,
cipherIds: [
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
],
},
{
}),
mock<MemberCipherDetailsResponse>({
userGuid: "user-1",
userName: "Mister Secure",
email: "mister.secure@secureco.com",
usesKeyConnector: true,
useKeyConnector: true,
cipherIds: ["cbea34a8-bde4-46ad-9d19-b05001227tt1"],
},
}),
];
describe("Member Cipher Details API Service", () => {
let memberCipherDetailsApiService: MemberCipherDetailsApiService;
const apiService = mock<ApiService>();
beforeEach(() => {
memberCipherDetailsApiService = new MemberCipherDetailsApiService(apiService);
jest.resetAllMocks();
});
it("instantiates", () => {
expect(memberCipherDetailsApiService).not.toBeFalsy();
});
it("getMemberCipherDetails retrieves data", async () => {
apiService.send.mockResolvedValue(mockMemberCipherDetails);
const orgId = "1234";
const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId);
expect(result).not.toBeNull();
expect(result).toHaveLength(7);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/reports/member-cipher-details/" + orgId,
null,
true,
true,
);
});
});

View File

@@ -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,
},
};
}

View File

@@ -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;
};

View File

@@ -1,44 +1,13 @@
import { Opaque } from "type-fest";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrganizationReportId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BadgeVariant } from "@bitwarden/components";
import { ExposedPasswordDetail, WeakPasswordDetail } from "./password-health";
// -------------------- Drawer and UI Models --------------------
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum DrawerType {
None = 0,
AppAtRiskMembers = 1,
OrgAtRiskMembers = 2,
OrgAtRiskApps = 3,
}
export type DrawerDetails = {
open: boolean;
invokerId: string;
activeDrawerType: DrawerType;
atRiskMemberDetails?: AtRiskMemberDetail[];
appAtRiskMembers?: AppAtRiskMembersDialogParams | null;
atRiskAppDetails?: AtRiskApplicationDetail[] | null;
};
export type AppAtRiskMembersDialogParams = {
members: MemberDetails[];
applicationName: string;
};
// -------------------- Member Models --------------------
/**
* Member email with the number of at risk passwords
* At risk member detail that contains the email
* and the count of at risk ciphers
*/
export type AtRiskMemberDetail = {
email: string;
atRiskPasswordCount: number;
};
/**
* Flattened member details that associates an
@@ -71,18 +40,6 @@ export type CipherHealthReport = {
cipher: CipherView;
};
/**
* Breaks the cipher health info out by uri and passes
* along the password health and member info
*/
export type CipherApplicationView = {
cipherId: string;
cipher: CipherView;
cipherMembers: MemberDetails[];
application: string;
healthData: PasswordHealthData;
};
// -------------------- Application Health Report Models --------------------
/**
* All applications report summary. The total members,
@@ -91,21 +48,16 @@ export type CipherApplicationView = {
*/
export type OrganizationReportSummary = {
totalMemberCount: number;
totalCriticalMemberCount: number;
totalAtRiskMemberCount: number;
totalCriticalAtRiskMemberCount: number;
totalApplicationCount: number;
totalCriticalApplicationCount: number;
totalAtRiskMemberCount: number;
totalAtRiskApplicationCount: number;
totalCriticalApplicationCount: number;
totalCriticalMemberCount: number;
totalCriticalAtRiskMemberCount: number;
totalCriticalAtRiskApplicationCount: number;
newApplications: string[];
};
export type CriticalSummaryDetails = {
totalCriticalMembersCount: number;
totalCriticalApplicationsCount: number;
};
/**
* An entry for an organization application and if it is
* marked as critical
@@ -113,6 +65,11 @@ export type CriticalSummaryDetails = {
export type OrganizationReportApplication = {
applicationName: string;
isCritical: boolean;
/**
* Captures when a report has been reviewed by a user and
* can be filtered on to check for new applications
* */
reviewedDate: Date | null;
};
/**
@@ -131,15 +88,6 @@ export type ApplicationHealthReportDetail = {
cipherIds: string[];
};
/*
* A list of applications and the count of
* at risk passwords for each application
*/
export type AtRiskApplicationDetail = {
applicationName: string;
atRiskPasswordCount: number;
};
// -------------------- Password Health Report Models --------------------
export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">;
@@ -152,8 +100,26 @@ export type ReportResult = CipherView & {
};
export interface RiskInsightsData {
id: OrganizationReportId;
creationDate: Date;
contentEncryptionKey: EncString;
reportData: ApplicationHealthReportDetail[];
summaryData: OrganizationReportSummary;
applicationData: OrganizationReportApplication[];
}
export interface ReportState {
loading: boolean;
error: string | null;
data: RiskInsightsData | null;
}
// TODO Make Versioned models for structure changes
// export type VersionedRiskInsightsData = RiskInsightsDataV1 | RiskInsightsDataV2;
// export interface RiskInsightsDataV1 {
// version: 1;
// creationDate: Date;
// reportData: ApplicationHealthReportDetail[];
// summaryData: OrganizationReportSummary;
// applicationData: OrganizationReportApplication[];
// }

View File

@@ -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");
}
}

View File

@@ -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";

View File

@@ -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) {}

View File

@@ -0,0 +1,38 @@
import { mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
describe("Member Cipher Details API Service", () => {
let memberCipherDetailsApiService: MemberCipherDetailsApiService;
const apiService = mock<ApiService>();
beforeEach(() => {
memberCipherDetailsApiService = new MemberCipherDetailsApiService(apiService);
jest.resetAllMocks();
});
it("instantiates", () => {
expect(memberCipherDetailsApiService).not.toBeFalsy();
});
it("getMemberCipherDetails retrieves data", async () => {
apiService.send.mockResolvedValue(mockMemberCipherDetailsResponse);
const orgId = "1234";
const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId);
expect(result).not.toBeNull();
expect(result).toHaveLength(7);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/reports/member-cipher-details/" + orgId,
null,
true,
true,
);
});
});

View File

@@ -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 {

View File

@@ -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();
});
});

View File

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

View File

@@ -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();

View File

@@ -1,13 +1,17 @@
import {
BehaviorSubject,
catchError,
filter,
first,
firstValueFrom,
forkJoin,
from,
map,
Observable,
of,
switchMap,
tap,
throwError,
zip,
} from "rxjs";
@@ -20,9 +24,8 @@ import { KeyService } from "@bitwarden/key-management";
import {
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "../models/api-models.types";
import { CriticalAppsApiService } from "./critical-apps-api.service";
} from "../../models/api-models.types";
import { CriticalAppsApiService } from "../api/critical-apps-api.service";
/* Retrieves and decrypts critical apps for a given organization
* Encrypts and saves data for a given organization
@@ -125,9 +128,14 @@ export class CriticalAppsService {
this.criticalAppsListSubject$.next(updatedList);
}
// Drop a critical app for a given organization
// Only one app may be dropped at a time
async dropCriticalApp(orgId: OrganizationId, selectedUrl: string) {
/**
* Drop a critical application by url
*
* @param orgId
* @param selectedUrl
* @returns
*/
async dropCriticalAppByUrl(orgId: OrganizationId, selectedUrl: string) {
if (orgId != this.organizationId.value) {
throw new Error("Organization ID mismatch");
}
@@ -150,6 +158,31 @@ export class CriticalAppsService {
);
}
/**
* Drop multiple critical applications by id
*
* @param orgId
* @param ids
* @returns
*/
dropCriticalAppsById(orgId: OrganizationId, ids: string[]) {
return from(
this.criticalAppsApiService.dropCriticalApp({
organizationId: orgId,
passwordHealthReportApplicationIds: ids,
}),
).pipe(
tap((response) => {
this.criticalAppsListSubject$.next(
this.criticalAppsListSubject$.value.filter((f) => ids.some((id) => id === f.id)),
);
}),
catchError((error: unknown) => {
return throwError(() => error);
}),
);
}
private retrieveCriticalApps(
orgId: OrganizationId | null,
): Observable<PasswordHealthReportApplicationsResponse[]> {

View File

@@ -10,7 +10,7 @@ import {
ExposedPasswordDetail,
WeakPasswordDetail,
WeakPasswordScore,
} from "../models/password-health";
} from "../../models/password-health";
export class PasswordHealthService {
constructor(

View File

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

View File

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

View File

@@ -0,0 +1,224 @@
import { mock } from "jest-mock-extended";
import { of, throwError } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { makeEncString } from "@bitwarden/common/spec";
import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { LogService } from "@bitwarden/logging";
import { createNewSummaryData } from "../../helpers";
import { RiskInsightsData, SaveRiskInsightsReportResponse } from "../../models";
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
import {
mockApplicationData,
mockEnrichedReportData,
mockSummaryData,
} from "../../models/mocks/mock-data";
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
import { CriticalAppsService } from "./critical-apps.service";
import { PasswordHealthService } from "./password-health.service";
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
import { RiskInsightsOrchestratorService } from "./risk-insights-orchestrator.service";
import { RiskInsightsReportService } from "./risk-insights-report.service";
describe("RiskInsightsOrchestratorService", () => {
let service: RiskInsightsOrchestratorService;
// Non changing mock data
const mockOrgId = "org-789" as OrganizationId;
const mockOrgName = "Test Org";
const mockUserId = "user-101" as UserId;
const mockReportId = "report-1" as OrganizationReportId;
const mockKey: EncString = makeEncString("wrappedKey");
const reportState: RiskInsightsData = {
id: mockReportId,
reportData: [],
summaryData: createNewSummaryData(),
applicationData: [],
creationDate: new Date(),
contentEncryptionKey: mockKey,
};
const mockCiphers = [{ id: "cipher-1" }] as any;
// Mock services
const mockAccountService = mock<AccountService>({
activeAccount$: of(mock<Account>({ id: mockUserId })),
});
const mockCriticalAppsService = mock<CriticalAppsService>({
criticalAppsList$: of([]),
});
const mockOrganizationService = mock<OrganizationService>();
const mockCipherService = mock<CipherService>();
const mockMemberCipherDetailsApiService = mock<MemberCipherDetailsApiService>();
let mockPasswordHealthService: PasswordHealthService;
const mockReportApiService = mock<RiskInsightsApiService>();
let mockReportService: RiskInsightsReportService;
const mockRiskInsightsEncryptionService = mock<RiskInsightsEncryptionService>();
const mockLogService = mock<LogService>();
beforeEach(() => {
// Mock pipes from constructor
mockReportService = mock<RiskInsightsReportService>({
generateApplicationsReport: jest.fn().mockReturnValue(mockEnrichedReportData),
getApplicationsSummary: jest.fn().mockReturnValue(mockSummaryData),
getOrganizationApplications: jest.fn().mockReturnValue(mockApplicationData),
getRiskInsightsReport$: jest.fn().mockReturnValue(of(reportState)),
saveRiskInsightsReport$: jest.fn().mockReturnValue(
of({
response: { id: mockReportId } as SaveRiskInsightsReportResponse,
contentEncryptionKey: mockKey,
}),
),
});
// Arrange mocks for new flow
mockMemberCipherDetailsApiService.getMemberCipherDetails.mockResolvedValue(
mockMemberCipherDetailsResponse,
);
mockPasswordHealthService = mock<PasswordHealthService>({
auditPasswordLeaks$: jest.fn(() => of([])),
isValidCipher: jest.fn().mockReturnValue(true),
findWeakPasswordDetails: jest.fn().mockReturnValue(null),
});
mockCipherService.getAllFromApiForOrganization.mockReturnValue(mockCiphers);
service = new RiskInsightsOrchestratorService(
mockAccountService,
mockCipherService,
mockCriticalAppsService,
mockLogService,
mockMemberCipherDetailsApiService,
mockOrganizationService,
mockPasswordHealthService,
mockReportApiService,
mockReportService,
mockRiskInsightsEncryptionService,
);
});
describe("fetchReport", () => {
it("should call with correct org and user IDs and emit ReportState", (done) => {
// Arrange
const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"];
const privateUserIdSubject = service["_userIdSubject"];
// Set up organization and user context
privateOrganizationDetailsSubject.next({
organizationId: mockOrgId,
organizationName: mockOrgName,
});
privateUserIdSubject.next(mockUserId);
// Act
service.fetchReport();
// Assert
service.rawReportData$.subscribe((state) => {
if (!state.loading) {
expect(mockReportService.getRiskInsightsReport$).toHaveBeenCalledWith(
mockOrgId,
mockUserId,
);
expect(state.data).toEqual(reportState);
done();
}
});
});
it("should emit error ReportState when getRiskInsightsReport$ throws", (done) => {
// Setup error passed via constructor for this test case
mockReportService.getRiskInsightsReport$ = jest
.fn()
.mockReturnValue(throwError(() => new Error("API error")));
const testService = new RiskInsightsOrchestratorService(
mockAccountService,
mockCipherService,
mockCriticalAppsService,
mockLogService,
mockMemberCipherDetailsApiService,
mockOrganizationService,
mockPasswordHealthService,
mockReportApiService,
mockReportService,
mockRiskInsightsEncryptionService,
);
const { _organizationDetailsSubject, _userIdSubject } = testService as any;
_organizationDetailsSubject.next({
organizationId: mockOrgId,
organizationName: mockOrgName,
});
_userIdSubject.next(mockUserId);
testService.fetchReport();
testService.rawReportData$.subscribe((state) => {
if (!state.loading) {
expect(state.error).toBe("Failed to fetch report");
expect(state.data).toBeNull();
done();
}
});
});
});
describe("generateReport", () => {
it("should generate report using member ciphers and password health, then save and emit ReportState", (done) => {
const privateOrganizationDetailsSubject = service["_organizationDetailsSubject"];
const privateUserIdSubject = service["_userIdSubject"];
// Set up ciphers in orchestrator
privateOrganizationDetailsSubject.next({
organizationId: mockOrgId,
organizationName: mockOrgName,
});
privateUserIdSubject.next(mockUserId);
// Act
service.generateReport();
// Assert
service.rawReportData$.subscribe((state) => {
if (!state.loading && state.data) {
expect(mockMemberCipherDetailsApiService.getMemberCipherDetails).toHaveBeenCalledWith(
mockOrgId,
);
expect(mockReportService.generateApplicationsReport).toHaveBeenCalled();
expect(mockReportService.saveRiskInsightsReport$).toHaveBeenCalledWith(
mockEnrichedReportData,
mockSummaryData,
mockApplicationData,
{ organizationId: mockOrgId, userId: mockUserId },
);
expect(state.data.reportData).toEqual(mockEnrichedReportData);
expect(state.data.summaryData).toEqual(mockSummaryData);
expect(state.data.applicationData).toEqual(mockApplicationData);
done();
}
});
});
describe("destroy", () => {
it("should complete destroy$ subject and unsubscribe reportStateSubscription", () => {
const privateDestroy = (service as any)._destroy$;
const privateReportStateSubscription = (service as any)._reportStateSubscription;
// Spy on the methods you expect to be called.
const destroyCompleteSpy = jest.spyOn(privateDestroy, "complete");
const unsubscribeSpy = jest.spyOn(privateReportStateSubscription, "unsubscribe");
// Execute the destroy method.
service.destroy();
// Assert that the methods were called as expected.
expect(destroyCompleteSpy).toHaveBeenCalled();
expect(unsubscribeSpy).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,742 @@
import {
BehaviorSubject,
combineLatest,
forkJoin,
from,
merge,
Observable,
of,
Subject,
Subscription,
throwError,
} from "rxjs";
import {
catchError,
distinctUntilChanged,
exhaustMap,
filter,
map,
scan,
shareReplay,
startWith,
switchMap,
take,
takeUntil,
tap,
withLatestFrom,
} from "rxjs/operators";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LogService } from "@bitwarden/logging";
import {
buildPasswordUseMap,
createNewSummaryData,
flattenMemberDetails,
getTrimmedCipherUris,
} from "../../helpers";
import {
ApplicationHealthReportDetailEnriched,
PasswordHealthReportApplicationsResponse,
} from "../../models";
import { RiskInsightsEnrichedData } from "../../models/report-data-service.types";
import {
CipherHealthReport,
MemberDetails,
OrganizationReportApplication,
ReportState,
} from "../../models/report-models";
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
import { CriticalAppsService } from "./critical-apps.service";
import { PasswordHealthService } from "./password-health.service";
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
import { RiskInsightsReportService } from "./risk-insights-report.service";
export class RiskInsightsOrchestratorService {
private _destroy$ = new Subject<void>();
// -------------------------- Context state --------------------------
// Current user viewing risk insights
private _userIdSubject = new BehaviorSubject<UserId | null>(null);
private _userId$ = this._userIdSubject.asObservable();
// Organization the user is currently viewing
private _organizationDetailsSubject = new BehaviorSubject<{
organizationId: OrganizationId;
organizationName: string;
} | null>(null);
organizationDetails$ = this._organizationDetailsSubject.asObservable();
// ------------------------- Raw data -------------------------
private _ciphersSubject = new BehaviorSubject<CipherView[] | null>(null);
private _ciphers$ = this._ciphersSubject.asObservable();
// ------------------------- Report Variables ----------------
private _rawReportDataSubject = new BehaviorSubject<ReportState>({
loading: true,
error: null,
data: null,
});
rawReportData$ = this._rawReportDataSubject.asObservable();
private _enrichedReportDataSubject = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
enrichedReportData$ = this._enrichedReportDataSubject.asObservable();
// Generate report trigger and state
private _generateReportTriggerSubject = new BehaviorSubject<boolean>(false);
generatingReport$ = this._generateReportTriggerSubject.asObservable();
// --------------------------- Critical Application data ---------------------
criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
// --------------------------- Trigger subjects ---------------------
private _initializeOrganizationTriggerSubject = new Subject<OrganizationId>();
private _fetchReportTriggerSubject = new Subject<void>();
private _reportStateSubscription: Subscription | null = null;
private _migrationSubscription: Subscription | null = null;
constructor(
private accountService: AccountService,
private cipherService: CipherService,
private criticalAppsService: CriticalAppsService,
private logService: LogService,
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
private organizationService: OrganizationService,
private passwordHealthService: PasswordHealthService,
private reportApiService: RiskInsightsApiService,
private reportService: RiskInsightsReportService,
private riskInsightsEncryptionService: RiskInsightsEncryptionService,
) {
this.logService.debug("[RiskInsightsOrchestratorService] Setting up");
this._setupCriticalApplicationContext();
this._setupCriticalApplicationReport();
this._setupEnrichedReportData();
this._setupInitializationPipeline();
this._setupMigrationAndCleanup();
this._setupReportState();
this._setupUserId();
}
destroy(): void {
this.logService.debug("[RiskInsightsOrchestratorService] Destroying");
if (this._reportStateSubscription) {
this._reportStateSubscription.unsubscribe();
}
if (this._migrationSubscription) {
this._migrationSubscription.unsubscribe();
}
this._destroy$.next();
this._destroy$.complete();
}
/**
* Fetches the latest report for the current organization and user
*/
fetchReport(): void {
this.logService.debug("[RiskInsightsOrchestratorService] Fetch report triggered");
this._fetchReportTriggerSubject.next();
}
/**
* Generates a new report for the current organization and user
*/
generateReport(): void {
this.logService.debug("[RiskInsightsOrchestratorService] Create new report triggered");
this._generateReportTriggerSubject.next(true);
}
/**
* Initializes the service context for a specific organization
*
* @param organizationId The ID of the organization to initialize context for
*/
initializeForOrganization(organizationId: OrganizationId) {
this.logService.debug("[RiskInsightsOrchestratorService] Initializing for org", organizationId);
this._initializeOrganizationTriggerSubject.next(organizationId);
}
removeCriticalApplication$(criticalApplication: string): Observable<ReportState> {
this.logService.info(
"[RiskInsightsOrchestratorService] Removing critical applications from report",
);
return this.rawReportData$.pipe(
take(1),
filter((data) => !data.loading && data.data != null),
withLatestFrom(
this.organizationDetails$.pipe(filter((org) => !!org && !!org.organizationId)),
this._userId$.pipe(filter((userId) => !!userId)),
),
map(([reportState, organizationDetails, userId]) => {
// Create a set for quick lookup of the new critical apps
const existingApplicationData = reportState?.data?.applicationData || [];
const updatedApplicationData = this._removeCriticalApplication(
existingApplicationData,
criticalApplication,
);
const updatedState = {
...reportState,
data: {
...reportState.data,
applicationData: updatedApplicationData,
},
} as ReportState;
return { reportState, organizationDetails, updatedState, userId };
}),
switchMap(({ reportState, organizationDetails, updatedState, userId }) => {
return from(
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
{
organizationId: organizationDetails!.organizationId,
userId: userId!,
},
{
reportData: reportState?.data?.reportData ?? [],
summaryData: reportState?.data?.summaryData ?? createNewSummaryData(),
applicationData: updatedState?.data?.applicationData ?? [],
},
reportState?.data?.contentEncryptionKey,
),
).pipe(
map((encryptedData) => ({
reportState,
organizationDetails,
updatedState,
encryptedData,
})),
);
}),
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
this.logService.debug(
`[RiskInsightsOrchestratorService] Saving applicationData with toggled critical flag for report with id: ${reportState?.data?.id} and org id: ${organizationDetails?.organizationId}`,
);
if (!reportState?.data?.id || !organizationDetails?.organizationId) {
return of({ ...reportState });
}
return this.reportApiService
.updateRiskInsightsApplicationData$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
applicationData: encryptedData.encryptedApplicationData.toSdk(),
},
},
)
.pipe(
map(() => updatedState),
tap((finalState) => this._rawReportDataSubject.next(finalState)),
catchError((error: unknown) => {
this.logService.error("Failed to save updated applicationData", error);
return of({ ...reportState, error: "Failed to remove a critical application" });
}),
);
}),
);
}
saveCriticalApplications$(criticalApplications: string[]): Observable<ReportState> {
this.logService.info(
"[RiskInsightsOrchestratorService] Saving critical applications to report",
);
return this.rawReportData$.pipe(
take(1),
filter((data) => !data.loading && data.data != null),
withLatestFrom(
this.organizationDetails$.pipe(filter((org) => !!org && !!org.organizationId)),
this._userId$.pipe(filter((userId) => !!userId)),
),
map(([reportState, organizationDetails, userId]) => {
// Create a set for quick lookup of the new critical apps
const newCriticalAppNamesSet = new Set(criticalApplications);
const existingApplicationData = reportState?.data?.applicationData || [];
const updatedApplicationData = this._mergeApplicationData(
existingApplicationData,
newCriticalAppNamesSet,
);
const updatedState = {
...reportState,
data: {
...reportState.data,
applicationData: updatedApplicationData,
},
} as ReportState;
return { reportState, organizationDetails, updatedState, userId };
}),
switchMap(({ reportState, organizationDetails, updatedState, userId }) => {
return from(
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
{
organizationId: organizationDetails!.organizationId,
userId: userId!,
},
{
reportData: reportState?.data?.reportData ?? [],
summaryData: reportState?.data?.summaryData ?? createNewSummaryData(),
applicationData: updatedState?.data?.applicationData ?? [],
},
reportState?.data?.contentEncryptionKey,
),
).pipe(
map((encryptedData) => ({
reportState,
organizationDetails,
updatedState,
encryptedData,
})),
);
}),
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
this.logService.debug(
`[RiskInsightsOrchestratorService] Saving critical applications on applicationData with report id: ${reportState?.data?.id} and org id: ${organizationDetails?.organizationId}`,
);
if (!reportState?.data?.id || !organizationDetails?.organizationId) {
return of({ ...reportState });
}
return this.reportApiService
.updateRiskInsightsApplicationData$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
applicationData: encryptedData.encryptedApplicationData.toSdk(),
},
},
)
.pipe(
map(() => updatedState),
tap((finalState) => this._rawReportDataSubject.next(finalState)),
catchError((error: unknown) => {
this.logService.error("Failed to save updated applicationData", error);
return of({ ...reportState, error: "Failed to save critical applications" });
}),
);
}),
);
}
private _fetchReport$(organizationId: OrganizationId, userId: UserId): Observable<ReportState> {
return this.reportService.getRiskInsightsReport$(organizationId, userId).pipe(
tap(() => this.logService.debug("[RiskInsightsOrchestratorService] Fetching report")),
map((result): ReportState => {
return {
loading: false,
error: null,
data: result ?? null,
};
}),
catchError(() => of({ loading: false, error: "Failed to fetch report", data: null })),
startWith({ loading: true, error: null, data: null }),
);
}
private _generateNewApplicationsReport$(
organizationId: OrganizationId,
userId: UserId,
): Observable<ReportState> {
// Generate the report
const memberCiphers$ = from(
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers)));
return forkJoin([this._ciphers$.pipe(take(1)), memberCiphers$]).pipe(
tap(() => {
this.logService.debug("[RiskInsightsOrchestratorService] Generating new report");
}),
switchMap(([ciphers, memberCiphers]) => this._getCipherHealth(ciphers ?? [], memberCiphers)),
map((cipherHealthReports) =>
this.reportService.generateApplicationsReport(cipherHealthReports),
),
withLatestFrom(this.rawReportData$),
map(([report, previousReport]) => ({
report: report,
summary: this.reportService.getApplicationsSummary(report),
applications: this.reportService.getOrganizationApplications(
report,
previousReport?.data?.applicationData ?? [],
),
})),
switchMap(({ report, summary, applications }) => {
// Save the report after enrichment
return this.reportService
.saveRiskInsightsReport$(report, summary, applications, {
organizationId,
userId,
})
.pipe(
map((result) => ({
report,
summary,
applications,
id: result.response.id,
contentEncryptionKey: result.contentEncryptionKey,
})),
);
}),
// Update the running state
map((mappedResult): ReportState => {
const { id, report, summary, applications, contentEncryptionKey } = mappedResult;
return {
loading: false,
error: null,
data: {
id,
reportData: report,
summaryData: summary,
applicationData: applications,
creationDate: new Date(),
contentEncryptionKey,
},
};
}),
catchError(() => {
return of({ loading: false, error: "Failed to generate or save report", data: null });
}),
startWith({ loading: true, error: null, data: null }),
);
}
/**
* Associates the members with the ciphers they have access to. Calculates the password health.
* Finds the trimmed uris.
* @param ciphers Org ciphers
* @param memberDetails Org members
* @returns Cipher password health data with trimmed uris and associated members
*/
private _getCipherHealth(
ciphers: CipherView[],
memberDetails: MemberDetails[],
): Observable<CipherHealthReport[]> {
const validCiphers = ciphers.filter((cipher) =>
this.passwordHealthService.isValidCipher(cipher),
);
const passwordUseMap = buildPasswordUseMap(validCiphers);
// Check for exposed passwords and map to cipher health report
return this.passwordHealthService.auditPasswordLeaks$(validCiphers).pipe(
map((exposedDetails) => {
return validCiphers.map((cipher) => {
const exposedPasswordDetail = exposedDetails.find((x) => x?.cipherId === cipher.id);
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
const applications = getTrimmedCipherUris(cipher);
const weakPasswordDetail = this.passwordHealthService.findWeakPasswordDetails(cipher);
const reusedPasswordCount = passwordUseMap.get(cipher.login.password!) ?? 0;
return {
cipher,
cipherMembers,
applications,
healthData: {
weakPasswordDetail,
reusedPasswordCount,
exposedPasswordDetail,
},
} as CipherHealthReport;
});
}),
);
}
private _mergeApplicationData(
existingApplications: OrganizationReportApplication[],
criticalApplications: Set<string>,
): OrganizationReportApplication[] {
const setToMerge = new Set(criticalApplications);
// First, iterate through the existing apps and update their isCritical flag
const updatedApps = existingApplications.map((app) => {
const foundCritical = setToMerge.has(app.applicationName);
if (foundCritical) {
setToMerge.delete(app.applicationName);
}
return {
...app,
isCritical: foundCritical || app.isCritical,
};
});
setToMerge.forEach((applicationName) => {
updatedApps.push({
applicationName,
isCritical: true,
reviewedDate: null,
});
});
return updatedApps;
}
// Toggles the isCritical flag on applications via criticalApplicationName
private _removeCriticalApplication(
applicationData: OrganizationReportApplication[],
criticalApplication: string,
): OrganizationReportApplication[] {
const updatedApplicationData = applicationData.map((application) => {
if (application.applicationName == criticalApplication) {
return { ...application, isCritical: false } as OrganizationReportApplication;
}
return application;
});
return updatedApplicationData;
}
private _runMigrationAndCleanup$(criticalApps: PasswordHealthReportApplicationsResponse[]) {
return of(criticalApps).pipe(
withLatestFrom(this.organizationDetails$),
switchMap(([savedCriticalApps, organizationDetails]) => {
// No saved critical apps for migration
if (!savedCriticalApps || savedCriticalApps.length === 0) {
this.logService.debug("[RiskInsightsOrchestratorService] No critical apps to migrate.");
return of([]);
}
const criticalAppsNames = savedCriticalApps.map((app) => app.uri);
const criticalAppsIds = savedCriticalApps.map((app) => app.id);
// Use the setCriticalApplications$ function to update and save the report
return this.saveCriticalApplications$(criticalAppsNames).pipe(
// After setCriticalApplications$ completes, trigger the deletion.
switchMap(() => {
return this.criticalAppsService
.dropCriticalAppsById(organizationDetails!.organizationId, criticalAppsIds)
.pipe(
// After all deletes complete, map to the migrated apps.
tap(() => {
this.logService.debug(
"[RiskInsightsOrchestratorService] Migrated and deleted critical applications.",
);
}),
);
}),
catchError((error: unknown) => {
this.logService.error(
"[RiskInsightsOrchestratorService] Failed to save migrated critical applications",
error,
);
return throwError(() => error);
}),
);
}),
);
}
// Setup the pipeline to load critical applications when organization or user changes
private _setupCriticalApplicationContext() {
this.organizationDetails$
.pipe(
filter((orgDetails) => !!orgDetails),
withLatestFrom(this._userId$),
filter(([_, userId]) => !!userId),
tap(([orgDetails, userId]) => {
this.logService.debug(
"[RiskInsightsOrchestratorService] Loading critical applications for org",
orgDetails!.organizationId,
);
this.criticalAppsService.loadOrganizationContext(orgDetails!.organizationId, userId!);
}),
takeUntil(this._destroy$),
)
.subscribe();
}
// Setup the pipeline to create a report view filtered to only critical applications
private _setupCriticalApplicationReport() {
const criticalReportResultsPipeline$ = this.enrichedReportData$.pipe(
filter((state) => !!state),
map((enrichedReports) => {
const criticalApplications = enrichedReports!.reportData.filter(
(app) => app.isMarkedAsCritical,
);
// Generate a new summary based on just the critical applications
const summary = this.reportService.getApplicationsSummary(criticalApplications);
return {
...enrichedReports,
summaryData: summary,
reportData: criticalApplications,
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.criticalReportResults$ = criticalReportResultsPipeline$;
}
/**
* Takes the basic application health report details and enriches them to include
* critical app status and associated ciphers.
*/
private _setupEnrichedReportData() {
// Setup the enriched report data pipeline
const enrichmentSubscription = combineLatest([
this.rawReportData$.pipe(filter((data) => !!data && !!data?.data)),
this._ciphers$.pipe(filter((data) => !!data)),
]).pipe(
switchMap(([rawReportData, ciphers]) => {
this.logService.debug(
"[RiskInsightsOrchestratorService] Enriching report data with ciphers and critical app status",
);
const criticalApps =
rawReportData?.data?.applicationData.filter((app) => app.isCritical) ?? [];
const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.applicationName));
const rawReports = rawReportData.data?.reportData || [];
const cipherMap = this.reportService.getApplicationCipherMap(ciphers, rawReports);
const enrichedReports: ApplicationHealthReportDetailEnriched[] = rawReports.map((app) => ({
...app,
ciphers: cipherMap.get(app.applicationName) || [],
isMarkedAsCritical: criticalApplicationNames.has(app.applicationName),
}));
const enrichedData = {
...rawReportData.data,
reportData: enrichedReports,
} as RiskInsightsEnrichedData;
return of(enrichedData);
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
enrichmentSubscription.pipe(takeUntil(this._destroy$)).subscribe((enrichedData) => {
this._enrichedReportDataSubject.next(enrichedData);
});
}
// Setup the pipeline to initialize organization context
private _setupInitializationPipeline() {
this._initializeOrganizationTriggerSubject
.pipe(
withLatestFrom(this._userId$),
filter(([orgId, userId]) => !!orgId && !!userId),
exhaustMap(([orgId, userId]) =>
this.organizationService.organizations$(userId!).pipe(
getOrganizationById(orgId),
map((org) => ({ organizationId: orgId!, organizationName: org?.name ?? "" })),
),
),
tap(async (orgDetails) => {
this.logService.debug("[RiskInsightsOrchestratorService] Fetching organization ciphers");
const ciphers = await this.cipherService.getAllFromApiForOrganization(
orgDetails.organizationId,
);
this._ciphersSubject.next(ciphers);
}),
takeUntil(this._destroy$),
)
.subscribe((orgDetails) => this._organizationDetailsSubject.next(orgDetails));
}
private _setupMigrationAndCleanup() {
const criticalApps$ = this.criticalAppsService.criticalAppsList$.pipe(
filter((criticalApps) => criticalApps.length > 0),
take(1),
);
const rawReportData$ = this.rawReportData$.pipe(
filter((reportState) => !!reportState.data),
take(1),
);
this._migrationSubscription = forkJoin([criticalApps$, rawReportData$])
.pipe(
tap(([criticalApps]) => {
this.logService.debug(
`[RiskInsightsOrchestratorService] Detected ${criticalApps.length} legacy critical apps, running migration and cleanup`,
criticalApps,
);
}),
switchMap(([criticalApps, _reportState]) =>
this._runMigrationAndCleanup$(criticalApps).pipe(
catchError((error: unknown) => {
this.logService.error(
"[RiskInsightsOrchestratorService] Migration and cleanup failed.",
error,
);
return of([]);
}),
),
),
take(1),
)
.subscribe();
}
// Setup the report state management pipeline
private _setupReportState() {
// Dependencies needed for report state
const reportDependencies$ = combineLatest([
this.organizationDetails$.pipe(filter((org) => !!org)),
this._userId$.pipe(filter((user) => !!user)),
]).pipe(shareReplay({ bufferSize: 1, refCount: true }));
// A stream for the initial report fetch
const initialReportLoad$ = reportDependencies$.pipe(
take(1),
exhaustMap(([orgDetails, userId]) => this._fetchReport$(orgDetails!.organizationId, userId!)),
);
// A stream for manually triggered fetches
const manualReportFetch$ = this._fetchReportTriggerSubject.pipe(
withLatestFrom(reportDependencies$),
exhaustMap(([_, [orgDetails, userId]]) =>
this._fetchReport$(orgDetails!.organizationId, userId!),
),
);
// A stream for generating a new report
const newReportGeneration$ = this.generatingReport$.pipe(
distinctUntilChanged(),
filter((isRunning) => isRunning),
withLatestFrom(reportDependencies$),
exhaustMap(([_, [orgDetails, userId]]) =>
this._generateNewApplicationsReport$(orgDetails!.organizationId, userId!),
),
tap(() => {
this._generateReportTriggerSubject.next(false);
}),
);
// Combine all triggers and update the single report state
const mergedReportState$ = merge(
initialReportLoad$,
manualReportFetch$,
newReportGeneration$,
).pipe(
scan((prevState: ReportState, currState: ReportState) => ({
...prevState,
...currState,
data: currState.data !== null ? currState.data : prevState.data,
})),
startWith({ loading: false, error: null, data: null }),
shareReplay({ bufferSize: 1, refCount: true }),
takeUntil(this._destroy$),
);
this._reportStateSubscription = mergedReportState$
.pipe(takeUntil(this._destroy$))
.subscribe((state) => {
this._rawReportDataSubject.next(state);
});
}
// Setup the user ID observable to track the current user
private _setupUserId() {
// Watch userId changes
this.accountService.activeAccount$.pipe(getUserId).subscribe((userId) => {
this._userIdSubject.next(userId);
});
}
}

View File

@@ -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,
});
});
});
});

View File

@@ -0,0 +1,385 @@
import { catchError, EMPTY, from, map, Observable, switchMap, throwError } from "rxjs";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { getUniqueMembers } from "../../helpers/risk-insights-data-mappers";
import {
isSaveRiskInsightsReportResponse,
SaveRiskInsightsReportResponse,
} from "../../models/api-models.types";
import {
ApplicationHealthReportDetail,
OrganizationReportSummary,
CipherHealthReport,
PasswordHealthData,
OrganizationReportApplication,
RiskInsightsData,
} from "../../models/report-models";
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
export class RiskInsightsReportService {
constructor(
private riskInsightsApiService: RiskInsightsApiService,
private riskInsightsEncryptionService: RiskInsightsEncryptionService,
) {}
/**
* Report data for the aggregation of uris to like uris and getting password/member counts,
* members, and at risk statuses.
*
* @param ciphers The list of ciphers to analyze
* @param memberCiphers The list of member cipher details to associate members to ciphers
* @returns The all applications health report data
*/
generateApplicationsReport(ciphers: CipherHealthReport[]): ApplicationHealthReportDetail[] {
const groupedByApplication = this._groupCiphersByApplication(ciphers);
return Array.from(groupedByApplication.entries()).map(([application, ciphers]) =>
this._getApplicationHealthReport(application, ciphers),
);
}
/**
* Gets the summary from the application health report. Returns total members and applications as well
* as the total at risk members and at risk applications
* @param reports The previously calculated application health report data
* @returns A summary object containing report totals
*/
getApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary {
const totalMembers = reports.flatMap((x) => x.memberDetails);
const uniqueMembers = getUniqueMembers(totalMembers);
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers);
// TODO: Replace with actual new applications detection logic (PM-26185)
const dummyNewApplications = [
"github.com",
"google.com",
"stackoverflow.com",
"gitlab.com",
"bitbucket.org",
"npmjs.com",
"docker.com",
"aws.amazon.com",
"azure.microsoft.com",
"jenkins.io",
"terraform.io",
"kubernetes.io",
"atlassian.net",
];
return {
totalMemberCount: uniqueMembers.length,
totalAtRiskMemberCount: uniqueAtRiskMembers.length,
totalApplicationCount: reports.length,
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
totalCriticalMemberCount: 0,
totalCriticalAtRiskMemberCount: 0,
totalCriticalApplicationCount: 0,
totalCriticalAtRiskApplicationCount: 0,
newApplications: dummyNewApplications,
};
}
/**
* Generate a snapshot of applications and related data associated to this report
*
* @param reports
* @returns A list of applications with a critical marking flag
*/
getOrganizationApplications(
reports: ApplicationHealthReportDetail[],
previousApplications: OrganizationReportApplication[] = [],
): OrganizationReportApplication[] {
if (previousApplications.length > 0) {
// Preserve existing critical application markings and dates
return reports.map((report) => {
const existingApp = previousApplications.find(
(app) => app.applicationName === report.applicationName,
);
return {
applicationName: report.applicationName,
isCritical: existingApp ? existingApp.isCritical : false,
reviewedDate: existingApp ? existingApp.reviewedDate : null,
};
});
}
// No previous applications, return all as non-critical with current date
return reports.map(
(report): OrganizationReportApplication => ({
applicationName: report.applicationName,
isCritical: false,
reviewedDate: null,
}),
);
}
/**
* Gets the risk insights report for a specific organization and user.
*
* @param organizationId
* @param userId
* @returns An observable that emits the decrypted risk insights report data.
*/
getRiskInsightsReport$(
organizationId: OrganizationId,
userId: UserId,
): Observable<RiskInsightsData> {
return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe(
switchMap((response) => {
if (!response) {
// Return an empty report and summary if response is falsy
return EMPTY;
}
if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") {
return throwError(() => new Error("Report key not found"));
}
if (!response.reportData) {
return throwError(() => new Error("Report data not found"));
}
if (!response.summaryData) {
return throwError(() => new Error("Summary data not found"));
}
if (!response.applicationData) {
return throwError(() => new Error("Application data not found"));
}
return from(
this.riskInsightsEncryptionService.decryptRiskInsightsReport(
{
organizationId,
userId,
},
{
encryptedReportData: response.reportData,
encryptedSummaryData: response.summaryData,
encryptedApplicationData: response.applicationData,
},
response.contentEncryptionKey,
),
).pipe(
map((decryptedData) => {
const newReport: RiskInsightsData = {
id: response.id as OrganizationReportId,
reportData: decryptedData.reportData,
summaryData: decryptedData.summaryData,
applicationData: decryptedData.applicationData,
creationDate: response.creationDate,
contentEncryptionKey: response.contentEncryptionKey,
};
return newReport;
}),
catchError((error: unknown) => {
return throwError(() => error);
}),
);
}),
catchError((error: unknown) => {
return throwError(() => error);
}),
);
}
/**
* Encrypts the risk insights report data for a specific organization.
* @param organizationId The ID of the organization.
* @param userId The ID of the user.
* @param report The report data to encrypt.
* @returns A promise that resolves to an object containing the encrypted data and encryption key.
*/
saveRiskInsightsReport$(
report: ApplicationHealthReportDetail[],
summary: OrganizationReportSummary,
applications: OrganizationReportApplication[],
encryptionParameters: {
organizationId: OrganizationId;
userId: UserId;
},
): Observable<{ response: SaveRiskInsightsReportResponse; contentEncryptionKey: EncString }> {
return from(
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
{
organizationId: encryptionParameters.organizationId,
userId: encryptionParameters.userId,
},
{
reportData: report,
summaryData: summary,
applicationData: applications,
},
),
).pipe(
map(
({
encryptedReportData,
encryptedSummaryData,
encryptedApplicationData,
contentEncryptionKey,
}) => ({
requestPayload: {
data: {
organizationId: encryptionParameters.organizationId,
creationDate: new Date().toISOString(),
reportData: encryptedReportData.toSdk(),
summaryData: encryptedSummaryData.toSdk(),
applicationData: encryptedApplicationData.toSdk(),
contentEncryptionKey: contentEncryptionKey.toSdk(),
},
},
// Keep the original EncString alongside the SDK payload so downstream can return the EncString type.
contentEncryptionKey,
}),
),
switchMap(({ requestPayload, contentEncryptionKey }) =>
this.riskInsightsApiService
.saveRiskInsightsReport$(requestPayload, encryptionParameters.organizationId)
.pipe(
map((response) => ({
response,
contentEncryptionKey,
})),
),
),
catchError((error: unknown) => {
return EMPTY;
}),
map((result) => {
if (!isSaveRiskInsightsReportResponse(result.response)) {
throw new Error("Invalid response from API");
}
return result;
}),
);
}
private _groupCiphersByApplication(
cipherHealthData: CipherHealthReport[],
): Map<string, CipherHealthReport[]> {
const applicationMap = new Map<string, CipherHealthReport[]>();
cipherHealthData.forEach((cipher: CipherHealthReport) => {
cipher.applications.forEach((application) => {
const existingApplication = applicationMap.get(application) || [];
existingApplication.push(cipher);
applicationMap.set(application, existingApplication);
});
});
return applicationMap;
}
/**
*
* @param applications The list of application health report details to map ciphers to
* @param organizationId
* @returns
*/
getApplicationCipherMap(
ciphers: CipherView[],
applications: ApplicationHealthReportDetail[],
): Map<string, CipherView[]> {
const cipherMap = new Map<string, CipherView[]>();
applications.forEach((app) => {
const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id));
cipherMap.set(app.applicationName, filteredCiphers);
});
return cipherMap;
}
// --------------------------- Aggregation methods ---------------------------
/**
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
* If the item is new, create and add the object with the flattened details
* @param cipherHealthReport Cipher and password health info broken out into their uris
* @returns Application health reports
*/
private _getApplicationHealthReport(
application: string,
ciphers: CipherHealthReport[],
): ApplicationHealthReportDetail {
let aggregatedReport: ApplicationHealthReportDetail | undefined;
ciphers.forEach((cipher) => {
const isAtRisk = this._isPasswordAtRisk(cipher.healthData);
aggregatedReport = this._aggregateReport(application, cipher, isAtRisk, aggregatedReport);
});
return aggregatedReport!;
}
private _aggregateReport(
application: string,
newCipherReport: CipherHealthReport,
isAtRisk: boolean,
existingReport?: ApplicationHealthReportDetail,
): ApplicationHealthReportDetail {
let baseReport = existingReport
? this._updateExistingReport(existingReport, newCipherReport)
: this._createNewReport(application, newCipherReport);
if (isAtRisk) {
baseReport = { ...baseReport, ...this._getAtRiskData(baseReport, newCipherReport) };
}
baseReport.memberCount = baseReport.memberDetails.length;
baseReport.atRiskMemberCount = baseReport.atRiskMemberDetails.length;
return baseReport;
}
private _createNewReport(
application: string,
cipherReport: CipherHealthReport,
): ApplicationHealthReportDetail {
return {
applicationName: application,
cipherIds: [cipherReport.cipher.id],
passwordCount: 1,
memberDetails: [...cipherReport.cipherMembers],
memberCount: cipherReport.cipherMembers.length,
atRiskCipherIds: [],
atRiskMemberCount: 0,
atRiskMemberDetails: [],
atRiskPasswordCount: 0,
};
}
private _updateExistingReport(
existingReport: ApplicationHealthReportDetail,
newCipherReport: CipherHealthReport,
): ApplicationHealthReportDetail {
return {
...existingReport,
passwordCount: existingReport.passwordCount + 1,
memberDetails: getUniqueMembers(
existingReport.memberDetails.concat(newCipherReport.cipherMembers),
),
cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id),
};
}
private _getAtRiskData(report: ApplicationHealthReportDetail, cipherReport: CipherHealthReport) {
const atRiskMemberDetails = getUniqueMembers(
report.atRiskMemberDetails.concat(cipherReport.cipherMembers),
);
return {
atRiskPasswordCount: report.atRiskPasswordCount + 1,
atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id),
atRiskMemberDetails,
atRiskMemberCount: atRiskMemberDetails.length,
};
}
// TODO Move to health service
private _isPasswordAtRisk(healthData: PasswordHealthData): boolean {
return !!(
healthData.exposedPasswordDetail ||
healthData.weakPasswordDetail ||
healthData.reusedPasswordCount > 1
);
}
}

View File

@@ -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";

View File

@@ -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",
],
},
],
};

View File

@@ -1,469 +0,0 @@
import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of, throwError } from "rxjs";
import {
catchError,
distinctUntilChanged,
exhaustMap,
filter,
finalize,
map,
shareReplay,
switchMap,
tap,
withLatestFrom,
} from "rxjs/operators";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { ApplicationHealthReportDetailEnriched } from "../models";
import { RiskInsightsEnrichedData } from "../models/report-data-service.types";
import { DrawerType, DrawerDetails, ApplicationHealthReportDetail } from "../models/report-models";
import { CriticalAppsService } from "./critical-apps.service";
import { RiskInsightsReportService } from "./risk-insights-report.service";
export class RiskInsightsDataService {
// -------------------------- Context state --------------------------
// Current user viewing risk insights
private userIdSubject = new BehaviorSubject<UserId | null>(null);
userId$ = this.userIdSubject.asObservable();
// Organization the user is currently viewing
private organizationDetailsSubject = new BehaviorSubject<{
organizationId: OrganizationId;
organizationName: string;
} | null>(null);
organizationDetails$ = this.organizationDetailsSubject.asObservable();
// -------------------------- Data ------------------------------------
// TODO: Remove. Will use report results
private LEGACY_applicationsSubject = new BehaviorSubject<ApplicationHealthReportDetail[] | null>(
null,
);
LEGACY_applications$ = this.LEGACY_applicationsSubject.asObservable();
// TODO: Remove. Will use date from report results
private LEGACY_dataLastUpdatedSubject = new BehaviorSubject<Date | null>(null);
dataLastUpdated$ = this.LEGACY_dataLastUpdatedSubject.asObservable();
// --------------------------- UI State ------------------------------------
private isLoadingSubject = new BehaviorSubject<boolean>(false);
isLoading$ = this.isLoadingSubject.asObservable();
private isRefreshingSubject = new BehaviorSubject<boolean>(false);
isRefreshing$ = this.isRefreshingSubject.asObservable();
private errorSubject = new BehaviorSubject<string | null>(null);
error$ = this.errorSubject.asObservable();
// ------------------------- Drawer Variables ----------------
// Drawer variables unified into a single BehaviorSubject
private drawerDetailsSubject = new BehaviorSubject<DrawerDetails>({
open: false,
invokerId: "",
activeDrawerType: DrawerType.None,
atRiskMemberDetails: [],
appAtRiskMembers: null,
atRiskAppDetails: null,
});
drawerDetails$ = this.drawerDetailsSubject.asObservable();
// ------------------------- Report Variables ----------------
// The last run report details
private reportResultsSubject = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
reportResults$ = this.reportResultsSubject.asObservable();
// Is a report being generated
private isRunningReportSubject = new BehaviorSubject<boolean>(false);
isRunningReport$ = this.isRunningReportSubject.asObservable();
// --------------------------- Critical Application data ---------------------
criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
constructor(
private accountService: AccountService,
private criticalAppsService: CriticalAppsService,
private organizationService: OrganizationService,
private reportService: RiskInsightsReportService,
) {
// Reload report if critical applications change
// This also handles the original report load
this.criticalAppsService.criticalAppsList$
.pipe(withLatestFrom(this.organizationDetails$, this.userId$))
.subscribe({
next: ([_criticalApps, organizationDetails, userId]) => {
if (organizationDetails?.organizationId && userId) {
this.fetchLastReport(organizationDetails?.organizationId, userId);
}
},
});
// Setup critical application data and summary generation for live critical application usage
this.criticalReportResults$ = this.reportResults$.pipe(
filter((report) => !!report),
map((r) => {
const criticalApplications = r.reportData.filter(
(application) => application.isMarkedAsCritical,
);
const summary = this.reportService.generateApplicationsSummary(criticalApplications);
return {
...r,
summaryData: summary,
reportData: criticalApplications,
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
async initializeForOrganization(organizationId: OrganizationId) {
// Fetch current user
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
if (userId) {
this.userIdSubject.next(userId);
}
// [FIXME] getOrganizationById is now deprecated - replace with appropriate method
// Fetch organization details
const org = await firstValueFrom(
this.organizationService.organizations$(userId).pipe(getOrganizationById(organizationId)),
);
if (org) {
this.organizationDetailsSubject.next({
organizationId: organizationId,
organizationName: org.name,
});
}
// Load critical applications for organization
await this.criticalAppsService.loadOrganizationContext(organizationId, userId);
// Setup new report generation
this._runApplicationsReport().subscribe({
next: (result) => {
this.isRunningReportSubject.next(false);
},
error: () => {
this.errorSubject.next("Failed to save report");
},
});
}
/**
* Fetches the applications report and updates the applicationsSubject.
* @param organizationId The ID of the organization.
*/
LEGACY_fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void {
if (isRefresh) {
this.isRefreshingSubject.next(true);
} else {
this.isLoadingSubject.next(true);
}
this.reportService
.LEGACY_generateApplicationsReport$(organizationId)
.pipe(
finalize(() => {
this.isLoadingSubject.next(false);
this.isRefreshingSubject.next(false);
this.LEGACY_dataLastUpdatedSubject.next(new Date());
}),
)
.subscribe({
next: (reports: ApplicationHealthReportDetail[]) => {
this.LEGACY_applicationsSubject.next(reports);
this.errorSubject.next(null);
},
error: () => {
this.LEGACY_applicationsSubject.next([]);
},
});
}
// ------------------------------- Enrichment methods -------------------------------
/**
* Takes the basic application health report details and enriches them to include
* critical app status and associated ciphers.
*
* @param applications The list of application health report details to enrich
* @returns The enriched application health report details with critical app status and ciphers
*/
enrichReportData$(
applications: ApplicationHealthReportDetail[],
): Observable<ApplicationHealthReportDetailEnriched[]> {
// TODO Compare applications on report to updated critical applications
// TODO Compare applications on report to any new applications
return of(applications).pipe(
withLatestFrom(this.organizationDetails$, this.criticalAppsService.criticalAppsList$),
switchMap(async ([apps, orgDetails, criticalApps]) => {
if (!orgDetails) {
return [];
}
// Get ciphers for application
const cipherMap = await this.reportService.getApplicationCipherMap(
apps,
orgDetails.organizationId,
);
// Find critical apps
const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.uri));
// Return enriched application data
return apps.map((app) => ({
...app,
ciphers: cipherMap.get(app.applicationName) || [],
isMarkedAsCritical: criticalApplicationNames.has(app.applicationName),
})) as ApplicationHealthReportDetailEnriched[];
}),
);
}
// ------------------------- Drawer functions -----------------------------
isActiveDrawerType = (drawerType: DrawerType): boolean => {
return this.drawerDetailsSubject.value.activeDrawerType === drawerType;
};
isDrawerOpenForInvoker = (applicationName: string): boolean => {
return this.drawerDetailsSubject.value.invokerId === applicationName;
};
closeDrawer = (): void => {
this.drawerDetailsSubject.next({
open: false,
invokerId: "",
activeDrawerType: DrawerType.None,
atRiskMemberDetails: [],
appAtRiskMembers: null,
atRiskAppDetails: null,
});
};
setDrawerForOrgAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose =
open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId;
if (shouldClose) {
this.closeDrawer();
} else {
const reportResults = await firstValueFrom(this.reportResults$);
if (!reportResults) {
return;
}
const atRiskMemberDetails = this.reportService.generateAtRiskMemberList(
reportResults.reportData,
);
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.OrgAtRiskMembers,
atRiskMemberDetails,
appAtRiskMembers: null,
atRiskAppDetails: null,
});
}
};
setDrawerForAppAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose =
open && activeDrawerType === DrawerType.AppAtRiskMembers && currentInvokerId === invokerId;
if (shouldClose) {
this.closeDrawer();
} else {
const reportResults = await firstValueFrom(this.reportResults$);
if (!reportResults) {
return;
}
const atRiskMembers = {
members:
reportResults.reportData.find((app) => app.applicationName === invokerId)
?.atRiskMemberDetails ?? [],
applicationName: invokerId,
};
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.AppAtRiskMembers,
atRiskMemberDetails: [],
appAtRiskMembers: atRiskMembers,
atRiskAppDetails: null,
});
}
};
setDrawerForOrgAtRiskApps = async (invokerId: string = ""): Promise<void> => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose =
open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId;
if (shouldClose) {
this.closeDrawer();
} else {
const reportResults = await firstValueFrom(this.reportResults$);
if (!reportResults) {
return;
}
const atRiskAppDetails = this.reportService.generateAtRiskApplicationList(
reportResults.reportData,
);
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.OrgAtRiskApps,
atRiskMemberDetails: [],
appAtRiskMembers: null,
atRiskAppDetails,
});
}
};
// ------------------- Trigger Report Generation -------------------
/** Trigger generating a report based on the current applications */
triggerReport(): void {
this.isRunningReportSubject.next(true);
}
/**
* Fetches the applications report and updates the applicationsSubject.
* @param organizationId The ID of the organization.
*/
fetchLastReport(organizationId: OrganizationId, userId: UserId): void {
this.isLoadingSubject.next(true);
this.reportService
.getRiskInsightsReport$(organizationId, userId)
.pipe(
switchMap((report) => {
// Take fetched report data and merge with critical applications
return this.enrichReportData$(report.reportData).pipe(
map((enrichedReport) => ({
report: enrichedReport,
summary: report.summaryData,
applications: report.applicationData,
creationDate: report.creationDate,
})),
);
}),
catchError((error: unknown) => {
// console.error("An error occurred when fetching the last report", error);
return EMPTY;
}),
finalize(() => {
this.isLoadingSubject.next(false);
}),
)
.subscribe({
next: ({ report, summary, applications, creationDate }) => {
this.reportResultsSubject.next({
reportData: report,
summaryData: summary,
applicationData: applications,
creationDate: creationDate,
});
this.errorSubject.next(null);
this.isLoadingSubject.next(false);
},
error: () => {
this.errorSubject.next("Failed to fetch report");
this.reportResultsSubject.next(null);
this.isLoadingSubject.next(false);
},
});
}
private _runApplicationsReport() {
return this.isRunningReport$.pipe(
distinctUntilChanged(),
// Only run this report if the flag for running is true
filter((isRunning) => isRunning),
withLatestFrom(this.organizationDetails$, this.userId$),
exhaustMap(([_, organizationDetails, userId]) => {
const organizationId = organizationDetails?.organizationId;
if (!organizationId || !userId) {
return EMPTY;
}
// Generate the report
return this.reportService.generateApplicationsReport$(organizationId).pipe(
map((report) => ({
report,
summary: this.reportService.generateApplicationsSummary(report),
applications: this.reportService.generateOrganizationApplications(report),
})),
// Enrich report with critical markings
switchMap(({ report, summary, applications }) =>
this.enrichReportData$(report).pipe(
map((enrichedReport) => ({ report: enrichedReport, summary, applications })),
),
),
// Load the updated data into the UI
tap(({ report, summary, applications }) => {
this.reportResultsSubject.next({
reportData: report,
summaryData: summary,
applicationData: applications,
creationDate: new Date(),
});
this.errorSubject.next(null);
}),
switchMap(({ report, summary, applications }) => {
// Save the generated data
return this.reportService.saveRiskInsightsReport$(report, summary, applications, {
organizationId,
userId,
});
}),
);
}),
);
}
// ------------------------------ Critical application methods --------------
saveCriticalApplications(selectedUrls: string[]) {
return this.organizationDetails$.pipe(
exhaustMap((organizationDetails) => {
if (!organizationDetails?.organizationId) {
return EMPTY;
}
return this.criticalAppsService.setCriticalApps(
organizationDetails?.organizationId,
selectedUrls,
);
}),
catchError((error: unknown) => {
this.errorSubject.next("Failed to save critical applications");
return throwError(() => error);
}),
);
}
removeCriticalApplication(hostname: string) {
return this.organizationDetails$.pipe(
exhaustMap((organizationDetails) => {
if (!organizationDetails?.organizationId) {
return EMPTY;
}
return this.criticalAppsService.dropCriticalApp(
organizationDetails?.organizationId,
hostname,
);
}),
catchError((error: unknown) => {
this.errorSubject.next("Failed to remove critical application");
return throwError(() => error);
}),
);
}
}

View File

@@ -1,698 +0,0 @@
import {
catchError,
concatMap,
EMPTY,
first,
firstValueFrom,
forkJoin,
from,
map,
Observable,
of,
switchMap,
throwError,
zip,
} from "rxjs";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
createNewReportData,
flattenMemberDetails,
getApplicationReportDetail,
getFlattenedCipherDetails,
getMemberDetailsFlat,
getTrimmedCipherUris,
getUniqueMembers,
} from "../helpers/risk-insights-data-mappers";
import {
isSaveRiskInsightsReportResponse,
SaveRiskInsightsReportResponse,
} from "../models/api-models.types";
import {
LEGACY_CipherHealthReportDetail,
LEGACY_CipherHealthReportUriDetail,
LEGACY_MemberDetailsFlat,
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
} from "../models/password-health";
import {
ApplicationHealthReportDetail,
OrganizationReportSummary,
AtRiskApplicationDetail,
AtRiskMemberDetail,
CipherHealthReport,
MemberDetails,
PasswordHealthData,
OrganizationReportApplication,
RiskInsightsData,
} from "../models/report-models";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
import { PasswordHealthService } from "./password-health.service";
import { RiskInsightsApiService } from "./risk-insights-api.service";
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
export class RiskInsightsReportService {
// [FIXME] CipherData
// Cipher data
// private _ciphersSubject = new BehaviorSubject<CipherView[] | null>(null);
// _ciphers$ = this._ciphersSubject.asObservable();
constructor(
private cipherService: CipherService,
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
private passwordHealthService: PasswordHealthService,
private riskInsightsApiService: RiskInsightsApiService,
private riskInsightsEncryptionService: RiskInsightsEncryptionService,
) {}
// [FIXME] CipherData
// async loadCiphersForOrganization(organizationId: OrganizationId): Promise<void> {
// await this.cipherService.getAllFromApiForOrganization(organizationId).then((ciphers) => {
// this._ciphersSubject.next(ciphers);
// });
// }
/**
* Report data from raw cipher health data.
* Can be used in the Raw Data diagnostic tab (just exclude the members in the view)
* and can be used in the raw data + members tab when including the members in the view
* @param organizationId
* @returns Cipher health report data with members and trimmed uris
*/
LEGACY_generateRawDataReport$(
organizationId: OrganizationId,
): Observable<LEGACY_CipherHealthReportDetail[]> {
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId));
const memberCiphers$ = from(
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
);
const results$ = zip(allCiphers$, memberCiphers$).pipe(
map(([allCiphers, memberCiphers]) => {
const details: LEGACY_MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
dtl.cipherIds.map((c) => getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c)),
);
return [allCiphers, details] as const;
}),
concatMap(([ciphers, flattenedDetails]) =>
this.LEGACY_getCipherDetails(ciphers, flattenedDetails),
),
first(),
);
return results$;
}
/**
* Report data for raw cipher health broken out into the uris
* Can be used in the raw data + members + uri diagnostic report
* @param organizationId Id of the organization
* @returns Cipher health report data flattened to the uris
*/
generateRawDataUriReport$(
organizationId: OrganizationId,
): Observable<LEGACY_CipherHealthReportUriDetail[]> {
const cipherHealthDetails$ = this.LEGACY_generateRawDataReport$(organizationId);
const results$ = cipherHealthDetails$.pipe(
map((healthDetails) => this.getCipherUriDetails(healthDetails)),
first(),
);
return results$;
}
/**
* Report data for the aggregation of uris to like uris and getting password/member counts,
* members, and at risk statuses.
* @param organizationId Id of the organization
* @returns The all applications health report data
*/
LEGACY_generateApplicationsReport$(
organizationId: OrganizationId,
): Observable<ApplicationHealthReportDetail[]> {
const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId);
const results$ = cipherHealthUriReport$.pipe(
map((uriDetails) => this.LEGACY_getApplicationHealthReport(uriDetails)),
first(),
);
return results$;
}
/**
* Report data for the aggregation of uris to like uris and getting password/member counts,
* members, and at risk statuses.
*
* @param organizationId Id of the organization
* @returns The all applications health report data
*/
generateApplicationsReport$(
organizationId: OrganizationId,
): Observable<ApplicationHealthReportDetail[]> {
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId));
const memberCiphers$ = from(
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers)));
return forkJoin([allCiphers$, memberCiphers$]).pipe(
switchMap(([ciphers, memberCiphers]) => this._getCipherDetails(ciphers, memberCiphers)),
map((cipherApplications) => {
const groupedByApplication = this._groupCiphersByApplication(cipherApplications);
return Array.from(groupedByApplication.entries()).map(([application, ciphers]) =>
this._getApplicationHealthReport(application, ciphers),
);
}),
);
}
/**
* Generates a list of members with at-risk passwords along with the number of at-risk passwords.
*/
generateAtRiskMemberList(
cipherHealthReportDetails: ApplicationHealthReportDetail[],
): AtRiskMemberDetail[] {
const memberRiskMap = new Map<string, number>();
cipherHealthReportDetails.forEach((app) => {
app.atRiskMemberDetails.forEach((member) => {
const currentCount = memberRiskMap.get(member.email) ?? 0;
memberRiskMap.set(member.email, currentCount + 1);
});
});
return Array.from(memberRiskMap.entries()).map(([email, atRiskPasswordCount]) => ({
email,
atRiskPasswordCount,
}));
}
generateAtRiskApplicationList(
cipherHealthReportDetails: ApplicationHealthReportDetail[],
): AtRiskApplicationDetail[] {
const applicationPasswordRiskMap = new Map<string, number>();
cipherHealthReportDetails
.filter((app) => app.atRiskPasswordCount > 0)
.forEach((app) => {
const atRiskPasswordCount = applicationPasswordRiskMap.get(app.applicationName) ?? 0;
applicationPasswordRiskMap.set(
app.applicationName,
atRiskPasswordCount + app.atRiskPasswordCount,
);
});
return Array.from(applicationPasswordRiskMap.entries()).map(
([applicationName, atRiskPasswordCount]) => ({
applicationName,
atRiskPasswordCount,
}),
);
}
/**
* Gets the summary from the application health report. Returns total members and applications as well
* as the total at risk members and at risk applications
* @param reports The previously calculated application health report data
* @returns A summary object containing report totals
*/
generateApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary {
const totalMembers = reports.flatMap((x) => x.memberDetails);
const uniqueMembers = getUniqueMembers(totalMembers);
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers);
// TODO: Replace with actual new applications detection logic (PM-26185)
const dummyNewApplications = [
"github.com",
"google.com",
"stackoverflow.com",
"gitlab.com",
"bitbucket.org",
"npmjs.com",
"docker.com",
"aws.amazon.com",
"azure.microsoft.com",
"jenkins.io",
"terraform.io",
"kubernetes.io",
"atlassian.net",
];
return {
totalMemberCount: uniqueMembers.length,
totalAtRiskMemberCount: uniqueAtRiskMembers.length,
totalApplicationCount: reports.length,
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
totalCriticalMemberCount: 0,
totalCriticalAtRiskMemberCount: 0,
totalCriticalApplicationCount: 0,
totalCriticalAtRiskApplicationCount: 0,
newApplications: dummyNewApplications,
};
}
/**
* Generate a snapshot of applications and related data associated to this report
*
* @param reports
* @returns A list of applications with a critical marking flag
*/
generateOrganizationApplications(
reports: ApplicationHealthReportDetail[],
): OrganizationReportApplication[] {
return reports.map((report) => ({
applicationName: report.applicationName,
isCritical: false,
}));
}
async identifyCiphers(
data: ApplicationHealthReportDetail[],
organizationId: OrganizationId,
): Promise<LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[]> {
const cipherViews = await this.cipherService.getAllFromApiForOrganization(organizationId);
const dataWithCiphers = data.map(
(app, index) =>
({
...app,
ciphers: cipherViews.filter((c) => app.cipherIds.some((a) => a === c.id)),
}) as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
);
return dataWithCiphers;
}
/**
* Gets the risk insights report for a specific organization and user.
*
* @param organizationId
* @param userId
* @returns An observable that emits the decrypted risk insights report data.
*/
getRiskInsightsReport$(
organizationId: OrganizationId,
userId: UserId,
): Observable<RiskInsightsData> {
return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe(
switchMap((response) => {
if (!response) {
// Return an empty report and summary if response is falsy
return of<RiskInsightsData>(createNewReportData());
}
if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") {
return throwError(() => new Error("Report key not found"));
}
if (!response.reportData) {
return throwError(() => new Error("Report data not found"));
}
if (!response.summaryData) {
return throwError(() => new Error("Summary data not found"));
}
if (!response.applicationData) {
return throwError(() => new Error("Application data not found"));
}
return from(
this.riskInsightsEncryptionService.decryptRiskInsightsReport(
{
organizationId,
userId,
},
{
encryptedReportData: response.reportData,
encryptedSummaryData: response.summaryData,
encryptedApplicationData: response.applicationData,
},
response.contentEncryptionKey,
),
).pipe(
map((decryptedData) => ({
reportData: decryptedData.reportData,
summaryData: decryptedData.summaryData,
applicationData: decryptedData.applicationData,
creationDate: response.creationDate,
})),
catchError((error: unknown) => {
// TODO Handle errors appropriately
// console.error("An error occurred when decrypting report", error);
return EMPTY;
}),
);
}),
catchError((error: unknown) => {
// console.error("An error occurred when fetching the last report", error);
return EMPTY;
}),
);
}
/**
* Encrypts the risk insights report data for a specific organization.
* @param organizationId The ID of the organization.
* @param userId The ID of the user.
* @param report The report data to encrypt.
* @returns A promise that resolves to an object containing the encrypted data and encryption key.
*/
saveRiskInsightsReport$(
report: ApplicationHealthReportDetail[],
summary: OrganizationReportSummary,
applications: OrganizationReportApplication[],
encryptionParameters: {
organizationId: OrganizationId;
userId: UserId;
},
): Observable<SaveRiskInsightsReportResponse> {
return from(
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
{
organizationId: encryptionParameters.organizationId,
userId: encryptionParameters.userId,
},
{
reportData: report,
summaryData: summary,
applicationData: applications,
},
),
).pipe(
map(
({
encryptedReportData,
encryptedSummaryData,
encryptedApplicationData,
contentEncryptionKey,
}) => ({
data: {
organizationId: encryptionParameters.organizationId,
creationDate: new Date().toISOString(),
reportData: encryptedReportData.toSdk(),
summaryData: encryptedSummaryData.toSdk(),
applicationData: encryptedApplicationData.toSdk(),
contentEncryptionKey: contentEncryptionKey.toSdk(),
},
}),
),
switchMap((encryptedReport) =>
this.riskInsightsApiService.saveRiskInsightsReport$(
encryptedReport,
encryptionParameters.organizationId,
),
),
catchError((error: unknown) => {
return EMPTY;
}),
map((response) => {
if (!isSaveRiskInsightsReportResponse(response)) {
throw new Error("Invalid response from API");
}
return response;
}),
);
}
/**
* Associates the members with the ciphers they have access to. Calculates the password health.
* Finds the trimmed uris.
* @param ciphers Org ciphers
* @param memberDetails Org members
* @returns Cipher password health data with trimmed uris and associated members
*/
private async LEGACY_getCipherDetails(
ciphers: CipherView[],
memberDetails: LEGACY_MemberDetailsFlat[],
): Promise<LEGACY_CipherHealthReportDetail[]> {
const cipherHealthReports: LEGACY_CipherHealthReportDetail[] = [];
const passwordUseMap = new Map<string, number>();
const exposedDetails = await firstValueFrom(
this.passwordHealthService.auditPasswordLeaks$(ciphers),
);
for (const cipher of ciphers) {
if (this.passwordHealthService.isValidCipher(cipher)) {
const weakPassword = this.passwordHealthService.findWeakPasswordDetails(cipher);
// Looping over all ciphers needs to happen first to determine reused passwords over all ciphers.
// Store in the set and evaluate later
if (passwordUseMap.has(cipher.login.password!)) {
passwordUseMap.set(
cipher.login.password!,
(passwordUseMap.get(cipher.login.password!) || 0) + 1,
);
} else {
passwordUseMap.set(cipher.login.password!, 1);
}
const exposedPassword = exposedDetails.find((x) => x?.cipherId === cipher.id);
// Get the cipher members
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
// Trim uris to host name and create the cipher health report
const cipherTrimmedUris = getTrimmedCipherUris(cipher);
const cipherHealth = {
...cipher,
weakPasswordDetail: weakPassword,
exposedPasswordDetail: exposedPassword,
cipherMembers: cipherMembers,
trimmedUris: cipherTrimmedUris,
} as LEGACY_CipherHealthReportDetail;
cipherHealthReports.push(cipherHealth);
}
}
// loop for reused passwords
cipherHealthReports.forEach((detail) => {
detail.reusedPasswordCount = passwordUseMap.get(detail.login.password!) ?? 0;
});
return cipherHealthReports;
}
/**
* Flattens the cipher to trimmed uris. Used for the raw data + uri
* @param cipherHealthReport Cipher health report with uris and members
* @returns Flattened cipher health details to uri
*/
private getCipherUriDetails(
cipherHealthReport: LEGACY_CipherHealthReportDetail[],
): LEGACY_CipherHealthReportUriDetail[] {
return cipherHealthReport.flatMap((rpt) =>
rpt.trimmedUris.map((u) => getFlattenedCipherDetails(rpt, u)),
);
}
/**
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
* If the item is new, create and add the object with the flattened details
* @param cipherHealthUriReport Cipher and password health info broken out into their uris
* @returns Application health reports
*/
private LEGACY_getApplicationHealthReport(
cipherHealthUriReport: LEGACY_CipherHealthReportUriDetail[],
): ApplicationHealthReportDetail[] {
const appReports: ApplicationHealthReportDetail[] = [];
cipherHealthUriReport.forEach((uri) => {
const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri);
let atRisk: boolean = false;
if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) {
atRisk = true;
}
if (index === -1) {
appReports.push(getApplicationReportDetail(uri, atRisk));
} else {
appReports[index] = getApplicationReportDetail(uri, atRisk, appReports[index]);
}
});
return appReports;
}
private _buildPasswordUseMap(ciphers: CipherView[]): Map<string, number> {
const passwordUseMap = new Map<string, number>();
ciphers.forEach((cipher) => {
const password = cipher.login.password!;
passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1);
});
return passwordUseMap;
}
private _groupCiphersByApplication(
cipherHealthData: CipherHealthReport[],
): Map<string, CipherHealthReport[]> {
const applicationMap = new Map<string, CipherHealthReport[]>();
cipherHealthData.forEach((cipher: CipherHealthReport) => {
// Warning: Currently does not show ciphers with NO Application
// if (cipher.applications.length === 0) {
// const existingApplication = applicationMap.get("None") || [];
// existingApplication.push(cipher);
// applicationMap.set("None", existingApplication);
// }
cipher.applications.forEach((application) => {
const existingApplication = applicationMap.get(application) || [];
existingApplication.push(cipher);
applicationMap.set(application, existingApplication);
});
});
return applicationMap;
}
/**
*
* @param applications The list of application health report details to map ciphers to
* @param organizationId
* @returns
*/
async getApplicationCipherMap(
applications: ApplicationHealthReportDetail[],
organizationId: OrganizationId,
): Promise<Map<string, CipherView[]>> {
// [FIXME] CipherData
// This call is made multiple times. We can optimize this
// by loading the ciphers once via a load method to avoid multiple API calls
// for the same organization
const allCiphers = await this.cipherService.getAllFromApiForOrganization(organizationId);
const cipherMap = new Map<string, CipherView[]>();
applications.forEach((app) => {
const filteredCiphers = allCiphers.filter((c) => app.cipherIds.includes(c.id));
cipherMap.set(app.applicationName, filteredCiphers);
});
return cipherMap;
}
// --------------------------- Aggregation methods ---------------------------
/**
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
* If the item is new, create and add the object with the flattened details
* @param cipherHealthReport Cipher and password health info broken out into their uris
* @returns Application health reports
*/
private _getApplicationHealthReport(
application: string,
ciphers: CipherHealthReport[],
): ApplicationHealthReportDetail {
let aggregatedReport: ApplicationHealthReportDetail | undefined;
ciphers.forEach((cipher) => {
const isAtRisk = this._isPasswordAtRisk(cipher.healthData);
aggregatedReport = this._aggregateReport(application, cipher, isAtRisk, aggregatedReport);
});
return aggregatedReport!;
}
private _aggregateReport(
application: string,
newCipherReport: CipherHealthReport,
isAtRisk: boolean,
existingReport?: ApplicationHealthReportDetail,
): ApplicationHealthReportDetail {
let baseReport = existingReport
? this._updateExistingReport(existingReport, newCipherReport)
: this._createNewReport(application, newCipherReport);
if (isAtRisk) {
baseReport = { ...baseReport, ...this._getAtRiskData(baseReport, newCipherReport) };
}
baseReport.memberCount = baseReport.memberDetails.length;
baseReport.atRiskMemberCount = baseReport.atRiskMemberDetails.length;
return baseReport;
}
private _createNewReport(
application: string,
cipherReport: CipherHealthReport,
): ApplicationHealthReportDetail {
return {
applicationName: application,
cipherIds: [cipherReport.cipher.id],
passwordCount: 1,
memberDetails: [...cipherReport.cipherMembers],
memberCount: cipherReport.cipherMembers.length,
atRiskCipherIds: [],
atRiskMemberCount: 0,
atRiskMemberDetails: [],
atRiskPasswordCount: 0,
};
}
private _updateExistingReport(
existingReport: ApplicationHealthReportDetail,
newCipherReport: CipherHealthReport,
): ApplicationHealthReportDetail {
return {
...existingReport,
passwordCount: existingReport.passwordCount + 1,
memberDetails: getUniqueMembers(
existingReport.memberDetails.concat(newCipherReport.cipherMembers),
),
cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id),
};
}
private _getAtRiskData(report: ApplicationHealthReportDetail, cipherReport: CipherHealthReport) {
const atRiskMemberDetails = getUniqueMembers(
report.atRiskMemberDetails.concat(cipherReport.cipherMembers),
);
return {
atRiskPasswordCount: report.atRiskPasswordCount + 1,
atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id),
atRiskMemberDetails,
atRiskMemberCount: atRiskMemberDetails.length,
};
}
// TODO Move to health service
private _isPasswordAtRisk(healthData: PasswordHealthData): boolean {
return !!(
healthData.exposedPasswordDetail ||
healthData.weakPasswordDetail ||
healthData.reusedPasswordCount > 1
);
}
/**
* Associates the members with the ciphers they have access to. Calculates the password health.
* Finds the trimmed uris.
* @param ciphers Org ciphers
* @param memberDetails Org members
* @returns Cipher password health data with trimmed uris and associated members
*/
private _getCipherDetails(
ciphers: CipherView[],
memberDetails: MemberDetails[],
): Observable<CipherHealthReport[]> {
const validCiphers = ciphers.filter((cipher) =>
this.passwordHealthService.isValidCipher(cipher),
);
// Build password use map
const passwordUseMap = this._buildPasswordUseMap(validCiphers);
return this.passwordHealthService.auditPasswordLeaks$(validCiphers).pipe(
map((exposedDetails) => {
return validCiphers.map((cipher) => {
const exposedPassword = exposedDetails.find((x) => x?.cipherId === cipher.id);
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
const result = {
cipher: cipher,
cipherMembers,
healthData: {
weakPasswordDetail: this.passwordHealthService.findWeakPasswordDetails(cipher),
exposedPasswordDetail: exposedPassword,
reusedPasswordCount: passwordUseMap.get(cipher.login.password!) ?? 0,
},
applications: getTrimmedCipherUris(cipher),
} as CipherHealthReport;
return result;
});
}),
);
}
}

View File

@@ -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);

View File

@@ -0,0 +1,186 @@
import { BehaviorSubject, firstValueFrom, Observable, of, Subject } from "rxjs";
import { distinctUntilChanged, map } from "rxjs/operators";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { getAtRiskApplicationList, getAtRiskMemberList } from "../../helpers";
import { ReportState, DrawerDetails, DrawerType, RiskInsightsEnrichedData } from "../../models";
import { RiskInsightsOrchestratorService } from "../domain/risk-insights-orchestrator.service";
export class RiskInsightsDataService {
private _destroy$ = new Subject<void>();
// -------------------------- Context state --------------------------
// Organization the user is currently viewing
readonly organizationDetails$: Observable<{
organizationId: OrganizationId;
organizationName: string;
} | null> = of(null);
// --------------------------- UI State ------------------------------------
private errorSubject = new BehaviorSubject<string | null>(null);
error$ = this.errorSubject.asObservable();
// -------------------------- Orchestrator-driven state -------------
// The full report state (for internal facade use or complex components)
private readonly reportState$: Observable<ReportState>;
readonly isLoading$: Observable<boolean> = of(false);
readonly enrichedReportData$: Observable<RiskInsightsEnrichedData | null> = of(null);
readonly isGeneratingReport$: Observable<boolean> = of(false);
readonly criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
// ------------------------- Drawer Variables ---------------------
// Drawer variables unified into a single BehaviorSubject
private drawerDetailsSubject = new BehaviorSubject<DrawerDetails>({
open: false,
invokerId: "",
activeDrawerType: DrawerType.None,
atRiskMemberDetails: [],
appAtRiskMembers: null,
atRiskAppDetails: null,
});
drawerDetails$ = this.drawerDetailsSubject.asObservable();
// --------------------------- Critical Application data ---------------------
constructor(private orchestrator: RiskInsightsOrchestratorService) {
this.reportState$ = this.orchestrator.rawReportData$;
this.isGeneratingReport$ = this.orchestrator.generatingReport$;
this.organizationDetails$ = this.orchestrator.organizationDetails$;
this.enrichedReportData$ = this.orchestrator.enrichedReportData$;
this.criticalReportResults$ = this.orchestrator.criticalReportResults$;
// Expose the loading state
this.isLoading$ = this.reportState$.pipe(
map((state) => state.loading),
distinctUntilChanged(), // Prevent unnecessary component re-renders
);
}
destroy(): void {
this._destroy$.next();
this._destroy$.complete();
}
// ----- UI-triggered methods (delegate to orchestrator) -----
initializeForOrganization(organizationId: OrganizationId) {
this.orchestrator.initializeForOrganization(organizationId);
}
triggerReport(): void {
this.orchestrator.generateReport();
}
fetchReport(): void {
this.orchestrator.fetchReport();
}
// ------------------------- Drawer functions -----------------------------
isActiveDrawerType = (drawerType: DrawerType): boolean => {
return this.drawerDetailsSubject.value.activeDrawerType === drawerType;
};
isDrawerOpenForInvoker = (applicationName: string): boolean => {
return this.drawerDetailsSubject.value.invokerId === applicationName;
};
closeDrawer = (): void => {
this.drawerDetailsSubject.next({
open: false,
invokerId: "",
activeDrawerType: DrawerType.None,
atRiskMemberDetails: [],
appAtRiskMembers: null,
atRiskAppDetails: null,
});
};
setDrawerForOrgAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose =
open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId;
if (shouldClose) {
this.closeDrawer();
} else {
const reportResults = await firstValueFrom(this.enrichedReportData$);
if (!reportResults) {
return;
}
const atRiskMemberDetails = getAtRiskMemberList(reportResults.reportData);
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.OrgAtRiskMembers,
atRiskMemberDetails,
appAtRiskMembers: null,
atRiskAppDetails: null,
});
}
};
setDrawerForAppAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose =
open && activeDrawerType === DrawerType.AppAtRiskMembers && currentInvokerId === invokerId;
if (shouldClose) {
this.closeDrawer();
} else {
const reportResults = await firstValueFrom(this.enrichedReportData$);
if (!reportResults) {
return;
}
const atRiskMembers = {
members:
reportResults.reportData.find((app) => app.applicationName === invokerId)
?.atRiskMemberDetails ?? [],
applicationName: invokerId,
};
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.AppAtRiskMembers,
atRiskMemberDetails: [],
appAtRiskMembers: atRiskMembers,
atRiskAppDetails: null,
});
}
};
setDrawerForOrgAtRiskApps = async (invokerId: string = ""): Promise<void> => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose =
open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId;
if (shouldClose) {
this.closeDrawer();
} else {
const reportResults = await firstValueFrom(this.enrichedReportData$);
if (!reportResults) {
return;
}
const atRiskAppDetails = getAtRiskApplicationList(reportResults.reportData);
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.OrgAtRiskApps,
atRiskMemberDetails: [],
appAtRiskMembers: null,
atRiskAppDetails,
});
}
};
// ------------------------------ Critical application methods --------------
saveCriticalApplications(selectedUrls: string[]) {
return this.orchestrator.saveCriticalApplications$(selectedUrls);
}
removeCriticalApplication(hostname: string) {
return this.orchestrator.removeCriticalApplication$(hostname);
}
}

View File

@@ -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,

View File

@@ -1,12 +1,12 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { Component, OnInit, ChangeDetectionStrategy } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Subject, switchMap, takeUntil, of, BehaviorSubject, combineLatest } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
AllActivitiesService,
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
ApplicationHealthReportDetailEnriched,
SecurityTasksApiService,
TaskMetrics,
OrganizationReportSummary,
@@ -14,17 +14,12 @@ import {
import { OrganizationId } from "@bitwarden/common/types/guid";
import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components";
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service";
export const RenderMode = {
noCriticalApps: "noCriticalApps",
criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks",
criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks",
} as const;
export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode];
import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service";
import { RenderMode } from "../../models/activity.models";
import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service";
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "dirt-password-change-metric",
imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule],
templateUrl: "./password-change-metric.component.html",
@@ -34,8 +29,7 @@ export class PasswordChangeMetricComponent implements OnInit {
protected taskMetrics$ = new BehaviorSubject<TaskMetrics>({ totalTasks: 0, completedTasks: 0 });
private completedTasks: number = 0;
private totalTasks: number = 0;
private allApplicationsDetails: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[] =
[];
private allApplicationsDetails: ApplicationHealthReportDetailEnriched[] = [];
atRiskAppsCount: number = 0;
atRiskPasswordsCount: number = 0;
@@ -43,6 +37,13 @@ export class PasswordChangeMetricComponent implements OnInit {
private destroyRef = new Subject<void>();
renderMode: RenderMode = "noCriticalApps";
constructor(
private activatedRoute: ActivatedRoute,
private securityTasksApiService: SecurityTasksApiService,
private allActivitiesService: AllActivitiesService,
protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
) {}
async ngOnInit(): Promise<void> {
combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$])
.pipe(
@@ -83,13 +84,6 @@ export class PasswordChangeMetricComponent implements OnInit {
});
}
constructor(
private activatedRoute: ActivatedRoute,
private securityTasksApiService: SecurityTasksApiService,
private allActivitiesService: AllActivitiesService,
protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
) {}
private determineRenderMode(
summary: OrganizationReportSummary,
taskMetrics: TaskMetrics,

View File

@@ -11,16 +11,16 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
import { ToastService, DialogService } from "@bitwarden/components";
import { DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { RiskInsightsTabType } from "../models/risk-insights.models";
import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component";
import { ActivityCardComponent } from "./activity-card.component";
import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component";
import { NewApplicationsDialogComponent } from "./new-applications-dialog.component";
import { ApplicationsLoadingComponent } from "./risk-insights-loading.component";
import { RiskInsightsTabType } from "./risk-insights.component";
@Component({
selector: "dirt-all-activity",
@@ -43,6 +43,15 @@ export class AllActivityComponent implements OnInit {
destroyRef = inject(DestroyRef);
constructor(
private accountService: AccountService,
protected activatedRoute: ActivatedRoute,
protected allActivitiesService: AllActivitiesService,
protected dataService: RiskInsightsDataService,
private dialogService: DialogService,
protected organizationService: OrganizationService,
) {}
async ngOnInit(): Promise<void> {
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId");
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
@@ -71,17 +80,6 @@ export class AllActivityComponent implements OnInit {
}
}
constructor(
protected activatedRoute: ActivatedRoute,
private accountService: AccountService,
protected organizationService: OrganizationService,
protected dataService: RiskInsightsDataService,
protected allActivitiesService: AllActivitiesService,
private toastService: ToastService,
private i18nService: I18nService,
private dialogService: DialogService,
) {}
get RiskInsightsTabType() {
return RiskInsightsTabType;
}

View File

@@ -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 ?? [];

View File

@@ -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",

View File

@@ -0,0 +1,7 @@
export const RenderMode = {
noCriticalApps: "noCriticalApps",
criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks",
criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks",
} as const;
export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode];

View File

@@ -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,
}

View File

@@ -1,11 +0,0 @@
<!-- <bit-table [dataSource]="dataSource"> -->
<ng-container header>
<tr>
<th bitCell>{{ "member" | i18n }}</th>
<th bitCell>{{ "atRiskPasswords" | i18n }}</th>
<th bitCell>{{ "totalPasswords" | i18n }}</th>
<th bitCell>{{ "atRiskApplications" | i18n }}</th>
<th bitCell>{{ "totalApplications" | i18n }}</th>
</tr>
</ng-container>
<!-- </bit-table> -->

View File

@@ -1,18 +0,0 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { TableDataSource, TableModule } from "@bitwarden/components";
@Component({
selector: "tools-notified-members-table",
templateUrl: "./notified-members-table.component.html",
imports: [CommonModule, JslibModule, TableModule],
})
export class NotifiedMembersTableComponent {
dataSource = new TableDataSource<any>();
constructor() {
this.dataSource.data = [];
}
}

View File

@@ -17,7 +17,7 @@
} @else {
<span class="tw-mx-4">{{ "noReportRan" | i18n }}</span>
}
@let isRunningReport = dataService.isRunningReport$ | async;
@let isRunningReport = dataService.isGeneratingReport$ | async;
<span class="tw-flex tw-justify-center">
<button
*ngIf="!isRunningReport"
@@ -26,7 +26,7 @@
buttonType="secondary"
class="tw-border-none !tw-font-normal tw-cursor-pointer !tw-py-0"
tabindex="0"
[bitAction]="refreshData.bind(this)"
[bitAction]="generateReport.bind(this)"
>
{{ "riskInsightsRunReport" | i18n }}
</button>

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -2,7 +2,7 @@ import { Injectable } from "@angular/core";
import {
AllActivitiesService,
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
ApplicationHealthReportDetailEnriched,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
@@ -20,10 +20,7 @@ export class AccessIntelligenceSecurityTasksService {
private toastService: ToastService,
private i18nService: I18nService,
) {}
async assignTasks(
organizationId: OrganizationId,
apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[],
) {
async assignTasks(organizationId: OrganizationId, apps: ApplicationHealthReportDetailEnriched[]) {
const taskCount = await this.requestPasswordChange(organizationId, apps);
this.allActivitiesService.setTaskCreatedCount(taskCount);
}
@@ -31,7 +28,7 @@ export class AccessIntelligenceSecurityTasksService {
// TODO: this method is shared between here and critical-applications.component.ts
async requestPasswordChange(
organizationId: OrganizationId,
apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[],
apps: ApplicationHealthReportDetailEnriched[],
): Promise<number> {
// Only create tasks for CRITICAL applications with at-risk passwords
const cipherIds = apps