1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

[PM-25611][PM-25612] Update components to use persistance code (#16655)

* Add password trigger logic to report service. Also updated api to use classes that properly handle encstring with placeholders for upcoming usage

* Fix merged test case conflict

* Fix type errors and test cases. Make create data functions for report and summary

* Update Risk Insights Report Data Type

* Update encryption usage and test cases. Moved mock data

* Remove unused variable

* Move all-application constructor

* Update all applications and risk insights to look at fetched logic

* Fix name of variable. Fetch last report run

* Cleanup all and critical application tabs drawer dependencies

* Rename components from tool to dirt. Hook up all applications to use reportResult summary

* Critical application cleanup. Trigger refetch of report for enriching when critical applications change

* Fix type errors

* Rename loader from tools to dirt. Cleanup

* Add activity tab updates using data service

* Use safeProviders in access intelligence

* Fix refresh button not appearing. Change "refresh" to "run report"

* Remove multiple async calls for isRunningReport

* Fix report button not showing

* Add no report ran message

* Fix password change on critical applications
This commit is contained in:
Leslie Tilton
2025-10-06 12:01:06 -05:00
committed by GitHub
parent 6d98360b04
commit 8c81ccc1c5
30 changed files with 1055 additions and 846 deletions

View File

@@ -35,6 +35,9 @@
} }
} }
}, },
"noReportRan": {
"message": "You have not created a report yet"
},
"notifiedMembers": { "notifiedMembers": {
"message": "Notified members" "message": "Notified members"
}, },
@@ -187,6 +190,9 @@
"applicationsMarkedAsCriticalSuccess": { "applicationsMarkedAsCriticalSuccess": {
"message": "Applications marked as critical" "message": "Applications marked as critical"
}, },
"applicationsMarkedAsCriticalFail": {
"message": "Failed to mark applications as critical"
},
"application": { "application": {
"message": "Application" "message": "Application"
}, },
@@ -4317,6 +4323,9 @@
"generatingYourRiskInsights": { "generatingYourRiskInsights": {
"message": "Generating your Risk Insights..." "message": "Generating your Risk Insights..."
}, },
"riskInsightsRunReport": {
"message": "Run report"
},
"updateBrowserDesc": { "updateBrowserDesc": {
"message": "You are using an unsupported web browser. The web vault may not function properly." "message": "You are using an unsupported web browser. The web vault may not function properly."
}, },

View File

@@ -9,7 +9,7 @@ import {
import { import {
ApplicationHealthReportDetail, ApplicationHealthReportDetail,
OrganizationReportSummary, OrganizationReportSummary,
RiskInsightsReportData, RiskInsightsData,
} from "../models/report-models"; } from "../models/report-models";
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
@@ -154,10 +154,12 @@ export function getApplicationReportDetail(
* *
* @returns An empty report * @returns An empty report
*/ */
export function createNewReportData(): RiskInsightsReportData { export function createNewReportData(): RiskInsightsData {
return { return {
data: [], creationDate: new Date(),
summary: createNewSummaryData(), reportData: [],
summaryData: createNewSummaryData(),
applicationData: [],
}; };
} }

View File

@@ -37,8 +37,10 @@ export interface PasswordHealthReportApplicationsRequest {
export interface SaveRiskInsightsReportRequest { export interface SaveRiskInsightsReportRequest {
data: { data: {
organizationId: OrganizationId; organizationId: OrganizationId;
date: string; creationDate: string;
reportData: string; reportData: string;
summaryData: string;
applicationData: string;
contentEncryptionKey: string; contentEncryptionKey: string;
}; };
} }
@@ -58,9 +60,10 @@ export function isSaveRiskInsightsReportResponse(obj: any): obj is SaveRiskInsig
export class GetRiskInsightsReportResponse extends BaseResponse { export class GetRiskInsightsReportResponse extends BaseResponse {
id: string; id: string;
organizationId: OrganizationId; organizationId: OrganizationId;
// TODO Update to use creationDate from server creationDate: Date;
date: string;
reportData: EncString; reportData: EncString;
summaryData: EncString;
applicationData: EncString;
contentEncryptionKey: EncString; contentEncryptionKey: EncString;
constructor(response: any) { constructor(response: any) {
@@ -68,8 +71,10 @@ export class GetRiskInsightsReportResponse extends BaseResponse {
this.id = this.getResponseProperty("organizationId"); this.id = this.getResponseProperty("organizationId");
this.organizationId = this.getResponseProperty("organizationId"); this.organizationId = this.getResponseProperty("organizationId");
this.date = this.getResponseProperty("date"); this.creationDate = new Date(this.getResponseProperty("creationDate"));
this.reportData = new EncString(this.getResponseProperty("reportData")); this.reportData = new EncString(this.getResponseProperty("reportData"));
this.summaryData = new EncString(this.getResponseProperty("summaryData"));
this.applicationData = new EncString(this.getResponseProperty("applicationData"));
this.contentEncryptionKey = new EncString(this.getResponseProperty("contentEncryptionKey")); this.contentEncryptionKey = new EncString(this.getResponseProperty("contentEncryptionKey"));
} }
} }
@@ -77,7 +82,7 @@ export class GetRiskInsightsReportResponse extends BaseResponse {
export class GetRiskInsightsSummaryResponse extends BaseResponse { export class GetRiskInsightsSummaryResponse extends BaseResponse {
id: string; id: string;
organizationId: OrganizationId; organizationId: OrganizationId;
encryptedData: EncString; // Decrypted as OrganizationReportSummary encryptedSummary: EncString; // Decrypted as OrganizationReportSummary
contentEncryptionKey: EncString; contentEncryptionKey: EncString;
constructor(response: any) { constructor(response: any) {
@@ -85,7 +90,7 @@ export class GetRiskInsightsSummaryResponse extends BaseResponse {
// TODO Handle taking array of summary data and converting to array // TODO Handle taking array of summary data and converting to array
this.id = this.getResponseProperty("id"); this.id = this.getResponseProperty("id");
this.organizationId = this.getResponseProperty("organizationId"); this.organizationId = this.getResponseProperty("organizationId");
this.encryptedData = this.getResponseProperty("encryptedData"); this.encryptedSummary = this.getResponseProperty("encryptedData");
this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey");
} }

View File

@@ -1,3 +1,5 @@
export * from "./api-models.types"; export * from "./api-models.types";
export * from "./password-health"; export * from "./password-health";
export * from "./report-data-service.types";
export * from "./report-encryption.types";
export * from "./report-models"; export * from "./report-models";

View File

@@ -0,0 +1,140 @@
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 {
ApplicationHealthReportDetail,
OrganizationReportApplication,
OrganizationReportSummary,
} from "./report-models";
const mockApplication1: ApplicationHealthReportDetail = {
applicationName: "application1.com",
passwordCount: 2,
atRiskPasswordCount: 1,
atRiskCipherIds: ["cipher-1"],
memberCount: 2,
atRiskMemberCount: 1,
memberDetails: [
{
userGuid: "user-id-1",
userName: "tom",
email: "tom@application1.com",
cipherId: "cipher-1",
},
],
atRiskMemberDetails: [
{
userGuid: "user-id-2",
userName: "tom",
email: "tom2@application1.com",
cipherId: "cipher-2",
},
],
cipherIds: ["cipher-1", "cipher-2"],
};
const mockApplication2: ApplicationHealthReportDetail = {
applicationName: "site2.application1.com",
passwordCount: 0,
atRiskPasswordCount: 0,
atRiskCipherIds: [],
memberCount: 0,
atRiskMemberCount: 0,
memberDetails: [],
atRiskMemberDetails: [],
cipherIds: [],
};
const mockApplication3: ApplicationHealthReportDetail = {
applicationName: "application2.com",
passwordCount: 0,
atRiskPasswordCount: 0,
atRiskCipherIds: [],
memberCount: 0,
atRiskMemberCount: 0,
memberDetails: [],
atRiskMemberDetails: [],
cipherIds: [],
};
export const mockReportData: ApplicationHealthReportDetail[] = [
mockApplication1,
mockApplication2,
mockApplication3,
];
export const mockSummaryData: OrganizationReportSummary = {
totalMemberCount: 5,
totalAtRiskMemberCount: 2,
totalApplicationCount: 3,
totalAtRiskApplicationCount: 1,
totalCriticalMemberCount: 1,
totalCriticalAtRiskMemberCount: 1,
totalCriticalApplicationCount: 1,
totalCriticalAtRiskApplicationCount: 1,
newApplications: [],
};
export const mockApplicationData: OrganizationReportApplication[] = [
{
applicationName: "application1.com",
isCritical: true,
},
{
applicationName: "application2.com",
isCritical: false,
},
];
export const mockEnrichedReportData: ApplicationHealthReportDetailEnriched[] = [
{ ...mockApplication1, isMarkedAsCritical: true, ciphers: [] },
{ ...mockApplication2, isMarkedAsCritical: false, ciphers: [] },
];
export const mockCipherViews: CipherView[] = [
mock<CipherView>({
id: "cipher-1",
type: CipherType.Login,
login: { password: "pass1", username: "user1", uris: [{ uri: "https://app.com/login" }] },
isDeleted: false,
viewPassword: true,
}),
mock<CipherView>({
id: "cipher-2",
type: CipherType.Login,
login: { password: "pass2", username: "user2", uris: [{ uri: "app.com/home" }] },
isDeleted: false,
viewPassword: true,
}),
mock<CipherView>({
id: "cipher-3",
type: CipherType.Login,
login: { password: "pass3", username: "user3", uris: [{ uri: "https://other.com" }] },
isDeleted: false,
viewPassword: true,
}),
];
export const mockMemberDetails = [
mock<MemberCipherDetailsResponse>({
cipherIds: ["cipher-1"],
userGuid: "user1",
userName: "User 1",
email: "user1@app.com",
}),
mock<MemberCipherDetailsResponse>({
cipherIds: ["cipher-2"],
userGuid: "user2",
userName: "User 2",
email: "user2@app.com",
}),
mock<MemberCipherDetailsResponse>({
cipherIds: ["cipher-3"],
userGuid: "user3",
userName: "User 3",
email: "user3@other.com",
}),
];

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BadgeVariant } from "@bitwarden/components"; import { BadgeVariant } from "@bitwarden/components";
@@ -33,16 +31,6 @@ export type ExposedPasswordDetail = {
exposedXTimes: number; exposedXTimes: number;
} | null; } | null;
/*
* After data is encrypted, it is returned with the
* encryption key used to encrypt the data.
*/
export interface EncryptedDataWithKey {
organizationId: OrganizationId;
encryptedData: EncString;
contentEncryptionKey: EncString;
}
export type LEGACY_MemberDetailsFlat = { export type LEGACY_MemberDetailsFlat = {
userGuid: string; userGuid: string;
userName: string; userName: string;

View File

@@ -0,0 +1,18 @@
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
ApplicationHealthReportDetail,
OrganizationReportApplication,
OrganizationReportSummary,
} from "./report-models";
export type ApplicationHealthReportDetailEnriched = ApplicationHealthReportDetail & {
isMarkedAsCritical: boolean;
ciphers: CipherView[];
};
export interface RiskInsightsEnrichedData {
reportData: ApplicationHealthReportDetailEnriched[];
summaryData: OrganizationReportSummary;
applicationData: OrganizationReportApplication[];
creationDate: Date;
}

View File

@@ -0,0 +1,32 @@
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
ApplicationHealthReportDetail,
OrganizationReportApplication,
OrganizationReportSummary,
} from "./report-models";
/*
* After data is encrypted, it is returned with the
* encryption key used to encrypt the data.
*/
export interface EncryptedDataWithKey {
organizationId: OrganizationId;
encryptedReportData: EncString;
encryptedSummaryData: EncString;
encryptedApplicationData: EncString;
contentEncryptionKey: EncString;
}
export interface DecryptedReportData {
reportData: ApplicationHealthReportDetail[];
summaryData: OrganizationReportSummary;
applicationData: OrganizationReportApplication[];
}
export interface EncryptedReportData {
encryptedReportData: EncString;
encryptedSummaryData: EncString;
encryptedApplicationData: EncString;
}

View File

@@ -131,11 +131,6 @@ export type ApplicationHealthReportDetail = {
cipherIds: string[]; cipherIds: string[];
}; };
export type ApplicationHealthReportDetailEnriched = ApplicationHealthReportDetail & {
isMarkedAsCritical: boolean;
ciphers: CipherView[];
};
/* /*
* A list of applications and the count of * A list of applications and the count of
* at risk passwords for each application * at risk passwords for each application
@@ -148,12 +143,6 @@ export type AtRiskApplicationDetail = {
// -------------------- Password Health Report Models -------------------- // -------------------- Password Health Report Models --------------------
export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">; export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">;
// -------------------- Risk Insights Report Models --------------------
export interface RiskInsightsReportData {
data: ApplicationHealthReportDetailEnriched[];
summary: OrganizationReportSummary;
}
export type ReportScore = { label: string; badgeVariant: BadgeVariant; sortOrder: number }; export type ReportScore = { label: string; badgeVariant: BadgeVariant; sortOrder: number };
export type ReportResult = CipherView & { export type ReportResult = CipherView & {
@@ -162,8 +151,9 @@ export type ReportResult = CipherView & {
scoreKey: number; scoreKey: number;
}; };
export type ReportDetailsAndSummary = { export interface RiskInsightsData {
data: ApplicationHealthReportDetailEnriched[]; creationDate: Date;
summary: OrganizationReportSummary; reportData: ApplicationHealthReportDetail[];
dateCreated: Date; summaryData: OrganizationReportSummary;
}; applicationData: OrganizationReportApplication[];
}

View File

@@ -1,8 +1,10 @@
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher } from "../models"; import { ApplicationHealthReportDetailEnriched } from "../models";
import { OrganizationReportSummary } from "../models/report-models"; import { OrganizationReportSummary } from "../models/report-models";
import { RiskInsightsDataService } from "./risk-insights-data.service";
export class AllActivitiesService { export class AllActivitiesService {
/// This class is used to manage the summary of all applications /// This class is used to manage the summary of all applications
/// and critical applications. /// and critical applications.
@@ -20,12 +22,10 @@ export class AllActivitiesService {
totalCriticalAtRiskApplicationCount: 0, totalCriticalAtRiskApplicationCount: 0,
newApplications: [], newApplications: [],
}); });
reportSummary$ = this.reportSummarySubject$.asObservable(); reportSummary$ = this.reportSummarySubject$.asObservable();
private allApplicationsDetailsSubject$: BehaviorSubject< private allApplicationsDetailsSubject$: BehaviorSubject<ApplicationHealthReportDetailEnriched[]> =
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[] new BehaviorSubject<ApplicationHealthReportDetailEnriched[]>([]);
> = new BehaviorSubject<LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[]>([]);
allApplicationsDetails$ = this.allApplicationsDetailsSubject$.asObservable(); allApplicationsDetails$ = this.allApplicationsDetailsSubject$.asObservable();
private atRiskPasswordsCountSubject$ = new BehaviorSubject<number>(0); private atRiskPasswordsCountSubject$ = new BehaviorSubject<number>(0);
@@ -35,6 +35,22 @@ export class AllActivitiesService {
passwordChangeProgressMetricHasProgressBar$ = passwordChangeProgressMetricHasProgressBar$ =
this.passwordChangeProgressMetricHasProgressBarSubject$.asObservable(); this.passwordChangeProgressMetricHasProgressBarSubject$.asObservable();
constructor(private dataService: RiskInsightsDataService) {
// All application summary changes
this.dataService.reportResults$.subscribe((report) => {
if (report) {
this.setAllAppsReportSummary(report.summaryData);
this.setAllAppsReportDetails(report.reportData);
}
});
// Critical application summary changes
this.dataService.criticalReportResults$.subscribe((report) => {
if (report) {
this.setCriticalAppsReportSummary(report.summaryData);
}
});
}
setCriticalAppsReportSummary(summary: OrganizationReportSummary) { setCriticalAppsReportSummary(summary: OrganizationReportSummary) {
this.reportSummarySubject$.next({ this.reportSummarySubject$.next({
...this.reportSummarySubject$.getValue(), ...this.reportSummarySubject$.getValue(),
@@ -55,9 +71,7 @@ export class AllActivitiesService {
}); });
} }
setAllAppsReportDetails( setAllAppsReportDetails(applications: ApplicationHealthReportDetailEnriched[]) {
applications: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[],
) {
const totalAtRiskPasswords = applications.reduce( const totalAtRiskPasswords = applications.reduce(
(sum, app) => sum + app.atRiskPasswordCount, (sum, app) => sum + app.atRiskPasswordCount,
0, 0,

View File

@@ -82,9 +82,10 @@ describe("CriticalAppsService", () => {
}); });
it("should exclude records that already exist", async () => { it("should exclude records that already exist", async () => {
const privateCriticalAppsSubject = service["criticalAppsListSubject$"];
// arrange // arrange
// one record already exists // one record already exists
service.setAppsInListForOrg([ privateCriticalAppsSubject.next([
{ {
id: randomUUID() as PasswordHealthReportApplicationId, id: randomUUID() as PasswordHealthReportApplicationId,
organizationId: SomeOrganization, organizationId: SomeOrganization,
@@ -145,6 +146,7 @@ describe("CriticalAppsService", () => {
it("should get by org id", () => { it("should get by org id", () => {
const orgId = "some organization" as OrganizationId; const orgId = "some organization" as OrganizationId;
const privateCriticalAppsSubject = service["criticalAppsListSubject$"];
const response = [ const response = [
{ id: "id1", organizationId: "some organization", uri: "https://example.com" }, { id: "id1", organizationId: "some organization", uri: "https://example.com" },
{ id: "id2", organizationId: "some organization", uri: "https://example.org" }, { id: "id2", organizationId: "some organization", uri: "https://example.org" },
@@ -155,13 +157,14 @@ describe("CriticalAppsService", () => {
const orgKey$ = new BehaviorSubject(OrgRecords); const orgKey$ = new BehaviorSubject(OrgRecords);
keyService.orgKeys$.mockReturnValue(orgKey$); keyService.orgKeys$.mockReturnValue(orgKey$);
service.loadOrganizationContext(SomeOrganization, SomeUser); service.loadOrganizationContext(SomeOrganization, SomeUser);
service.setAppsInListForOrg(response); privateCriticalAppsSubject.next(response);
service.getAppsListForOrg(orgId as OrganizationId).subscribe((res) => { service.getAppsListForOrg(orgId as OrganizationId).subscribe((res) => {
expect(res).toHaveLength(2); expect(res).toHaveLength(2);
}); });
}); });
it("should drop a critical app", async () => { it("should drop a critical app", async () => {
const privateCriticalAppsSubject = service["criticalAppsListSubject$"];
// arrange // arrange
const selectedUrl = "https://example.com"; const selectedUrl = "https://example.com";
@@ -175,7 +178,7 @@ describe("CriticalAppsService", () => {
service.loadOrganizationContext(SomeOrganization, SomeUser); service.loadOrganizationContext(SomeOrganization, SomeUser);
service.setAppsInListForOrg(initialList); privateCriticalAppsSubject.next(initialList);
// act // act
await service.dropCriticalApp(SomeOrganization, selectedUrl); await service.dropCriticalApp(SomeOrganization, selectedUrl);
@@ -193,6 +196,7 @@ describe("CriticalAppsService", () => {
}); });
it("should not drop a critical app if it does not exist", async () => { it("should not drop a critical app if it does not exist", async () => {
const privateCriticalAppsSubject = service["criticalAppsListSubject$"];
// arrange // arrange
const selectedUrl = "https://nonexistent.com"; const selectedUrl = "https://nonexistent.com";
@@ -206,7 +210,7 @@ describe("CriticalAppsService", () => {
service.loadOrganizationContext(SomeOrganization, SomeUser); service.loadOrganizationContext(SomeOrganization, SomeUser);
service.setAppsInListForOrg(initialList); privateCriticalAppsSubject.next(initialList);
// act // act
await service.dropCriticalApp(SomeOrganization, selectedUrl); await service.dropCriticalApp(SomeOrganization, selectedUrl);

View File

@@ -83,11 +83,6 @@ export class CriticalAppsService {
.pipe(map((apps) => apps.filter((app) => app.organizationId === orgId))); .pipe(map((apps) => apps.filter((app) => app.organizationId === orgId)));
} }
// Reset the critical apps list
setAppsInListForOrg(apps: PasswordHealthReportApplicationsResponse[]) {
this.criticalAppsListSubject$.next(apps);
}
// Save the selected critical apps for a given organization // Save the selected critical apps for a given organization
async setCriticalApps(orgId: OrganizationId, selectedUrls: string[]) { async setCriticalApps(orgId: OrganizationId, selectedUrls: string[]) {
if (orgId != this.organizationId.value) { if (orgId != this.organizationId.value) {

View File

@@ -7,6 +7,7 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response"
import { makeEncString } from "@bitwarden/common/spec"; import { makeEncString } from "@bitwarden/common/spec";
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
import { EncryptedDataWithKey } from "../models";
import { import {
GetRiskInsightsApplicationDataResponse, GetRiskInsightsApplicationDataResponse,
GetRiskInsightsReportResponse, GetRiskInsightsReportResponse,
@@ -14,7 +15,7 @@ import {
SaveRiskInsightsReportRequest, SaveRiskInsightsReportRequest,
SaveRiskInsightsReportResponse, SaveRiskInsightsReportResponse,
} from "../models/api-models.types"; } from "../models/api-models.types";
import { EncryptedDataWithKey } from "../models/password-health"; import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data";
import { RiskInsightsApiService } from "./risk-insights-api.service"; import { RiskInsightsApiService } from "./risk-insights-api.service";
@@ -26,17 +27,21 @@ describe("RiskInsightsApiService", () => {
const orgId = "org1" as OrganizationId; const orgId = "org1" as OrganizationId;
const mockReportId = "report-1"; const mockReportId = "report-1";
const mockKey = "encryption-key-1"; const mockKey = "encryption-key-1";
const mockData = "encrypted-data";
const reportData = makeEncString("test").encryptedString?.toString() ?? ""; const mockReportKey = makeEncString("test-key");
const reportKey = makeEncString("test-key").encryptedString?.toString() ?? "";
const saveRiskInsightsReportRequest: SaveRiskInsightsReportRequest = { const mockReportEnc = makeEncString(JSON.stringify(mockReportData));
const mockSummaryEnc = makeEncString(JSON.stringify(mockSummaryData));
const mockApplicationsEnc = makeEncString(JSON.stringify(mockApplicationData));
const mockSaveRiskInsightsReportRequest: SaveRiskInsightsReportRequest = {
data: { data: {
organizationId: orgId, organizationId: orgId,
date: new Date().toISOString(), creationDate: new Date().toISOString(),
reportData: reportData, reportData: mockReportEnc.decryptedValue ?? "",
contentEncryptionKey: reportKey, summaryData: mockReportEnc.decryptedValue ?? "",
applicationData: mockReportEnc.decryptedValue ?? "",
contentEncryptionKey: mockReportKey.decryptedValue ?? "",
}, },
}; };
@@ -53,7 +58,9 @@ describe("RiskInsightsApiService", () => {
id: mockId, id: mockId,
organizationId: orgId, organizationId: orgId,
date: new Date().toISOString(), date: new Date().toISOString(),
reportData: mockData, reportData: mockReportEnc,
summaryData: mockSummaryEnc,
applicationData: mockApplicationsEnc,
contentEncryptionKey: mockKey, contentEncryptionKey: mockKey,
}; };
@@ -96,17 +103,17 @@ describe("RiskInsightsApiService", () => {
}); });
it("saveRiskInsightsReport$ should call apiService.send with correct parameters", async () => { it("saveRiskInsightsReport$ should call apiService.send with correct parameters", async () => {
mockApiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportRequest)); mockApiService.send.mockReturnValue(Promise.resolve(mockSaveRiskInsightsReportRequest));
const result = await firstValueFrom( const result = await firstValueFrom(
service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId), service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId),
); );
expect(result).toEqual(new SaveRiskInsightsReportResponse(saveRiskInsightsReportRequest)); expect(result).toEqual(new SaveRiskInsightsReportResponse(mockSaveRiskInsightsReportRequest));
expect(mockApiService.send).toHaveBeenCalledWith( expect(mockApiService.send).toHaveBeenCalledWith(
"POST", "POST",
`/reports/organizations/${orgId.toString()}`, `/reports/organizations/${orgId.toString()}`,
saveRiskInsightsReportRequest.data, mockSaveRiskInsightsReportRequest.data,
true, true,
true, true,
); );
@@ -117,13 +124,13 @@ describe("RiskInsightsApiService", () => {
mockApiService.send.mockReturnValue(Promise.reject(error)); mockApiService.send.mockReturnValue(Promise.reject(error));
await expect( await expect(
firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)), firstValueFrom(service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId)),
).rejects.toEqual(error); ).rejects.toEqual(error);
expect(mockApiService.send).toHaveBeenCalledWith( expect(mockApiService.send).toHaveBeenCalledWith(
"POST", "POST",
`/reports/organizations/${orgId.toString()}`, `/reports/organizations/${orgId.toString()}`,
saveRiskInsightsReportRequest.data, mockSaveRiskInsightsReportRequest.data,
true, true,
true, true,
); );
@@ -134,13 +141,13 @@ describe("RiskInsightsApiService", () => {
mockApiService.send.mockReturnValue(Promise.reject(error)); mockApiService.send.mockReturnValue(Promise.reject(error));
await expect( await expect(
firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)), firstValueFrom(service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId)),
).rejects.toEqual(error); ).rejects.toEqual(error);
expect(mockApiService.send).toHaveBeenCalledWith( expect(mockApiService.send).toHaveBeenCalledWith(
"POST", "POST",
`/reports/organizations/${orgId.toString()}`, `/reports/organizations/${orgId.toString()}`,
saveRiskInsightsReportRequest.data, mockSaveRiskInsightsReportRequest.data,
true, true,
true, true,
); );
@@ -153,7 +160,7 @@ describe("RiskInsightsApiService", () => {
{ {
reportId: mockReportId, reportId: mockReportId,
organizationId: orgId, organizationId: orgId,
encryptedData: mockData, encryptedData: mockReportData,
contentEncryptionKey: mockKey, contentEncryptionKey: mockKey,
}, },
]; ];
@@ -175,8 +182,10 @@ describe("RiskInsightsApiService", () => {
it("updateRiskInsightsSummary$ should call apiService.send with correct parameters and return an Observable", async () => { it("updateRiskInsightsSummary$ should call apiService.send with correct parameters and return an Observable", async () => {
const data: EncryptedDataWithKey = { const data: EncryptedDataWithKey = {
organizationId: orgId, organizationId: orgId,
encryptedData: new EncString(mockData),
contentEncryptionKey: new EncString(mockKey), contentEncryptionKey: new EncString(mockKey),
encryptedReportData: new EncString(JSON.stringify(mockReportData)),
encryptedSummaryData: new EncString(JSON.stringify(mockSummaryData)),
encryptedApplicationData: new EncString(JSON.stringify(mockApplicationData)),
}; };
const reportId = "report123" as OrganizationReportId; const reportId = "report123" as OrganizationReportId;
@@ -199,7 +208,9 @@ describe("RiskInsightsApiService", () => {
const reportId = "report123" as OrganizationReportId; const reportId = "report123" as OrganizationReportId;
const mockResponse: EncryptedDataWithKey | null = { const mockResponse: EncryptedDataWithKey | null = {
organizationId: orgId, organizationId: orgId,
encryptedData: new EncString(mockData), encryptedReportData: new EncString(JSON.stringify(mockReportData)),
encryptedSummaryData: new EncString(JSON.stringify(mockSummaryData)),
encryptedApplicationData: new EncString(JSON.stringify(mockApplicationData)),
contentEncryptionKey: new EncString(mockKey), contentEncryptionKey: new EncString(mockKey),
}; };
@@ -217,21 +228,17 @@ describe("RiskInsightsApiService", () => {
}); });
it("updateRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => { it("updateRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => {
const applicationData: EncryptedDataWithKey = {
organizationId: orgId,
encryptedData: new EncString(mockData),
contentEncryptionKey: new EncString(mockKey),
};
const reportId = "report123" as OrganizationReportId; const reportId = "report123" as OrganizationReportId;
const mockApplication = mockApplicationData[0];
mockApiService.send.mockResolvedValueOnce(undefined); mockApiService.send.mockResolvedValueOnce(undefined);
const result = await firstValueFrom( const result = await firstValueFrom(
service.updateRiskInsightsApplicationData$(applicationData, orgId, reportId), service.updateRiskInsightsApplicationData$(mockApplication, orgId, reportId),
); );
expect(mockApiService.send).toHaveBeenCalledWith( expect(mockApiService.send).toHaveBeenCalledWith(
"PATCH", "PATCH",
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
applicationData, mockApplication,
true, true,
true, true,
); );

View File

@@ -4,6 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
import { EncryptedDataWithKey, OrganizationReportApplication } from "../models";
import { import {
GetRiskInsightsApplicationDataResponse, GetRiskInsightsApplicationDataResponse,
GetRiskInsightsReportResponse, GetRiskInsightsReportResponse,
@@ -11,7 +12,6 @@ import {
SaveRiskInsightsReportRequest, SaveRiskInsightsReportRequest,
SaveRiskInsightsReportResponse, SaveRiskInsightsReportResponse,
} from "../models/api-models.types"; } from "../models/api-models.types";
import { EncryptedDataWithKey } from "../models/password-health";
export class RiskInsightsApiService { export class RiskInsightsApiService {
constructor(private apiService: ApiService) {} constructor(private apiService: ApiService) {}
@@ -102,7 +102,7 @@ export class RiskInsightsApiService {
} }
updateRiskInsightsApplicationData$( updateRiskInsightsApplicationData$(
applicationData: EncryptedDataWithKey, applicationData: OrganizationReportApplication,
orgId: OrganizationId, orgId: OrganizationId,
reportId: OrganizationReportId, reportId: OrganizationReportId,
): Observable<void> { ): Observable<void> {

View File

@@ -1,10 +1,12 @@
import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of } from "rxjs"; import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of, throwError } from "rxjs";
import { import {
catchError,
distinctUntilChanged, distinctUntilChanged,
exhaustMap, exhaustMap,
filter, filter,
finalize, finalize,
map, map,
shareReplay,
switchMap, switchMap,
tap, tap,
withLatestFrom, withLatestFrom,
@@ -18,19 +20,13 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { import { ApplicationHealthReportDetailEnriched } from "../models";
AppAtRiskMembersDialogParams, import { RiskInsightsEnrichedData } from "../models/report-data-service.types";
AtRiskApplicationDetail, import { DrawerType, DrawerDetails, ApplicationHealthReportDetail } from "../models/report-models";
AtRiskMemberDetail,
DrawerType,
DrawerDetails,
ApplicationHealthReportDetail,
ApplicationHealthReportDetailEnriched,
ReportDetailsAndSummary,
} from "../models/report-models";
import { CriticalAppsService } from "./critical-apps.service"; import { CriticalAppsService } from "./critical-apps.service";
import { RiskInsightsReportService } from "./risk-insights-report.service"; import { RiskInsightsReportService } from "./risk-insights-report.service";
export class RiskInsightsDataService { export class RiskInsightsDataService {
// -------------------------- Context state -------------------------- // -------------------------- Context state --------------------------
// Current user viewing risk insights // Current user viewing risk insights
@@ -45,16 +41,17 @@ export class RiskInsightsDataService {
organizationDetails$ = this.organizationDetailsSubject.asObservable(); organizationDetails$ = this.organizationDetailsSubject.asObservable();
// -------------------------- Data ------------------------------------ // -------------------------- Data ------------------------------------
private applicationsSubject = new BehaviorSubject<ApplicationHealthReportDetail[] | null>(null); // TODO: Remove. Will use report results
applications$ = this.applicationsSubject.asObservable(); private LEGACY_applicationsSubject = new BehaviorSubject<ApplicationHealthReportDetail[] | null>(
null,
);
LEGACY_applications$ = this.LEGACY_applicationsSubject.asObservable();
private dataLastUpdatedSubject = new BehaviorSubject<Date | null>(null); // TODO: Remove. Will use date from report results
dataLastUpdated$ = this.dataLastUpdatedSubject.asObservable(); private LEGACY_dataLastUpdatedSubject = new BehaviorSubject<Date | null>(null);
dataLastUpdated$ = this.LEGACY_dataLastUpdatedSubject.asObservable();
criticalApps$ = this.criticalAppsService.criticalAppsList$;
// --------------------------- UI State ------------------------------------ // --------------------------- UI State ------------------------------------
private isLoadingSubject = new BehaviorSubject<boolean>(false); private isLoadingSubject = new BehaviorSubject<boolean>(false);
isLoading$ = this.isLoadingSubject.asObservable(); isLoading$ = this.isLoadingSubject.asObservable();
@@ -78,21 +75,52 @@ export class RiskInsightsDataService {
// ------------------------- Report Variables ---------------- // ------------------------- Report Variables ----------------
// The last run report details // The last run report details
private reportResultsSubject = new BehaviorSubject<ReportDetailsAndSummary | null>(null); private reportResultsSubject = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
reportResults$ = this.reportResultsSubject.asObservable(); reportResults$ = this.reportResultsSubject.asObservable();
// Is a report being generated // Is a report being generated
private isRunningReportSubject = new BehaviorSubject<boolean>(false); private isRunningReportSubject = new BehaviorSubject<boolean>(false);
isRunningReport$ = this.isRunningReportSubject.asObservable(); isRunningReport$ = this.isRunningReportSubject.asObservable();
// The error from report generation if there was an error
// --------------------------- Critical Application data ---------------------
criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
constructor( constructor(
private accountService: AccountService, private accountService: AccountService,
private criticalAppsService: CriticalAppsService, private criticalAppsService: CriticalAppsService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private reportService: RiskInsightsReportService, 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 }),
);
}
// [FIXME] PM-25612 - Call Initialization in RiskInsightsComponent instead of child components
async initializeForOrganization(organizationId: OrganizationId) { async initializeForOrganization(organizationId: OrganizationId) {
// Fetch current user // Fetch current user
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
@@ -115,9 +143,6 @@ export class RiskInsightsDataService {
// Load critical applications for organization // Load critical applications for organization
await this.criticalAppsService.loadOrganizationContext(organizationId, userId); await this.criticalAppsService.loadOrganizationContext(organizationId, userId);
// Load existing report
this.fetchLastReport(organizationId, userId);
// Setup new report generation // Setup new report generation
this._runApplicationsReport().subscribe({ this._runApplicationsReport().subscribe({
next: (result) => { next: (result) => {
@@ -133,7 +158,7 @@ export class RiskInsightsDataService {
* Fetches the applications report and updates the applicationsSubject. * Fetches the applications report and updates the applicationsSubject.
* @param organizationId The ID of the organization. * @param organizationId The ID of the organization.
*/ */
fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void { LEGACY_fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void {
if (isRefresh) { if (isRefresh) {
this.isRefreshingSubject.next(true); this.isRefreshingSubject.next(true);
} else { } else {
@@ -145,24 +170,20 @@ export class RiskInsightsDataService {
finalize(() => { finalize(() => {
this.isLoadingSubject.next(false); this.isLoadingSubject.next(false);
this.isRefreshingSubject.next(false); this.isRefreshingSubject.next(false);
this.dataLastUpdatedSubject.next(new Date()); this.LEGACY_dataLastUpdatedSubject.next(new Date());
}), }),
) )
.subscribe({ .subscribe({
next: (reports: ApplicationHealthReportDetail[]) => { next: (reports: ApplicationHealthReportDetail[]) => {
this.applicationsSubject.next(reports); this.LEGACY_applicationsSubject.next(reports);
this.errorSubject.next(null); this.errorSubject.next(null);
}, },
error: () => { error: () => {
this.applicationsSubject.next([]); this.LEGACY_applicationsSubject.next([]);
}, },
}); });
} }
refreshApplicationsReport(organizationId: OrganizationId): void {
this.fetchApplicationsReport(organizationId, true);
}
// ------------------------------- Enrichment methods ------------------------------- // ------------------------------- Enrichment methods -------------------------------
/** /**
* Takes the basic application health report details and enriches them to include * Takes the basic application health report details and enriches them to include
@@ -174,8 +195,10 @@ export class RiskInsightsDataService {
enrichReportData$( enrichReportData$(
applications: ApplicationHealthReportDetail[], applications: ApplicationHealthReportDetail[],
): Observable<ApplicationHealthReportDetailEnriched[]> { ): Observable<ApplicationHealthReportDetailEnriched[]> {
// TODO Compare applications on report to updated critical applications
// TODO Compare applications on report to any new applications
return of(applications).pipe( return of(applications).pipe(
withLatestFrom(this.organizationDetails$, this.criticalApps$), withLatestFrom(this.organizationDetails$, this.criticalAppsService.criticalAppsList$),
switchMap(async ([apps, orgDetails, criticalApps]) => { switchMap(async ([apps, orgDetails, criticalApps]) => {
if (!orgDetails) { if (!orgDetails) {
return []; return [];
@@ -200,19 +223,11 @@ export class RiskInsightsDataService {
); );
} }
// ------------------------------- Drawer management methods -------------------------------
// ------------------------- Drawer functions ----------------------------- // ------------------------- Drawer functions -----------------------------
isActiveDrawerType$ = (drawerType: DrawerType): Observable<boolean> => {
return this.drawerDetails$.pipe(map((details) => details.activeDrawerType === drawerType));
};
isActiveDrawerType = (drawerType: DrawerType): boolean => { isActiveDrawerType = (drawerType: DrawerType): boolean => {
return this.drawerDetailsSubject.value.activeDrawerType === drawerType; return this.drawerDetailsSubject.value.activeDrawerType === drawerType;
}; };
isDrawerOpenForInvoker$ = (applicationName: string) => {
return this.drawerDetails$.pipe(map((details) => details.invokerId === applicationName));
};
isDrawerOpenForInvoker = (applicationName: string): boolean => { isDrawerOpenForInvoker = (applicationName: string): boolean => {
return this.drawerDetailsSubject.value.invokerId === applicationName; return this.drawerDetailsSubject.value.invokerId === applicationName;
}; };
@@ -228,10 +243,7 @@ export class RiskInsightsDataService {
}); });
}; };
setDrawerForOrgAtRiskMembers = ( setDrawerForOrgAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
atRiskMemberDetails: AtRiskMemberDetail[],
invokerId: string = "",
): void => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose = const shouldClose =
open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId; open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId;
@@ -239,6 +251,15 @@ export class RiskInsightsDataService {
if (shouldClose) { if (shouldClose) {
this.closeDrawer(); this.closeDrawer();
} else { } else {
const reportResults = await firstValueFrom(this.reportResults$);
if (!reportResults) {
return;
}
const atRiskMemberDetails = this.reportService.generateAtRiskMemberList(
reportResults.reportData,
);
this.drawerDetailsSubject.next({ this.drawerDetailsSubject.next({
open: true, open: true,
invokerId, invokerId,
@@ -250,10 +271,7 @@ export class RiskInsightsDataService {
} }
}; };
setDrawerForAppAtRiskMembers = ( setDrawerForAppAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
atRiskMembersDialogParams: AppAtRiskMembersDialogParams,
invokerId: string = "",
): void => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose = const shouldClose =
open && activeDrawerType === DrawerType.AppAtRiskMembers && currentInvokerId === invokerId; open && activeDrawerType === DrawerType.AppAtRiskMembers && currentInvokerId === invokerId;
@@ -261,21 +279,29 @@ export class RiskInsightsDataService {
if (shouldClose) { if (shouldClose) {
this.closeDrawer(); this.closeDrawer();
} else { } 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({ this.drawerDetailsSubject.next({
open: true, open: true,
invokerId, invokerId,
activeDrawerType: DrawerType.AppAtRiskMembers, activeDrawerType: DrawerType.AppAtRiskMembers,
atRiskMemberDetails: [], atRiskMemberDetails: [],
appAtRiskMembers: atRiskMembersDialogParams, appAtRiskMembers: atRiskMembers,
atRiskAppDetails: null, atRiskAppDetails: null,
}); });
} }
}; };
setDrawerForOrgAtRiskApps = ( setDrawerForOrgAtRiskApps = async (invokerId: string = ""): Promise<void> => {
atRiskApps: AtRiskApplicationDetail[],
invokerId: string = "",
): void => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose = const shouldClose =
open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId; open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId;
@@ -283,13 +309,21 @@ export class RiskInsightsDataService {
if (shouldClose) { if (shouldClose) {
this.closeDrawer(); this.closeDrawer();
} else { } else {
const reportResults = await firstValueFrom(this.reportResults$);
if (!reportResults) {
return;
}
const atRiskAppDetails = this.reportService.generateAtRiskApplicationList(
reportResults.reportData,
);
this.drawerDetailsSubject.next({ this.drawerDetailsSubject.next({
open: true, open: true,
invokerId, invokerId,
activeDrawerType: DrawerType.OrgAtRiskApps, activeDrawerType: DrawerType.OrgAtRiskApps,
atRiskMemberDetails: [], atRiskMemberDetails: [],
appAtRiskMembers: null, appAtRiskMembers: null,
atRiskAppDetails: atRiskApps, atRiskAppDetails,
}); });
} }
}; };
@@ -311,23 +345,31 @@ export class RiskInsightsDataService {
.getRiskInsightsReport$(organizationId, userId) .getRiskInsightsReport$(organizationId, userId)
.pipe( .pipe(
switchMap((report) => { switchMap((report) => {
return this.enrichReportData$(report.data).pipe( // Take fetched report data and merge with critical applications
return this.enrichReportData$(report.reportData).pipe(
map((enrichedReport) => ({ map((enrichedReport) => ({
data: enrichedReport, report: enrichedReport,
summary: report.summary, 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(() => { finalize(() => {
this.isLoadingSubject.next(false); this.isLoadingSubject.next(false);
}), }),
) )
.subscribe({ .subscribe({
next: ({ data, summary }) => { next: ({ report, summary, applications, creationDate }) => {
this.reportResultsSubject.next({ this.reportResultsSubject.next({
data, reportData: report,
summary, summaryData: summary,
dateCreated: new Date(), applicationData: applications,
creationDate: creationDate,
}); });
this.errorSubject.next(null); this.errorSubject.next(null);
this.isLoadingSubject.next(false); this.isLoadingSubject.next(false);
@@ -343,6 +385,7 @@ export class RiskInsightsDataService {
private _runApplicationsReport() { private _runApplicationsReport() {
return this.isRunningReport$.pipe( return this.isRunningReport$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
// Only run this report if the flag for running is true
filter((isRunning) => isRunning), filter((isRunning) => isRunning),
withLatestFrom(this.organizationDetails$, this.userId$), withLatestFrom(this.organizationDetails$, this.userId$),
exhaustMap(([_, organizationDetails, userId]) => { exhaustMap(([_, organizationDetails, userId]) => {
@@ -353,22 +396,30 @@ export class RiskInsightsDataService {
// Generate the report // Generate the report
return this.reportService.generateApplicationsReport$(organizationId).pipe( return this.reportService.generateApplicationsReport$(organizationId).pipe(
map((data) => ({ map((report) => ({
data, report,
summary: this.reportService.generateApplicationsSummary(data), summary: this.reportService.generateApplicationsSummary(report),
applications: this.reportService.generateOrganizationApplications(report),
})), })),
switchMap(({ data, summary }) => // Enrich report with critical markings
this.enrichReportData$(data).pipe( switchMap(({ report, summary, applications }) =>
map((enrichedData) => ({ data: enrichedData, summary })), this.enrichReportData$(report).pipe(
map((enrichedReport) => ({ report: enrichedReport, summary, applications })),
), ),
), ),
tap(({ data, summary }) => { // Load the updated data into the UI
this.reportResultsSubject.next({ data, summary, dateCreated: new Date() }); tap(({ report, summary, applications }) => {
this.reportResultsSubject.next({
reportData: report,
summaryData: summary,
applicationData: applications,
creationDate: new Date(),
});
this.errorSubject.next(null); this.errorSubject.next(null);
}), }),
switchMap(({ data, summary }) => { switchMap(({ report, summary, applications }) => {
// Just returns ID // Save the generated data
return this.reportService.saveRiskInsightsReport$(data, summary, { return this.reportService.saveRiskInsightsReport$(report, summary, applications, {
organizationId, organizationId,
userId, userId,
}); });
@@ -377,4 +428,42 @@ export class RiskInsightsDataService {
}), }),
); );
} }
// ------------------------------ 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

@@ -10,6 +10,9 @@ import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key"; import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
import { EncryptedReportData, DecryptedReportData } from "../models";
import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data";
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
describe("RiskInsightsEncryptionService", () => { describe("RiskInsightsEncryptionService", () => {
@@ -31,6 +34,10 @@ describe("RiskInsightsEncryptionService", () => {
}; };
const orgKey$ = new BehaviorSubject(OrgRecords); const orgKey$ = new BehaviorSubject(OrgRecords);
let mockDecryptedData: DecryptedReportData;
let mockEncryptedData: EncryptedReportData;
let mockKey: EncString;
beforeEach(() => { beforeEach(() => {
service = new RiskInsightsEncryptionService( service = new RiskInsightsEncryptionService(
mockKeyService, mockKeyService,
@@ -47,6 +54,18 @@ describe("RiskInsightsEncryptionService", () => {
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData)); mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData));
mockKeyService.orgKeys$.mockReturnValue(orgKey$); mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockKey = new EncString("wrapped-key");
mockEncryptedData = {
encryptedReportData: new EncString(JSON.stringify(mockReportData)),
encryptedSummaryData: new EncString(JSON.stringify(mockSummaryData)),
encryptedApplicationData: new EncString(JSON.stringify(mockApplicationData)),
};
mockDecryptedData = {
reportData: mockReportData,
summaryData: mockSummaryData,
applicationData: mockApplicationData,
};
}); });
describe("encryptRiskInsightsReport", () => { describe("encryptRiskInsightsReport", () => {
@@ -55,22 +74,40 @@ describe("RiskInsightsEncryptionService", () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$); mockKeyService.orgKeys$.mockReturnValue(orgKey$);
// Act: call the method under test // Act: call the method under test
const result = await service.encryptRiskInsightsReport(orgId, userId, testData); const result = await service.encryptRiskInsightsReport(
{ organizationId: orgId, userId },
mockDecryptedData,
);
// Assert: ensure that the methods were called with the expected parameters // Assert: ensure that the methods were called with the expected parameters
expect(mockKeyService.orgKeys$).toHaveBeenCalledWith(userId); expect(mockKeyService.orgKeys$).toHaveBeenCalledWith(userId);
expect(mockKeyGenerationService.createKey).toHaveBeenCalledWith(512); expect(mockKeyGenerationService.createKey).toHaveBeenCalledWith(512);
// Assert all variables were encrypted
expect(mockEncryptService.encryptString).toHaveBeenCalledWith( expect(mockEncryptService.encryptString).toHaveBeenCalledWith(
JSON.stringify(testData), JSON.stringify(mockDecryptedData.reportData),
contentEncryptionKey, contentEncryptionKey,
); );
expect(mockEncryptService.encryptString).toHaveBeenCalledWith(
JSON.stringify(mockDecryptedData.summaryData),
contentEncryptionKey,
);
expect(mockEncryptService.encryptString).toHaveBeenCalledWith(
JSON.stringify(mockDecryptedData.applicationData),
contentEncryptionKey,
);
expect(mockEncryptService.wrapSymmetricKey).toHaveBeenCalledWith( expect(mockEncryptService.wrapSymmetricKey).toHaveBeenCalledWith(
contentEncryptionKey, contentEncryptionKey,
orgKey, orgKey,
); );
// Mocked encrypt returns ENCRYPTED_TEXT
expect(result).toEqual({ expect(result).toEqual({
organizationId: orgId, organizationId: orgId,
encryptedData: new EncString(ENCRYPTED_TEXT), encryptedReportData: new EncString(ENCRYPTED_TEXT),
encryptedSummaryData: new EncString(ENCRYPTED_TEXT),
encryptedApplicationData: new EncString(ENCRYPTED_TEXT),
contentEncryptionKey: new EncString(ENCRYPTED_KEY), contentEncryptionKey: new EncString(ENCRYPTED_KEY),
}); });
}); });
@@ -82,9 +119,9 @@ describe("RiskInsightsEncryptionService", () => {
mockEncryptService.wrapSymmetricKey.mockResolvedValue(new EncString(ENCRYPTED_KEY)); mockEncryptService.wrapSymmetricKey.mockResolvedValue(new EncString(ENCRYPTED_KEY));
// Act & Assert: call the method under test and expect rejection // Act & Assert: call the method under test and expect rejection
await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow( await expect(
"Encryption failed, encrypted strings are null", service.encryptRiskInsightsReport({ organizationId: orgId, userId }, mockDecryptedData),
); ).rejects.toThrow("Encryption failed, encrypted strings are null");
}); });
it("should throw an error when encrypted key is null or empty", async () => { it("should throw an error when encrypted key is null or empty", async () => {
@@ -94,18 +131,18 @@ describe("RiskInsightsEncryptionService", () => {
mockEncryptService.wrapSymmetricKey.mockResolvedValue(new EncString("")); mockEncryptService.wrapSymmetricKey.mockResolvedValue(new EncString(""));
// Act & Assert: call the method under test and expect rejection // Act & Assert: call the method under test and expect rejection
await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow( await expect(
"Encryption failed, encrypted strings are null", service.encryptRiskInsightsReport({ organizationId: orgId, userId }, mockDecryptedData),
); ).rejects.toThrow("Encryption failed, encrypted strings are null");
}); });
it("should throw if org key is not found", async () => { it("should throw if org key is not found", async () => {
// when we cannot get an organization key, we should throw an error // when we cannot get an organization key, we should throw an error
mockKeyService.orgKeys$.mockReturnValue(new BehaviorSubject({})); mockKeyService.orgKeys$.mockReturnValue(new BehaviorSubject({}));
await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow( await expect(
"Organization key not found", service.encryptRiskInsightsReport({ organizationId: orgId, userId }, mockDecryptedData),
); ).rejects.toThrow("Organization key not found");
}); });
}); });
@@ -120,23 +157,21 @@ describe("RiskInsightsEncryptionService", () => {
// actual decryption does not happen here, // actual decryption does not happen here,
// we just want to ensure the method calls are correct // we just want to ensure the method calls are correct
const result = await service.decryptRiskInsightsReport( const result = await service.decryptRiskInsightsReport(
orgId, { organizationId: orgId, userId },
userId, mockEncryptedData,
new EncString("encrypted-data"), mockKey,
new EncString("wrapped-key"),
(data) => data as typeof testData,
); );
expect(mockKeyService.orgKeys$).toHaveBeenCalledWith(userId); expect(mockKeyService.orgKeys$).toHaveBeenCalledWith(userId);
expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith( expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith(mockKey, orgKey);
new EncString("wrapped-key"), expect(mockEncryptService.decryptString).toHaveBeenCalledTimes(3);
orgKey,
); // Mock decrypt returns JSON.stringify(testData)
expect(mockEncryptService.decryptString).toHaveBeenCalledWith( expect(result).toEqual({
new EncString("encrypted-data"), reportData: testData,
contentEncryptionKey, summaryData: testData,
); applicationData: testData,
expect(result).toEqual(testData); });
}); });
it("should invoke data type validation method during decryption", async () => { it("should invoke data type validation method during decryption", async () => {
@@ -144,77 +179,47 @@ describe("RiskInsightsEncryptionService", () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$); mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData)); mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData));
const mockParseFn = jest.fn((data) => data as typeof testData);
// act: call the decrypt method - with any params // act: call the decrypt method - with any params
// actual decryption does not happen here, // actual decryption does not happen here,
// we just want to ensure the method calls are correct // we just want to ensure the method calls are correct
const result = await service.decryptRiskInsightsReport( const result = await service.decryptRiskInsightsReport(
orgId, { organizationId: orgId, userId },
userId, mockEncryptedData,
new EncString("encrypted-data"), mockKey,
new EncString("wrapped-key"),
mockParseFn,
); );
expect(mockParseFn).toHaveBeenCalledWith(JSON.parse(JSON.stringify(testData))); expect(result).toEqual({
expect(result).toEqual(testData); reportData: testData,
summaryData: testData,
applicationData: testData,
});
}); });
it("should return null if org key is not found", async () => { it("should return null if org key is not found", async () => {
mockKeyService.orgKeys$.mockReturnValue(new BehaviorSubject({})); mockKeyService.orgKeys$.mockReturnValue(new BehaviorSubject({}));
await expect(
service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
const result = await service.decryptRiskInsightsReport( mockEncryptedData,
orgId, mockKey,
userId, ),
new EncString("encrypted-data"), ).rejects.toEqual(Error("Organization key not found"));
new EncString("wrapped-key"),
(data) => data as typeof testData,
);
expect(result).toBeNull();
}); });
it("should return null if decrypt throws", async () => { it("should return null if decrypt throws", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$); mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail")); mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail"));
const result = await service.decryptRiskInsightsReport( await expect(
orgId, service.decryptRiskInsightsReport(
userId, { organizationId: orgId, userId },
new EncString("encrypted-data"),
new EncString("wrapped-key"),
(data) => data as typeof testData,
);
expect(result).toBeNull();
});
it("should return null if decrypt throws", async () => { mockEncryptedData,
mockKeyService.orgKeys$.mockReturnValue(orgKey$); mockKey,
mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail")); ),
).rejects.toEqual(Error("fail"));
const result = await service.decryptRiskInsightsReport(
orgId,
userId,
new EncString("encrypted-data"),
new EncString("wrapped-key"),
(data) => data as typeof testData,
);
expect(result).toBeNull();
});
it("should return null if decrypt throws", async () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail"));
const result = await service.decryptRiskInsightsReport(
orgId,
userId,
new EncString("encrypted-data"),
new EncString("wrapped-key"),
(data) => data as typeof testData,
);
expect(result).toBeNull();
}); });
}); });
}); });

View File

@@ -1,13 +1,13 @@
import { firstValueFrom, map } from "rxjs"; import { firstValueFrom, map } from "rxjs";
import { Jsonify } from "type-fest";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
import { EncryptedDataWithKey } from "../models/password-health"; import { DecryptedReportData, EncryptedReportData, EncryptedDataWithKey } from "../models";
export class RiskInsightsEncryptionService { export class RiskInsightsEncryptionService {
constructor( constructor(
@@ -16,11 +16,15 @@ export class RiskInsightsEncryptionService {
private keyGeneratorService: KeyGenerationService, private keyGeneratorService: KeyGenerationService,
) {} ) {}
async encryptRiskInsightsReport<T>( async encryptRiskInsightsReport(
organizationId: OrganizationId, context: {
userId: UserId, organizationId: OrganizationId;
data: T, userId: UserId;
},
data: DecryptedReportData,
wrappedKey?: EncString,
): Promise<EncryptedDataWithKey> { ): Promise<EncryptedDataWithKey> {
const { userId, organizationId } = context;
const orgKey = await firstValueFrom( const orgKey = await firstValueFrom(
this.keyService this.keyService
.orgKeys$(userId) .orgKeys$(userId)
@@ -35,10 +39,28 @@ export class RiskInsightsEncryptionService {
throw new Error("Organization key not found"); throw new Error("Organization key not found");
} }
const contentEncryptionKey = await this.keyGeneratorService.createKey(512); 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);
}
const dataEncrypted = await this.encryptService.encryptString( const { reportData, summaryData, applicationData } = data;
JSON.stringify(data),
// Encrypt the data
const encryptedReportData = await this.encryptService.encryptString(
JSON.stringify(reportData),
contentEncryptionKey,
);
const encryptedSummaryData = await this.encryptService.encryptString(
JSON.stringify(summaryData),
contentEncryptionKey,
);
const encryptedApplicationData = await this.encryptService.encryptString(
JSON.stringify(applicationData),
contentEncryptionKey, contentEncryptionKey,
); );
@@ -47,59 +69,87 @@ export class RiskInsightsEncryptionService {
orgKey, orgKey,
); );
if (!dataEncrypted.encryptedString || !wrappedEncryptionKey.encryptedString) { if (
!encryptedReportData.encryptedString ||
!encryptedSummaryData.encryptedString ||
!encryptedApplicationData.encryptedString ||
!wrappedEncryptionKey.encryptedString
) {
throw new Error("Encryption failed, encrypted strings are null"); throw new Error("Encryption failed, encrypted strings are null");
} }
const encryptedData = dataEncrypted;
const contentEncryptionKeyString = wrappedEncryptionKey;
const encryptedDataPacket: EncryptedDataWithKey = { const encryptedDataPacket: EncryptedDataWithKey = {
organizationId, organizationId,
encryptedData, encryptedReportData: encryptedReportData,
contentEncryptionKey: contentEncryptionKeyString, encryptedSummaryData: encryptedSummaryData,
encryptedApplicationData: encryptedApplicationData,
contentEncryptionKey: wrappedEncryptionKey,
}; };
return encryptedDataPacket; return encryptedDataPacket;
} }
async decryptRiskInsightsReport<T>( async decryptRiskInsightsReport(
organizationId: OrganizationId, context: {
userId: UserId, organizationId: OrganizationId;
encryptedData: EncString, userId: UserId;
},
encryptedData: EncryptedReportData,
wrappedKey: EncString, wrappedKey: EncString,
parser: (data: Jsonify<T>) => T, ): Promise<DecryptedReportData> {
): Promise<T | null> { const { userId, organizationId } = context;
try { const orgKey = await firstValueFrom(
const orgKey = await firstValueFrom( this.keyService
this.keyService .orgKeys$(userId)
.orgKeys$(userId) .pipe(
.pipe( map((organizationKeysById) =>
map((organizationKeysById) => organizationKeysById ? organizationKeysById[organizationId] : null,
organizationKeysById ? organizationKeysById[organizationId] : null,
),
), ),
); ),
);
if (!orgKey) { if (!orgKey) {
throw new Error("Organization key not found"); throw new Error("Organization key not found");
}
const unwrappedEncryptionKey = await this.encryptService.unwrapSymmetricKey(
wrappedKey,
orgKey,
);
const dataUnencrypted = await this.encryptService.decryptString(
encryptedData,
unwrappedEncryptionKey,
);
const dataUnencryptedJson = parser(JSON.parse(dataUnencrypted));
return dataUnencryptedJson as T;
} catch {
return null;
} }
const unwrappedEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey);
if (!unwrappedEncryptionKey) {
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(
encryptedReportData,
unwrappedEncryptionKey,
);
const decryptedSummaryData = await this.encryptService.decryptString(
encryptedSummaryData,
unwrappedEncryptionKey,
);
const decryptedApplicationData = await this.encryptService.decryptString(
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,
};
return decryptedFullReport;
} }
} }

View File

@@ -1,25 +1,23 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs"; import { firstValueFrom, of } from "rxjs";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { makeEncString } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { createNewSummaryData } from "../helpers"; import { DecryptedReportData, EncryptedDataWithKey } from "../models";
import { import {
GetRiskInsightsReportResponse, GetRiskInsightsReportResponse,
SaveRiskInsightsReportResponse, SaveRiskInsightsReportResponse,
} from "../models/api-models.types"; } from "../models/api-models.types";
import { EncryptedDataWithKey } from "../models/password-health";
import { import {
ApplicationHealthReportDetail, mockApplicationData,
OrganizationReportSummary, mockCipherViews,
RiskInsightsReportData, mockMemberDetails,
} from "../models/report-models"; mockReportData,
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; mockSummaryData,
} from "../models/mock-data";
import { mockCiphers } from "./ciphers.mock"; import { mockCiphers } from "./ciphers.mock";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
@@ -45,17 +43,13 @@ describe("RiskInsightsReportService", () => {
// Non changing mock data // Non changing mock data
const mockOrganizationId = "orgId" as OrganizationId; const mockOrganizationId = "orgId" as OrganizationId;
const mockUserId = "userId" as UserId; const mockUserId = "userId" as UserId;
const ENCRYPTED_TEXT = "This data has been encrypted"; const mockEncryptedKey = makeEncString("test-key");
const ENCRYPTED_KEY = "Re-encrypted Cipher Key";
const mockEncryptedText = new EncString(ENCRYPTED_TEXT);
const mockEncryptedKey = new EncString(ENCRYPTED_KEY);
// Changing mock data // Changing mock data
let mockCipherViews: CipherView[]; let mockDecryptedData: DecryptedReportData;
let mockMemberDetails: MemberCipherDetailsResponse[]; const mockReportEnc = makeEncString(JSON.stringify(mockReportData));
let mockReport: ApplicationHealthReportDetail[]; const mockSummaryEnc = makeEncString(JSON.stringify(mockSummaryData));
let mockSummary: OrganizationReportSummary; const mockApplicationsEnc = makeEncString(JSON.stringify(mockApplicationData));
let mockEncryptedReport: EncryptedDataWithKey;
beforeEach(() => { beforeEach(() => {
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers); cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers);
@@ -87,75 +81,15 @@ describe("RiskInsightsReportService", () => {
service = new RiskInsightsReportService( service = new RiskInsightsReportService(
cipherService, cipherService,
memberCipherDetailsService, memberCipherDetailsService,
mockPasswordHealthService,
mockRiskInsightsApiService, mockRiskInsightsApiService,
mockRiskInsightsEncryptionService, mockRiskInsightsEncryptionService,
mockPasswordHealthService,
); );
// Reset mock ciphers before each test mockDecryptedData = {
mockCipherViews = [ reportData: mockReportData,
mock<CipherView>({ summaryData: mockSummaryData,
id: "cipher-1", applicationData: mockApplicationData,
type: CipherType.Login,
login: { password: "pass1", username: "user1", uris: [{ uri: "https://app.com/login" }] },
isDeleted: false,
viewPassword: true,
}),
mock<CipherView>({
id: "cipher-2",
type: CipherType.Login,
login: { password: "pass2", username: "user2", uris: [{ uri: "app.com/home" }] },
isDeleted: false,
viewPassword: true,
}),
mock<CipherView>({
id: "cipher-3",
type: CipherType.Login,
login: { password: "pass3", username: "user3", uris: [{ uri: "https://other.com" }] },
isDeleted: false,
viewPassword: true,
}),
];
mockMemberDetails = [
mock<MemberCipherDetailsResponse>({
cipherIds: ["cipher-1"],
userGuid: "user1",
userName: "User 1",
email: "user1@app.com",
}),
mock<MemberCipherDetailsResponse>({
cipherIds: ["cipher-2"],
userGuid: "user2",
userName: "User 2",
email: "user2@app.com",
}),
mock<MemberCipherDetailsResponse>({
cipherIds: ["cipher-3"],
userGuid: "user3",
userName: "User 3",
email: "user3@other.com",
}),
];
mockReport = [
{
applicationName: "app1",
passwordCount: 0,
atRiskPasswordCount: 0,
atRiskCipherIds: [],
memberCount: 0,
atRiskMemberCount: 0,
memberDetails: [],
atRiskMemberDetails: [],
cipherIds: [],
},
];
mockSummary = createNewSummaryData();
mockEncryptedReport = {
organizationId: mockOrganizationId,
encryptedData: mockEncryptedText,
contentEncryptionKey: mockEncryptedKey,
}; };
}); });
@@ -284,15 +218,22 @@ describe("RiskInsightsReportService", () => {
describe("saveRiskInsightsReport$", () => { describe("saveRiskInsightsReport$", () => {
it("should not update subjects if save response does not have id", (done) => { it("should not update subjects if save response does not have id", (done) => {
const mockEncryptedOutput: EncryptedDataWithKey = {
organizationId: mockOrganizationId,
encryptedReportData: mockReportEnc,
encryptedSummaryData: mockSummaryEnc,
encryptedApplicationData: mockApplicationsEnc,
contentEncryptionKey: mockEncryptedKey,
};
mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue( mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue(
mockEncryptedReport, mockEncryptedOutput,
); );
const saveResponse = new SaveRiskInsightsReportResponse({ id: "" }); // Simulating no ID in response const saveResponse = new SaveRiskInsightsReportResponse({ id: "" }); // Simulating no ID in response
mockRiskInsightsApiService.saveRiskInsightsReport$.mockReturnValue(of(saveResponse)); mockRiskInsightsApiService.saveRiskInsightsReport$.mockReturnValue(of(saveResponse));
service service
.saveRiskInsightsReport$(mockReport, mockSummary, { .saveRiskInsightsReport$(mockReportData, mockSummaryData, mockApplicationData, {
organizationId: mockOrganizationId, organizationId: mockOrganizationId,
userId: mockUserId, userId: mockUserId,
}) })
@@ -321,17 +262,19 @@ describe("RiskInsightsReportService", () => {
it("should call with the correct organizationId", async () => { it("should call with the correct organizationId", async () => {
// we need to ensure that the api is invoked with the specified organizationId // we need to ensure that the api is invoked with the specified organizationId
// here it doesn't matter what the Api returns // here it doesn't matter what the Api returns
const apiResponse = { const apiResponse = new GetRiskInsightsReportResponse({
id: "reportId", id: "reportId",
date: new Date().toISOString(), date: new Date(),
organizationId: mockOrganizationId, organizationId: mockOrganizationId,
reportData: mockEncryptedReport.encryptedData, reportData: mockReportEnc.encryptedString,
contentEncryptionKey: mockEncryptedReport.contentEncryptionKey, summaryData: mockSummaryEnc.encryptedString,
} as GetRiskInsightsReportResponse; applicationData: mockApplicationsEnc.encryptedString,
contentEncryptionKey: mockEncryptedKey.encryptedString,
});
const decryptedResponse: RiskInsightsReportData = { const decryptedResponse: DecryptedReportData = {
data: [], reportData: [],
summary: { summaryData: {
totalMemberCount: 1, totalMemberCount: 1,
totalAtRiskMemberCount: 1, totalAtRiskMemberCount: 1,
totalApplicationCount: 1, totalApplicationCount: 1,
@@ -342,9 +285,9 @@ describe("RiskInsightsReportService", () => {
totalCriticalAtRiskApplicationCount: 1, totalCriticalAtRiskApplicationCount: 1,
newApplications: [], newApplications: [],
}, },
applicationData: [],
}; };
const organizationId = "orgId" as OrganizationId;
const userId = "userId" as UserId; const userId = "userId" as UserId;
// Mock api returned encrypted data // Mock api returned encrypted data
@@ -355,17 +298,15 @@ describe("RiskInsightsReportService", () => {
Promise.resolve(decryptedResponse), Promise.resolve(decryptedResponse),
); );
await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId)); await firstValueFrom(service.getRiskInsightsReport$(mockOrganizationId, userId));
expect(mockRiskInsightsApiService.getRiskInsightsReport$).toHaveBeenCalledWith( expect(mockRiskInsightsApiService.getRiskInsightsReport$).toHaveBeenCalledWith(
organizationId, mockOrganizationId,
); );
expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith( expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith(
organizationId, { organizationId: mockOrganizationId, userId },
userId, expect.anything(),
expect.anything(), // encryptedData expect.anything(),
expect.anything(), // wrappedKey
expect.any(Function), // parser
); );
}); });
@@ -375,32 +316,29 @@ describe("RiskInsightsReportService", () => {
const organizationId = "orgId" as OrganizationId; const organizationId = "orgId" as OrganizationId;
const userId = "userId" as UserId; const userId = "userId" as UserId;
const mockResponse = { const mockResponse = new GetRiskInsightsReportResponse({
id: "reportId", id: "reportId",
date: new Date().toISOString(), creationDate: new Date(),
organizationId: organizationId as OrganizationId, organizationId: organizationId as OrganizationId,
reportData: mockEncryptedReport.encryptedData, reportData: mockReportEnc.encryptedString,
contentEncryptionKey: mockEncryptedReport.contentEncryptionKey, summaryData: mockSummaryEnc.encryptedString,
} as GetRiskInsightsReportResponse; applicationData: mockApplicationsEnc.encryptedString,
contentEncryptionKey: mockEncryptedKey.encryptedString,
});
const decryptedReport = {
data: [{ foo: "bar" }],
};
mockRiskInsightsApiService.getRiskInsightsReport$.mockReturnValue(of(mockResponse)); mockRiskInsightsApiService.getRiskInsightsReport$.mockReturnValue(of(mockResponse));
mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockResolvedValue( mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockResolvedValue(
decryptedReport, mockDecryptedData,
); );
const result = await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId)); const result = await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId));
expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith( expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith(
organizationId, { organizationId: mockOrganizationId, userId },
userId,
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
expect.any(Function),
); );
expect(result).toEqual(decryptedReport); expect(result).toEqual({ ...mockDecryptedData, creationDate: mockResponse.creationDate });
}); });
}); });
}); });

View File

@@ -1,6 +1,7 @@
import { import {
BehaviorSubject, catchError,
concatMap, concatMap,
EMPTY,
first, first,
firstValueFrom, firstValueFrom,
forkJoin, forkJoin,
@@ -19,7 +20,6 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { import {
createNewReportData, createNewReportData,
createNewSummaryData,
flattenMemberDetails, flattenMemberDetails,
getApplicationReportDetail, getApplicationReportDetail,
getFlattenedCipherDetails, getFlattenedCipherDetails,
@@ -45,7 +45,8 @@ import {
CipherHealthReport, CipherHealthReport,
MemberDetails, MemberDetails,
PasswordHealthData, PasswordHealthData,
RiskInsightsReportData, OrganizationReportApplication,
RiskInsightsData,
} from "../models/report-models"; } from "../models/report-models";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
@@ -54,14 +55,6 @@ import { RiskInsightsApiService } from "./risk-insights-api.service";
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
export class RiskInsightsReportService { export class RiskInsightsReportService {
private riskInsightsReportSubject = new BehaviorSubject<ApplicationHealthReportDetail[]>([]);
riskInsightsReport$ = this.riskInsightsReportSubject.asObservable();
private riskInsightsSummarySubject = new BehaviorSubject<OrganizationReportSummary>(
createNewSummaryData(),
);
riskInsightsSummary$ = this.riskInsightsSummarySubject.asObservable();
// [FIXME] CipherData // [FIXME] CipherData
// Cipher data // Cipher data
// private _ciphersSubject = new BehaviorSubject<CipherView[] | null>(null); // private _ciphersSubject = new BehaviorSubject<CipherView[] | null>(null);
@@ -70,9 +63,9 @@ export class RiskInsightsReportService {
constructor( constructor(
private cipherService: CipherService, private cipherService: CipherService,
private memberCipherDetailsApiService: MemberCipherDetailsApiService, private memberCipherDetailsApiService: MemberCipherDetailsApiService,
private passwordHealthService: PasswordHealthService,
private riskInsightsApiService: RiskInsightsApiService, private riskInsightsApiService: RiskInsightsApiService,
private riskInsightsEncryptionService: RiskInsightsEncryptionService, private riskInsightsEncryptionService: RiskInsightsEncryptionService,
private passwordHealthService: PasswordHealthService,
) {} ) {}
// [FIXME] CipherData // [FIXME] CipherData
@@ -152,6 +145,7 @@ export class RiskInsightsReportService {
/** /**
* Report data for the aggregation of uris to like uris and getting password/member counts, * Report data for the aggregation of uris to like uris and getting password/member counts,
* members, and at risk statuses. * members, and at risk statuses.
*
* @param organizationId Id of the organization * @param organizationId Id of the organization
* @returns The all applications health report data * @returns The all applications health report data
*/ */
@@ -235,17 +229,32 @@ export class RiskInsightsReportService {
// TODO: totalCriticalMemberCount, totalCriticalAtRiskMemberCount, totalCriticalApplicationCount, totalCriticalAtRiskApplicationCount, and newApplications will be handled with future logic implementation // TODO: totalCriticalMemberCount, totalCriticalAtRiskMemberCount, totalCriticalApplicationCount, totalCriticalAtRiskApplicationCount, and newApplications will be handled with future logic implementation
return { return {
totalMemberCount: uniqueMembers.length, totalMemberCount: uniqueMembers.length,
totalCriticalMemberCount: 0,
totalAtRiskMemberCount: uniqueAtRiskMembers.length, totalAtRiskMemberCount: uniqueAtRiskMembers.length,
totalCriticalAtRiskMemberCount: 0,
totalApplicationCount: reports.length, totalApplicationCount: reports.length,
totalCriticalApplicationCount: 0,
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length, totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
totalCriticalMemberCount: 0,
totalCriticalAtRiskMemberCount: 0,
totalCriticalApplicationCount: 0,
totalCriticalAtRiskApplicationCount: 0, totalCriticalAtRiskApplicationCount: 0,
newApplications: [], newApplications: [],
}; };
} }
/**
* 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( async identifyCiphers(
data: ApplicationHealthReportDetail[], data: ApplicationHealthReportDetail[],
organizationId: OrganizationId, organizationId: OrganizationId,
@@ -272,12 +281,12 @@ export class RiskInsightsReportService {
getRiskInsightsReport$( getRiskInsightsReport$(
organizationId: OrganizationId, organizationId: OrganizationId,
userId: UserId, userId: UserId,
): Observable<RiskInsightsReportData> { ): Observable<RiskInsightsData> {
return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe( return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe(
switchMap((response): Observable<RiskInsightsReportData> => { switchMap((response) => {
if (!response) { if (!response) {
// Return an empty report and summary if response is falsy // Return an empty report and summary if response is falsy
return of<RiskInsightsReportData>(createNewReportData()); return of<RiskInsightsData>(createNewReportData());
} }
if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") { if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") {
return throwError(() => new Error("Report key not found")); return throwError(() => new Error("Report key not found"));
@@ -285,15 +294,43 @@ export class RiskInsightsReportService {
if (!response.reportData) { if (!response.reportData) {
return throwError(() => new Error("Report data not found")); 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( return from(
this.riskInsightsEncryptionService.decryptRiskInsightsReport<RiskInsightsReportData>( this.riskInsightsEncryptionService.decryptRiskInsightsReport(
organizationId, {
userId, organizationId,
response.reportData, userId,
},
{
encryptedReportData: response.reportData,
encryptedSummaryData: response.summaryData,
encryptedApplicationData: response.applicationData,
},
response.contentEncryptionKey, response.contentEncryptionKey,
(data) => data as RiskInsightsReportData,
), ),
).pipe(map((decryptedReport) => decryptedReport ?? createNewReportData())); ).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;
}), }),
); );
} }
@@ -308,6 +345,7 @@ export class RiskInsightsReportService {
saveRiskInsightsReport$( saveRiskInsightsReport$(
report: ApplicationHealthReportDetail[], report: ApplicationHealthReportDetail[],
summary: OrganizationReportSummary, summary: OrganizationReportSummary,
applications: OrganizationReportApplication[],
encryptionParameters: { encryptionParameters: {
organizationId: OrganizationId; organizationId: OrganizationId;
userId: UserId; userId: UserId;
@@ -315,28 +353,43 @@ export class RiskInsightsReportService {
): Observable<SaveRiskInsightsReportResponse> { ): Observable<SaveRiskInsightsReportResponse> {
return from( return from(
this.riskInsightsEncryptionService.encryptRiskInsightsReport( this.riskInsightsEncryptionService.encryptRiskInsightsReport(
encryptionParameters.organizationId,
encryptionParameters.userId,
{ {
data: report, organizationId: encryptionParameters.organizationId,
summary: summary, userId: encryptionParameters.userId,
},
{
reportData: report,
summaryData: summary,
applicationData: applications,
}, },
), ),
).pipe( ).pipe(
map(({ encryptedData, contentEncryptionKey }) => ({ map(
data: { ({
organizationId: encryptionParameters.organizationId, encryptedReportData,
date: new Date().toISOString(), encryptedSummaryData,
reportData: encryptedData.toSdk(), encryptedApplicationData,
contentEncryptionKey: contentEncryptionKey.toSdk(), contentEncryptionKey,
}, }) => ({
})), data: {
organizationId: encryptionParameters.organizationId,
creationDate: new Date().toISOString(),
reportData: encryptedReportData.toSdk(),
summaryData: encryptedSummaryData.toSdk(),
applicationData: encryptedApplicationData.toSdk(),
contentEncryptionKey: contentEncryptionKey.toSdk(),
},
}),
),
switchMap((encryptedReport) => switchMap((encryptedReport) =>
this.riskInsightsApiService.saveRiskInsightsReport$( this.riskInsightsApiService.saveRiskInsightsReport$(
encryptedReport, encryptedReport,
encryptionParameters.organizationId, encryptionParameters.organizationId,
), ),
), ),
catchError((error: unknown) => {
return EMPTY;
}),
map((response) => { map((response) => {
if (!isSaveRiskInsightsReportResponse(response)) { if (!isSaveRiskInsightsReportResponse(response)) {
throw new Error("Invalid response from API"); throw new Error("Invalid response from API");
@@ -457,6 +510,13 @@ export class RiskInsightsReportService {
const applicationMap = new Map<string, CipherHealthReport[]>(); const applicationMap = new Map<string, CipherHealthReport[]>();
cipherHealthData.forEach((cipher: 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) => { cipher.applications.forEach((application) => {
const existingApplication = applicationMap.get(application) || []; const existingApplication = applicationMap.get(application) || [];
existingApplication.push(cipher); existingApplication.push(cipher);

View File

@@ -29,28 +29,32 @@ import { RiskInsightsComponent } from "./risk-insights.component";
@NgModule({ @NgModule({
imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule], imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule],
providers: [ providers: [
{ safeProvider({
provide: MemberCipherDetailsApiService, provide: MemberCipherDetailsApiService,
useClass: MemberCipherDetailsApiService,
deps: [ApiService], deps: [ApiService],
}, }),
{ safeProvider({
provide: PasswordHealthService, provide: PasswordHealthService,
useClass: PasswordHealthService,
deps: [PasswordStrengthServiceAbstraction, AuditService], deps: [PasswordStrengthServiceAbstraction, AuditService],
}, }),
{ safeProvider({
provide: RiskInsightsApiService, provide: RiskInsightsApiService,
useClass: RiskInsightsApiService,
deps: [ApiService], deps: [ApiService],
}, }),
{ safeProvider({
provide: RiskInsightsReportService, provide: RiskInsightsReportService,
useClass: RiskInsightsReportService,
deps: [ deps: [
CipherService, CipherService,
MemberCipherDetailsApiService, MemberCipherDetailsApiService,
PasswordHealthService,
RiskInsightsApiService, RiskInsightsApiService,
RiskInsightsEncryptionService, RiskInsightsEncryptionService,
PasswordHealthService,
], ],
}, }),
safeProvider({ safeProvider({
provide: RiskInsightsDataService, provide: RiskInsightsDataService,
deps: [ deps: [
@@ -78,7 +82,7 @@ import { RiskInsightsComponent } from "./risk-insights.component";
safeProvider({ safeProvider({
provide: AllActivitiesService, provide: AllActivitiesService,
useClass: AllActivitiesService, useClass: AllActivitiesService,
deps: [], deps: [RiskInsightsDataService],
}), }),
safeProvider({ safeProvider({
provide: SecurityTasksApiService, provide: SecurityTasksApiService,

View File

@@ -1,10 +1,6 @@
@if (isLoading$ | async) { @if (dataService.isLoading$ | async) {
<div *ngIf="isLoading$ | async"> <dirt-risk-insights-loading></dirt-risk-insights-loading>
<tools-risk-insights-loading></tools-risk-insights-loading> } @else {
</div>
}
@if (!(isLoading$ | async)) {
<ul <ul
class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none" class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none"
> >

View File

@@ -20,7 +20,7 @@ import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"
import { RiskInsightsTabType } from "./risk-insights.component"; import { RiskInsightsTabType } from "./risk-insights.component";
@Component({ @Component({
selector: "tools-all-activity", selector: "dirt-all-activity",
imports: [ imports: [
ApplicationsLoadingComponent, ApplicationsLoadingComponent,
SharedModule, SharedModule,
@@ -30,7 +30,6 @@ import { RiskInsightsTabType } from "./risk-insights.component";
templateUrl: "./all-activity.component.html", templateUrl: "./all-activity.component.html",
}) })
export class AllActivityComponent implements OnInit { export class AllActivityComponent implements OnInit {
protected isLoading$ = this.dataService.isLoading$;
organization: Organization | null = null; organization: Organization | null = null;
totalCriticalAppsAtRiskMemberCount = 0; totalCriticalAppsAtRiskMemberCount = 0;
totalCriticalAppsCount = 0; totalCriticalAppsCount = 0;

View File

@@ -1,96 +1,102 @@
<div *ngIf="isLoading$ | async"> @if (dataService.isLoading$ | async) {
<tools-risk-insights-loading></tools-risk-insights-loading> <dirt-risk-insights-loading></dirt-risk-insights-loading>
</div> } @else {
<div class="tw-mt-4" *ngIf="!(isLoading$ | async) && !dataSource.data.length"> @let drawerDetails = dataService.drawerDetails$ | async;
<bit-no-items [icon]="noItemsIcon" class="tw-text-main"> @if (!dataSource.data.length) {
<ng-container slot="title"> <div class="tw-mt-4">
<h2 class="tw-font-semibold tw-mt-4"> <bit-no-items [icon]="noItemsIcon" class="tw-text-main">
{{ "noAppsInOrgTitle" | i18n: organization?.name }} <ng-container slot="title">
</h2> <h2 class="tw-font-semibold tw-mt-4">
</ng-container> {{
<ng-container slot="description"> "noAppsInOrgTitle"
<div class="tw-flex tw-flex-col tw-mb-2"> | i18n: (dataService.organizationDetails$ | async)?.organizationName || ""
<span class="tw-text-muted"> }}
{{ "noAppsInOrgDescription" | i18n }} </h2>
</span> </ng-container>
<a class="tw-text-primary-600" routerLink="/login">{{ "learnMore" | i18n }}</a> <ng-container slot="description">
<div class="tw-flex tw-flex-col tw-mb-2">
<span class="tw-text-muted">
{{ "noAppsInOrgDescription" | i18n }}
</span>
<a class="tw-text-primary-600" routerLink="/login">{{ "learnMore" | i18n }}</a>
</div>
</ng-container>
<ng-container slot="button">
<button (click)="goToCreateNewLoginItem()" bitButton buttonType="primary" type="button">
{{ "createNewLoginItem" | i18n }}
</button>
</ng-container>
</bit-no-items>
</div>
} @else {
<div class="tw-mt-4 tw-flex tw-flex-col">
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
<div class="tw-flex tw-gap-6">
<button
type="button"
class="tw-flex-1"
tabindex="0"
(click)="dataService.setDrawerForOrgAtRiskMembers('allAppsOrgAtRiskMembers')"
>
<dirt-card
#allAppsOrgAtRiskMembers
class="tw-w-full"
[ngClass]="{
'tw-bg-primary-100': drawerDetails.invokerId === 'allAppsOrgAtRiskMembers',
}"
[title]="'atRiskMembers' | i18n"
[value]="applicationSummary.totalAtRiskMemberCount"
[maxValue]="applicationSummary.totalMemberCount"
>
</dirt-card>
</button>
<button
type="button"
class="tw-flex-1"
tabindex="0"
(click)="dataService.setDrawerForOrgAtRiskApps('allAppsOrgAtRiskApplications')"
>
<dirt-card
#allAppsOrgAtRiskApplications
class="tw-w-full"
[ngClass]="{
'tw-bg-primary-100': drawerDetails.invokerId === 'allAppsOrgAtRiskApplications',
}"
[title]="'atRiskApplications' | i18n"
[value]="applicationSummary.totalAtRiskApplicationCount"
[maxValue]="applicationSummary.totalApplicationCount"
>
</dirt-card>
</button>
</div> </div>
</ng-container> <div class="tw-flex tw-mt-8 tw-mb-4 tw-gap-4">
<ng-container slot="button"> <bit-search
<button (click)="goToCreateNewLoginItem()" bitButton buttonType="primary" type="button"> [placeholder]="'searchApps' | i18n"
{{ "createNewLoginItem" | i18n }} class="tw-grow"
</button> [formControl]="searchControl"
</ng-container> ></bit-search>
</bit-no-items> <button
</div> type="button"
<div class="tw-mt-4 tw-flex tw-flex-col" *ngIf="!(isLoading$ | async) && dataSource.data.length"> [buttonType]="'primary'"
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2> bitButton
@if (dataService.drawerDetails$ | async; as drawerDetails) { [disabled]="!selectedUrls.size"
<div class="tw-flex tw-gap-6"> [loading]="markingAsCritical"
<button (click)="markAppsAsCritical()"
type="button"
class="tw-flex-1"
tabindex="0"
(click)="showOrgAtRiskMembers('allAppsOrgAtRiskMembers')"
>
<dirt-card
#allAppsOrgAtRiskMembers
class="tw-w-full"
[ngClass]="{
'tw-bg-primary-100': drawerDetails.invokerId === 'allAppsOrgAtRiskMembers',
}"
[title]="'atRiskMembers' | i18n"
[value]="applicationSummary.totalAtRiskMemberCount"
[maxValue]="applicationSummary.totalMemberCount"
> >
</dirt-card> <i class="bwi tw-mr-2" [ngClass]="selectedUrls.size ? 'bwi-star-f' : 'bwi-star'"></i>
</button> {{ "markAppAsCritical" | i18n }}
<button </button>
type="button" </div>
class="tw-flex-1"
tabindex="0"
(click)="showOrgAtRiskApps('allAppsOrgAtRiskApplications')"
>
<dirt-card
#allAppsOrgAtRiskApplications
class="tw-w-full"
[ngClass]="{
'tw-bg-primary-100': drawerDetails.invokerId === 'allAppsOrgAtRiskApplications',
}"
[title]="'atRiskApplications' | i18n"
[value]="applicationSummary.totalAtRiskApplicationCount"
[maxValue]="applicationSummary.totalApplicationCount"
>
</dirt-card>
</button>
</div>
<div class="tw-flex tw-mt-8 tw-mb-4 tw-gap-4">
<bit-search
[placeholder]="'searchApps' | i18n"
class="tw-grow"
[formControl]="searchControl"
></bit-search>
<button
type="button"
[buttonType]="'primary'"
bitButton
[disabled]="!selectedUrls.size"
[loading]="markingAsCritical"
(click)="markAppsAsCritical()"
>
<i class="bwi tw-mr-2" [ngClass]="selectedUrls.size ? 'bwi-star-f' : 'bwi-star'"></i>
{{ "markAppAsCritical" | i18n }}
</button>
</div>
<app-table-row-scrollable <app-table-row-scrollable
[dataSource]="dataSource" [dataSource]="dataSource"
[showRowCheckBox]="true" [showRowCheckBox]="true"
[showRowMenuForCriticalApps]="false" [showRowMenuForCriticalApps]="false"
[selectedUrls]="selectedUrls" [selectedUrls]="selectedUrls"
[openApplication]="drawerDetails.invokerId || ''" [openApplication]="drawerDetails.invokerId || ''"
[checkboxChange]="onCheckboxChange" [checkboxChange]="onCheckboxChange"
[showAppAtRiskMembers]="showAppAtRiskMembers" [showAppAtRiskMembers]="showAppAtRiskMembers"
></app-table-row-scrollable> ></app-table-row-scrollable>
</div>
} }
</div> }

View File

@@ -2,33 +2,17 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms"; import { FormControl } from "@angular/forms";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { combineLatest, debounceTime, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; import { debounceTime } from "rxjs";
import { Security } from "@bitwarden/assets/svg"; import { Security } from "@bitwarden/assets/svg";
import { import {
AllActivitiesService, ApplicationHealthReportDetailEnriched,
CriticalAppsService,
RiskInsightsDataService, RiskInsightsDataService,
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights"; } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
import {
LEGACY_ApplicationHealthReportDetailWithCriticalFlag,
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { import {
IconButtonModule, IconButtonModule,
NoItemsModule, NoItemsModule,
@@ -45,7 +29,7 @@ import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.compo
import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; import { ApplicationsLoadingComponent } from "./risk-insights-loading.component";
@Component({ @Component({
selector: "tools-all-applications", selector: "dirt-all-applications",
templateUrl: "./all-applications.component.html", templateUrl: "./all-applications.component.html",
imports: [ imports: [
ApplicationsLoadingComponent, ApplicationsLoadingComponent,
@@ -60,97 +44,44 @@ import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"
], ],
}) })
export class AllApplicationsComponent implements OnInit { export class AllApplicationsComponent implements OnInit {
protected dataSource = protected dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
new TableDataSource<LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher>();
protected selectedUrls: Set<string> = new Set<string>(); protected selectedUrls: Set<string> = new Set<string>();
protected searchControl = new FormControl("", { nonNullable: true }); protected searchControl = new FormControl("", { nonNullable: true });
protected loading = true;
protected organization = new Organization(); protected organization = new Organization();
noItemsIcon = Security; noItemsIcon = Security;
protected markingAsCritical = false; protected markingAsCritical = false;
protected applicationSummary: OrganizationReportSummary = createNewSummaryData(); protected applicationSummary: OrganizationReportSummary = createNewSummaryData();
destroyRef = inject(DestroyRef); destroyRef = inject(DestroyRef);
isLoading$: Observable<boolean> = of(false);
async ngOnInit() {
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId");
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
if (organizationId) {
const organization$ = this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(organizationId));
combineLatest([
this.dataService.applications$,
this.criticalAppsService.getAppsListForOrg(organizationId as OrganizationId),
organization$,
])
.pipe(
takeUntilDestroyed(this.destroyRef),
map(([applications, criticalApps, organization]) => {
if (applications && applications.length === 0 && criticalApps && criticalApps) {
const criticalUrls = criticalApps.map((ca) => ca.uri);
const data = applications?.map((app) => ({
...app,
isMarkedAsCritical: criticalUrls.includes(app.applicationName),
})) as LEGACY_ApplicationHealthReportDetailWithCriticalFlag[];
return { data, organization };
}
return { data: applications, organization };
}),
switchMap(async ({ data, organization }) => {
if (data && organization) {
const dataWithCiphers = await this.reportService.identifyCiphers(
data,
organization.id as OrganizationId,
);
return {
data: dataWithCiphers,
organization,
};
}
return { data: [], organization };
}),
)
.subscribe(({ data, organization }) => {
if (data) {
this.dataSource.data = data;
this.applicationSummary = this.reportService.generateApplicationsSummary(data);
this.allActivitiesService.setAllAppsReportSummary(this.applicationSummary);
}
if (organization) {
this.organization = organization;
}
});
this.isLoading$ = this.dataService.isLoading$;
}
}
constructor( constructor(
protected cipherService: CipherService,
protected i18nService: I18nService, protected i18nService: I18nService,
protected activatedRoute: ActivatedRoute, protected activatedRoute: ActivatedRoute,
protected toastService: ToastService, protected toastService: ToastService,
protected configService: ConfigService,
protected dataService: RiskInsightsDataService, protected dataService: RiskInsightsDataService,
protected organizationService: OrganizationService, // protected allActivitiesService: AllActivitiesService,
protected reportService: RiskInsightsReportService,
private accountService: AccountService,
protected criticalAppsService: CriticalAppsService,
protected riskInsightsEncryptionService: RiskInsightsEncryptionService,
protected allActivitiesService: AllActivitiesService,
) { ) {
this.searchControl.valueChanges this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed()) .pipe(debounceTime(200), takeUntilDestroyed())
.subscribe((v) => (this.dataSource.filter = v)); .subscribe((v) => (this.dataSource.filter = v));
} }
async ngOnInit() {
this.dataService.reportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (report) => {
this.applicationSummary = report?.summaryData ?? createNewSummaryData();
this.dataSource.data = report?.reportData ?? [];
},
error: () => {
this.dataSource.data = [];
},
});
// TODO
// this.applicationSummary = this.reportService.generateApplicationsSummary(data);
// this.allActivitiesService.setAllAppsReportSummary(this.applicationSummary);
}
goToCreateNewLoginItem = async () => { goToCreateNewLoginItem = async () => {
// TODO: implement // TODO: implement
this.toastService.showToast({ this.toastService.showToast({
@@ -167,41 +98,31 @@ export class AllApplicationsComponent implements OnInit {
markAppsAsCritical = async () => { markAppsAsCritical = async () => {
this.markingAsCritical = true; this.markingAsCritical = true;
try { this.dataService
await this.criticalAppsService.setCriticalApps( .saveCriticalApplications(Array.from(this.selectedUrls))
this.organization.id as OrganizationId, .pipe(takeUntilDestroyed(this.destroyRef))
Array.from(this.selectedUrls), .subscribe({
); next: () => {
this.toastService.showToast({
this.toastService.showToast({ variant: "success",
variant: "success", title: "",
title: "", message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"),
message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"), });
this.selectedUrls.clear();
this.markingAsCritical = false;
},
error: () => {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("applicationsMarkedAsCriticalFail"),
});
},
}); });
} finally {
this.selectedUrls.clear();
this.markingAsCritical = false;
}
}; };
showAppAtRiskMembers = async (applicationName: string) => { showAppAtRiskMembers = async (applicationName: string) => {
const info = { await this.dataService.setDrawerForAppAtRiskMembers(applicationName);
members:
this.dataSource.data.find((app) => app.applicationName === applicationName)
?.atRiskMemberDetails ?? [],
applicationName,
};
this.dataService.setDrawerForAppAtRiskMembers(info, applicationName);
};
showOrgAtRiskMembers = async (invokerId: string) => {
const dialogData = this.reportService.generateAtRiskMemberList(this.dataSource.data);
this.dataService.setDrawerForOrgAtRiskMembers(dialogData, invokerId);
};
showOrgAtRiskApps = async (invokerId: string) => {
const data = this.reportService.generateAtRiskApplicationList(this.dataSource.data);
this.dataService.setDrawerForOrgAtRiskApps(data, invokerId);
}; };
onCheckboxChange = (applicationName: string, event: Event) => { onCheckboxChange = (applicationName: string, event: Event) => {

View File

@@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core"; import { Component, Input } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; import { ApplicationHealthReportDetailEnriched } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { MenuModule, TableDataSource, TableModule } from "@bitwarden/components"; import { MenuModule, TableDataSource, TableModule } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
@@ -14,7 +14,7 @@ import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pip
}) })
export class AppTableRowScrollableComponent { export class AppTableRowScrollableComponent {
@Input() @Input()
dataSource!: TableDataSource<LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher>; dataSource!: TableDataSource<ApplicationHealthReportDetailEnriched>;
@Input() showRowMenuForCriticalApps: boolean = false; @Input() showRowMenuForCriticalApps: boolean = false;
@Input() showRowCheckBox: boolean = false; @Input() showRowCheckBox: boolean = false;
@Input() selectedUrls: Set<string> = new Set<string>(); @Input() selectedUrls: Set<string> = new Set<string>();

View File

@@ -49,7 +49,7 @@
type="button" type="button"
class="tw-flex-1" class="tw-flex-1"
tabindex="0" tabindex="0"
(click)="showOrgAtRiskMembers('criticalAppsAtRiskMembers')" (click)="dataService.setDrawerForOrgAtRiskMembers('criticalAppsAtRiskMembers')"
> >
<dirt-card <dirt-card
#criticalAppsAtRiskMembers #criticalAppsAtRiskMembers
@@ -67,7 +67,7 @@
type="button" type="button"
class="tw-flex-1" class="tw-flex-1"
tabindex="0" tabindex="0"
(click)="showOrgAtRiskApps('criticalAppsAtRiskApplications')" (click)="dataService.setDrawerForOrgAtRiskApps('criticalAppsAtRiskApplications')"
> >
<dirt-card <dirt-card
#criticalAppsAtRiskApplications #criticalAppsAtRiskApplications
@@ -96,7 +96,7 @@
[showRowMenuForCriticalApps]="true" [showRowMenuForCriticalApps]="true"
[openApplication]="drawerDetails.invokerId || ''" [openApplication]="drawerDetails.invokerId || ''"
[showAppAtRiskMembers]="showAppAtRiskMembers" [showAppAtRiskMembers]="showAppAtRiskMembers"
[unmarkAsCritical]="unmarkAsCritical" [unmarkAsCritical]="removeCriticalApplication"
></app-table-row-scrollable> ></app-table-row-scrollable>
} }
</div> </div>

View File

@@ -4,23 +4,15 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms"; import { FormControl } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, debounceTime, firstValueFrom, map, switchMap } from "rxjs"; import { debounceTime, EMPTY, map, switchMap } from "rxjs";
import { Security } from "@bitwarden/assets/svg"; import { Security } from "@bitwarden/assets/svg";
import { import {
AllActivitiesService, ApplicationHealthReportDetailEnriched,
CriticalAppsService,
RiskInsightsDataService, RiskInsightsDataService,
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights"; } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
LEGACY_ApplicationHealthReportDetailWithCriticalFlag,
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; import { SecurityTaskType } from "@bitwarden/common/vault/tasks";
@@ -37,7 +29,7 @@ import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.compo
import { RiskInsightsTabType } from "./risk-insights.component"; import { RiskInsightsTabType } from "./risk-insights.component";
@Component({ @Component({
selector: "tools-critical-applications", selector: "dirt-critical-applications",
templateUrl: "./critical-applications.component.html", templateUrl: "./critical-applications.component.html",
imports: [ imports: [
CardComponent, CardComponent,
@@ -51,60 +43,57 @@ import { RiskInsightsTabType } from "./risk-insights.component";
providers: [DefaultAdminTaskService], providers: [DefaultAdminTaskService],
}) })
export class CriticalApplicationsComponent implements OnInit { export class CriticalApplicationsComponent implements OnInit {
protected dataSource =
new TableDataSource<LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher>();
protected selectedIds: Set<number> = new Set<number>();
protected searchControl = new FormControl("", { nonNullable: true });
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
protected loading = false; protected loading = false;
protected enableRequestPasswordChange = false;
protected organizationId: OrganizationId; protected organizationId: OrganizationId;
protected applicationSummary = {} as OrganizationReportSummary;
noItemsIcon = Security; noItemsIcon = Security;
enableRequestPasswordChange = false;
protected dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
protected applicationSummary = {} as OrganizationReportSummary;
protected selectedIds: Set<number> = new Set<number>();
protected searchControl = new FormControl("", { nonNullable: true });
constructor(
protected activatedRoute: ActivatedRoute,
protected router: Router,
protected toastService: ToastService,
protected dataService: RiskInsightsDataService,
protected i18nService: I18nService,
private adminTaskService: DefaultAdminTaskService,
) {
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
.subscribe((v) => (this.dataSource.filter = v));
}
async ngOnInit() { async ngOnInit() {
this.organizationId = this.activatedRoute.snapshot.paramMap.get( this.dataService.criticalReportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
"organizationId", next: (criticalReport) => {
) as OrganizationId; this.dataSource.data = criticalReport?.reportData ?? [];
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.applicationSummary = criticalReport?.summaryData ?? createNewSummaryData();
this.criticalAppsService.loadOrganizationContext(this.organizationId as OrganizationId, userId); this.enableRequestPasswordChange = criticalReport?.summaryData?.totalAtRiskMemberCount > 0;
},
if (this.organizationId) { error: () => {
combineLatest([ this.dataSource.data = [];
this.dataService.applications$, this.applicationSummary = createNewSummaryData();
this.criticalAppsService.getAppsListForOrg(this.organizationId as OrganizationId), this.enableRequestPasswordChange = false;
]) },
.pipe( });
takeUntilDestroyed(this.destroyRef), this.activatedRoute.paramMap
map(([applications, criticalApps]) => { .pipe(
const criticalUrls = criticalApps.map((ca) => ca.uri); takeUntilDestroyed(this.destroyRef),
const data = applications?.map((app) => ({ map((params) => params.get("organizationId")),
...app, switchMap(async (orgId) => {
isMarkedAsCritical: criticalUrls.includes(app.applicationName), if (orgId) {
})) as LEGACY_ApplicationHealthReportDetailWithCriticalFlag[]; this.organizationId = orgId as OrganizationId;
return data?.filter((app) => app.isMarkedAsCritical); } else {
}), return EMPTY;
switchMap(async (data) => {
if (data) {
const dataWithCiphers = await this.reportService.identifyCiphers(
data,
this.organizationId,
);
return dataWithCiphers;
}
return null;
}),
)
.subscribe((applications) => {
if (applications) {
this.dataSource.data = applications;
this.applicationSummary = this.reportService.generateApplicationsSummary(applications);
this.enableRequestPasswordChange = this.applicationSummary.totalAtRiskMemberCount > 0;
this.allActivitiesService.setCriticalAppsReportSummary(this.applicationSummary);
this.allActivitiesService.setAllAppsReportDetails(applications);
} }
}); }),
} )
.subscribe();
} }
goToAllAppsTab = async () => { goToAllAppsTab = async () => {
@@ -117,26 +106,25 @@ export class CriticalApplicationsComponent implements OnInit {
); );
}; };
unmarkAsCritical = async (hostname: string) => { removeCriticalApplication = async (hostname: string) => {
try { this.dataService
await this.criticalAppsService.dropCriticalApp( .removeCriticalApplication(hostname)
this.organizationId as OrganizationId, .pipe(takeUntilDestroyed(this.destroyRef))
hostname, .subscribe({
); next: () => {
} catch { this.toastService.showToast({
this.toastService.showToast({ message: this.i18nService.t("criticalApplicationUnmarkedSuccessfully"),
message: this.i18nService.t("unexpectedError"), variant: "success",
variant: "error", });
title: this.i18nService.t("error"), },
error: () => {
this.toastService.showToast({
message: this.i18nService.t("unexpectedError"),
variant: "error",
title: this.i18nService.t("error"),
});
},
}); });
return;
}
this.toastService.showToast({
message: this.i18nService.t("criticalApplicationUnmarkedSuccessfully"),
variant: "success",
});
this.dataSource.data = this.dataSource.data.filter((app) => app.applicationName !== hostname);
}; };
async requestPasswordChange() { async requestPasswordChange() {
@@ -167,42 +155,7 @@ export class CriticalApplicationsComponent implements OnInit {
}); });
} }
} }
constructor(
protected activatedRoute: ActivatedRoute,
protected router: Router,
protected toastService: ToastService,
protected dataService: RiskInsightsDataService,
protected criticalAppsService: CriticalAppsService,
protected reportService: RiskInsightsReportService,
protected i18nService: I18nService,
private configService: ConfigService,
private adminTaskService: DefaultAdminTaskService,
private accountService: AccountService,
private allActivitiesService: AllActivitiesService,
) {
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
.subscribe((v) => (this.dataSource.filter = v));
}
showAppAtRiskMembers = async (applicationName: string) => { showAppAtRiskMembers = async (applicationName: string) => {
const data = { await this.dataService.setDrawerForAppAtRiskMembers(applicationName);
members:
this.dataSource.data.find((app) => app.applicationName === applicationName)
?.atRiskMemberDetails ?? [],
applicationName,
};
this.dataService.setDrawerForAppAtRiskMembers(data, applicationName);
};
showOrgAtRiskMembers = async (invokerId: string) => {
const data = this.reportService.generateAtRiskMemberList(this.dataSource.data);
this.dataService.setDrawerForOrgAtRiskMembers(data, invokerId);
};
showOrgAtRiskApps = async (invokerId: string) => {
const data = this.reportService.generateAtRiskApplicationList(this.dataSource.data);
this.dataService.setDrawerForOrgAtRiskApps(data, invokerId);
}; };
} }

View File

@@ -4,7 +4,7 @@ import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
@Component({ @Component({
selector: "tools-risk-insights-loading", selector: "dirt-risk-insights-loading",
imports: [CommonModule, JslibModule], imports: [CommonModule, JslibModule],
templateUrl: "./risk-insights-loading.component.html", templateUrl: "./risk-insights-loading.component.html",
}) })

View File

@@ -4,19 +4,23 @@
{{ "reviewAtRiskPasswords" | i18n }} {{ "reviewAtRiskPasswords" | i18n }}
</div> </div>
<div <div
*ngIf="dataLastUpdated$ | async"
class="tw-bg-primary-100 tw-rounded-lg tw-w-full tw-px-8 tw-py-4 tw-my-4 tw-flex tw-items-center" class="tw-bg-primary-100 tw-rounded-lg tw-w-full tw-px-8 tw-py-4 tw-my-4 tw-flex tw-items-center"
> >
<i <i
class="bwi bwi-exclamation-triangle bwi-lg tw-text-[1.2rem] tw-text-muted" class="bwi bwi-exclamation-triangle bwi-lg tw-text-[1.2rem] tw-text-muted"
aria-hidden="true" aria-hidden="true"
></i> ></i>
<span class="tw-mx-4">{{ @if (dataLastUpdated) {
"dataLastUpdated" | i18n: (dataLastUpdated$ | async | date: "MMMM d, y 'at' h:mm a") <span class="tw-mx-4">{{
}}</span> "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a")
<span class="tw-flex tw-justify-center tw-w-16"> }}</span>
} @else {
<span class="tw-mx-4">{{ "noReportRan" | i18n }}</span>
}
@let isRunningReport = dataService.isRunningReport$ | async;
<span class="tw-flex tw-justify-center">
<button <button
*ngIf="!(isRefreshing$ | async)" *ngIf="!isRunningReport"
type="button" type="button"
bitButton bitButton
buttonType="secondary" buttonType="secondary"
@@ -24,11 +28,11 @@
tabindex="0" tabindex="0"
[bitAction]="refreshData.bind(this)" [bitAction]="refreshData.bind(this)"
> >
{{ "refresh" | i18n }} {{ "riskInsightsRunReport" | i18n }}
</button> </button>
<span> <span>
<i <i
*ngIf="isRefreshing$ | async" *ngIf="isRunningReport"
class="bwi bwi-spinner bwi-spin tw-text-muted tw-text-[1.2rem]" class="bwi bwi-spinner bwi-spin tw-text-muted tw-text-[1.2rem]"
aria-hidden="true" aria-hidden="true"
></i> ></i>
@@ -38,18 +42,21 @@
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)"> <bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
@if (isRiskInsightsActivityTabFeatureEnabled) { @if (isRiskInsightsActivityTabFeatureEnabled) {
<bit-tab label="{{ 'activity' | i18n }}"> <bit-tab label="{{ 'activity' | i18n }}">
<tools-all-activity></tools-all-activity> <dirt-all-activity></dirt-all-activity>
</bit-tab> </bit-tab>
} }
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}"> <bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">
<tools-all-applications></tools-all-applications> <dirt-all-applications></dirt-all-applications>
</bit-tab> </bit-tab>
<bit-tab> <bit-tab>
<ng-template bitTabLabel> <ng-template bitTabLabel>
<i class="bwi bwi-star"></i> <i class="bwi bwi-star"></i>
{{ "criticalApplicationsWithCount" | i18n: (criticalApps$ | async)?.length ?? 0 }} {{
"criticalApplicationsWithCount"
| i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0
}}
</ng-template> </ng-template>
<tools-critical-applications></tools-critical-applications> <dirt-critical-applications></dirt-critical-applications>
</bit-tab> </bit-tab>
</bit-tab-group> </bit-tab-group>
@@ -69,7 +76,9 @@
}}</span> }}</span>
<ng-container *ngIf="drawerDetails.atRiskMemberDetails.length > 0"> <ng-container *ngIf="drawerDetails.atRiskMemberDetails.length > 0">
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted"> <div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
<div bitTypography="body2" class="tw-text-sm tw-font-bold">{{ "email" | i18n }}</div> <div bitTypography="body2" class="tw-text-sm tw-font-bold">
{{ "email" | i18n }}
</div>
<div bitTypography="body2" class="tw-text-sm tw-font-bold"> <div bitTypography="body2" class="tw-text-sm tw-font-bold">
{{ "atRiskPasswords" | i18n }} {{ "atRiskPasswords" | i18n }}
</div> </div>

View File

@@ -2,21 +2,12 @@ import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, inject } from "@angular/core"; import { Component, DestroyRef, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { EMPTY, firstValueFrom, Observable } from "rxjs"; import { EMPTY } from "rxjs";
import { map, switchMap } from "rxjs/operators"; import { map, switchMap } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
CriticalAppsService, import { DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
RiskInsightsDataService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { PasswordHealthReportApplicationsResponse } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/api-models.types";
import {
ApplicationHealthReportDetail,
DrawerType,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrganizationId } from "@bitwarden/common/types/guid";
@@ -67,19 +58,13 @@ export class RiskInsightsComponent implements OnInit {
tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps; tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps;
isRiskInsightsActivityTabFeatureEnabled: boolean = false; isRiskInsightsActivityTabFeatureEnabled: boolean = false;
dataLastUpdated: Date = new Date();
criticalApps$: Observable<PasswordHealthReportApplicationsResponse[]> = new Observable();
appsCount: number = 0; appsCount: number = 0;
criticalAppsCount: number = 0; // Leaving this commented because it's not used but seems important
notifiedMembersCount: number = 0; // notifiedMembersCount: number = 0;
private organizationId: OrganizationId = "" as OrganizationId; private organizationId: OrganizationId = "" as OrganizationId;
isLoading$: Observable<boolean> = new Observable<boolean>(); dataLastUpdated: Date | null = null;
isRefreshing$: Observable<boolean> = new Observable<boolean>();
dataLastUpdated$: Observable<Date | null> = new Observable<Date | null>();
refetching: boolean = false; refetching: boolean = false;
constructor( constructor(
@@ -87,8 +72,6 @@ export class RiskInsightsComponent implements OnInit {
private router: Router, private router: Router,
private configService: ConfigService, private configService: ConfigService,
protected dataService: RiskInsightsDataService, protected dataService: RiskInsightsDataService,
private criticalAppsService: CriticalAppsService,
private accountService: AccountService,
) { ) {
this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => {
this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps; this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps;
@@ -104,39 +87,29 @@ export class RiskInsightsComponent implements OnInit {
} }
async ngOnInit() { async ngOnInit() {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.route.paramMap this.route.paramMap
.pipe( .pipe(
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
map((params) => params.get("organizationId")), map((params) => params.get("organizationId")),
switchMap((orgId) => { switchMap(async (orgId) => {
if (orgId) { if (orgId) {
// Initialize Data Service
await this.dataService.initializeForOrganization(orgId as OrganizationId);
this.organizationId = orgId as OrganizationId; this.organizationId = orgId as OrganizationId;
this.dataService.fetchApplicationsReport(this.organizationId);
this.isLoading$ = this.dataService.isLoading$;
this.isRefreshing$ = this.dataService.isRefreshing$;
this.dataLastUpdated$ = this.dataService.dataLastUpdated$;
return this.dataService.applications$;
} else { } else {
return EMPTY; return EMPTY;
} }
}), }),
) )
.subscribe({ .subscribe();
next: (applications: ApplicationHealthReportDetail[] | null) => {
if (applications) {
this.appsCount = applications.length;
}
this.criticalAppsService.loadOrganizationContext( // Subscribe to report result details
this.organizationId as OrganizationId, this.dataService.reportResults$
userId, .pipe(takeUntilDestroyed(this.destroyRef))
); .subscribe((report) => {
this.criticalApps$ = this.criticalAppsService.getAppsListForOrg( this.appsCount = report?.reportData.length ?? 0;
this.organizationId as OrganizationId, this.dataLastUpdated = report?.creationDate ?? null;
);
},
}); });
// Subscribe to drawer state changes // Subscribe to drawer state changes
@@ -156,7 +129,7 @@ export class RiskInsightsComponent implements OnInit {
*/ */
refreshData(): void { refreshData(): void {
if (this.organizationId) { if (this.organizationId) {
this.dataService.refreshApplicationsReport(this.organizationId); this.dataService.triggerReport();
} }
} }