1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +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

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

View File

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

View File

@@ -1,3 +1,5 @@
export * from "./api-models.types";
export * from "./password-health";
export * from "./report-data-service.types";
export * from "./report-encryption.types";
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
// @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 { BadgeVariant } from "@bitwarden/components";
@@ -33,16 +31,6 @@ export type ExposedPasswordDetail = {
exposedXTimes: number;
} | 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 = {
userGuid: 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[];
};
export type ApplicationHealthReportDetailEnriched = ApplicationHealthReportDetail & {
isMarkedAsCritical: boolean;
ciphers: CipherView[];
};
/*
* A list of applications and the count of
* at risk passwords for each application
@@ -148,12 +143,6 @@ export type AtRiskApplicationDetail = {
// -------------------- Password Health Report Models --------------------
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 ReportResult = CipherView & {
@@ -162,8 +151,9 @@ export type ReportResult = CipherView & {
scoreKey: number;
};
export type ReportDetailsAndSummary = {
data: ApplicationHealthReportDetailEnriched[];
summary: OrganizationReportSummary;
dateCreated: Date;
};
export interface RiskInsightsData {
creationDate: Date;
reportData: ApplicationHealthReportDetail[];
summaryData: OrganizationReportSummary;
applicationData: OrganizationReportApplication[];
}

View File

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

View File

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

View File

@@ -83,11 +83,6 @@ export class CriticalAppsService {
.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
async setCriticalApps(orgId: OrganizationId, selectedUrls: string[]) {
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 { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
import { EncryptedDataWithKey } from "../models";
import {
GetRiskInsightsApplicationDataResponse,
GetRiskInsightsReportResponse,
@@ -14,7 +15,7 @@ import {
SaveRiskInsightsReportRequest,
SaveRiskInsightsReportResponse,
} 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";
@@ -26,17 +27,21 @@ describe("RiskInsightsApiService", () => {
const orgId = "org1" as OrganizationId;
const mockReportId = "report-1";
const mockKey = "encryption-key-1";
const mockData = "encrypted-data";
const reportData = makeEncString("test").encryptedString?.toString() ?? "";
const reportKey = makeEncString("test-key").encryptedString?.toString() ?? "";
const mockReportKey = makeEncString("test-key");
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: {
organizationId: orgId,
date: new Date().toISOString(),
reportData: reportData,
contentEncryptionKey: reportKey,
creationDate: new Date().toISOString(),
reportData: mockReportEnc.decryptedValue ?? "",
summaryData: mockReportEnc.decryptedValue ?? "",
applicationData: mockReportEnc.decryptedValue ?? "",
contentEncryptionKey: mockReportKey.decryptedValue ?? "",
},
};
@@ -53,7 +58,9 @@ describe("RiskInsightsApiService", () => {
id: mockId,
organizationId: orgId,
date: new Date().toISOString(),
reportData: mockData,
reportData: mockReportEnc,
summaryData: mockSummaryEnc,
applicationData: mockApplicationsEnc,
contentEncryptionKey: mockKey,
};
@@ -96,17 +103,17 @@ describe("RiskInsightsApiService", () => {
});
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(
service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId),
service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId),
);
expect(result).toEqual(new SaveRiskInsightsReportResponse(saveRiskInsightsReportRequest));
expect(result).toEqual(new SaveRiskInsightsReportResponse(mockSaveRiskInsightsReportRequest));
expect(mockApiService.send).toHaveBeenCalledWith(
"POST",
`/reports/organizations/${orgId.toString()}`,
saveRiskInsightsReportRequest.data,
mockSaveRiskInsightsReportRequest.data,
true,
true,
);
@@ -117,13 +124,13 @@ describe("RiskInsightsApiService", () => {
mockApiService.send.mockReturnValue(Promise.reject(error));
await expect(
firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)),
firstValueFrom(service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId)),
).rejects.toEqual(error);
expect(mockApiService.send).toHaveBeenCalledWith(
"POST",
`/reports/organizations/${orgId.toString()}`,
saveRiskInsightsReportRequest.data,
mockSaveRiskInsightsReportRequest.data,
true,
true,
);
@@ -134,13 +141,13 @@ describe("RiskInsightsApiService", () => {
mockApiService.send.mockReturnValue(Promise.reject(error));
await expect(
firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)),
firstValueFrom(service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId)),
).rejects.toEqual(error);
expect(mockApiService.send).toHaveBeenCalledWith(
"POST",
`/reports/organizations/${orgId.toString()}`,
saveRiskInsightsReportRequest.data,
mockSaveRiskInsightsReportRequest.data,
true,
true,
);
@@ -153,7 +160,7 @@ describe("RiskInsightsApiService", () => {
{
reportId: mockReportId,
organizationId: orgId,
encryptedData: mockData,
encryptedData: mockReportData,
contentEncryptionKey: mockKey,
},
];
@@ -175,8 +182,10 @@ describe("RiskInsightsApiService", () => {
it("updateRiskInsightsSummary$ should call apiService.send with correct parameters and return an Observable", async () => {
const data: EncryptedDataWithKey = {
organizationId: orgId,
encryptedData: new EncString(mockData),
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;
@@ -199,7 +208,9 @@ describe("RiskInsightsApiService", () => {
const reportId = "report123" as OrganizationReportId;
const mockResponse: EncryptedDataWithKey | null = {
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),
};
@@ -217,21 +228,17 @@ describe("RiskInsightsApiService", () => {
});
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 mockApplication = mockApplicationData[0];
mockApiService.send.mockResolvedValueOnce(undefined);
const result = await firstValueFrom(
service.updateRiskInsightsApplicationData$(applicationData, orgId, reportId),
service.updateRiskInsightsApplicationData$(mockApplication, orgId, reportId),
);
expect(mockApiService.send).toHaveBeenCalledWith(
"PATCH",
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
applicationData,
mockApplication,
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 { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
import { EncryptedDataWithKey, OrganizationReportApplication } from "../models";
import {
GetRiskInsightsApplicationDataResponse,
GetRiskInsightsReportResponse,
@@ -11,7 +12,6 @@ import {
SaveRiskInsightsReportRequest,
SaveRiskInsightsReportResponse,
} from "../models/api-models.types";
import { EncryptedDataWithKey } from "../models/password-health";
export class RiskInsightsApiService {
constructor(private apiService: ApiService) {}
@@ -102,7 +102,7 @@ export class RiskInsightsApiService {
}
updateRiskInsightsApplicationData$(
applicationData: EncryptedDataWithKey,
applicationData: OrganizationReportApplication,
orgId: OrganizationId,
reportId: OrganizationReportId,
): 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 {
catchError,
distinctUntilChanged,
exhaustMap,
filter,
finalize,
map,
shareReplay,
switchMap,
tap,
withLatestFrom,
@@ -18,19 +20,13 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import {
AppAtRiskMembersDialogParams,
AtRiskApplicationDetail,
AtRiskMemberDetail,
DrawerType,
DrawerDetails,
ApplicationHealthReportDetail,
ApplicationHealthReportDetailEnriched,
ReportDetailsAndSummary,
} from "../models/report-models";
import { ApplicationHealthReportDetailEnriched } from "../models";
import { RiskInsightsEnrichedData } from "../models/report-data-service.types";
import { DrawerType, DrawerDetails, ApplicationHealthReportDetail } from "../models/report-models";
import { CriticalAppsService } from "./critical-apps.service";
import { RiskInsightsReportService } from "./risk-insights-report.service";
export class RiskInsightsDataService {
// -------------------------- Context state --------------------------
// Current user viewing risk insights
@@ -45,16 +41,17 @@ export class RiskInsightsDataService {
organizationDetails$ = this.organizationDetailsSubject.asObservable();
// -------------------------- Data ------------------------------------
private applicationsSubject = new BehaviorSubject<ApplicationHealthReportDetail[] | null>(null);
applications$ = this.applicationsSubject.asObservable();
// TODO: Remove. Will use report results
private LEGACY_applicationsSubject = new BehaviorSubject<ApplicationHealthReportDetail[] | null>(
null,
);
LEGACY_applications$ = this.LEGACY_applicationsSubject.asObservable();
private dataLastUpdatedSubject = new BehaviorSubject<Date | null>(null);
dataLastUpdated$ = this.dataLastUpdatedSubject.asObservable();
criticalApps$ = this.criticalAppsService.criticalAppsList$;
// TODO: Remove. Will use date from report results
private LEGACY_dataLastUpdatedSubject = new BehaviorSubject<Date | null>(null);
dataLastUpdated$ = this.LEGACY_dataLastUpdatedSubject.asObservable();
// --------------------------- UI State ------------------------------------
private isLoadingSubject = new BehaviorSubject<boolean>(false);
isLoading$ = this.isLoadingSubject.asObservable();
@@ -78,21 +75,52 @@ export class RiskInsightsDataService {
// ------------------------- Report Variables ----------------
// The last run report details
private reportResultsSubject = new BehaviorSubject<ReportDetailsAndSummary | null>(null);
private reportResultsSubject = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
reportResults$ = this.reportResultsSubject.asObservable();
// Is a report being generated
private isRunningReportSubject = new BehaviorSubject<boolean>(false);
isRunningReport$ = this.isRunningReportSubject.asObservable();
// The error from report generation if there was an error
// --------------------------- Critical Application data ---------------------
criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
constructor(
private accountService: AccountService,
private criticalAppsService: CriticalAppsService,
private organizationService: OrganizationService,
private reportService: RiskInsightsReportService,
) {}
) {
// Reload report if critical applications change
// This also handles the original report load
this.criticalAppsService.criticalAppsList$
.pipe(withLatestFrom(this.organizationDetails$, this.userId$))
.subscribe({
next: ([_criticalApps, organizationDetails, userId]) => {
if (organizationDetails?.organizationId && userId) {
this.fetchLastReport(organizationDetails?.organizationId, userId);
}
},
});
// Setup critical application data and summary generation for live critical application usage
this.criticalReportResults$ = this.reportResults$.pipe(
filter((report) => !!report),
map((r) => {
const criticalApplications = r.reportData.filter(
(application) => application.isMarkedAsCritical,
);
const summary = this.reportService.generateApplicationsSummary(criticalApplications);
return {
...r,
summaryData: summary,
reportData: criticalApplications,
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
// [FIXME] PM-25612 - Call Initialization in RiskInsightsComponent instead of child components
async initializeForOrganization(organizationId: OrganizationId) {
// Fetch current user
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
@@ -115,9 +143,6 @@ export class RiskInsightsDataService {
// Load critical applications for organization
await this.criticalAppsService.loadOrganizationContext(organizationId, userId);
// Load existing report
this.fetchLastReport(organizationId, userId);
// Setup new report generation
this._runApplicationsReport().subscribe({
next: (result) => {
@@ -133,7 +158,7 @@ export class RiskInsightsDataService {
* Fetches the applications report and updates the applicationsSubject.
* @param organizationId The ID of the organization.
*/
fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void {
LEGACY_fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void {
if (isRefresh) {
this.isRefreshingSubject.next(true);
} else {
@@ -145,24 +170,20 @@ export class RiskInsightsDataService {
finalize(() => {
this.isLoadingSubject.next(false);
this.isRefreshingSubject.next(false);
this.dataLastUpdatedSubject.next(new Date());
this.LEGACY_dataLastUpdatedSubject.next(new Date());
}),
)
.subscribe({
next: (reports: ApplicationHealthReportDetail[]) => {
this.applicationsSubject.next(reports);
this.LEGACY_applicationsSubject.next(reports);
this.errorSubject.next(null);
},
error: () => {
this.applicationsSubject.next([]);
this.LEGACY_applicationsSubject.next([]);
},
});
}
refreshApplicationsReport(organizationId: OrganizationId): void {
this.fetchApplicationsReport(organizationId, true);
}
// ------------------------------- Enrichment methods -------------------------------
/**
* Takes the basic application health report details and enriches them to include
@@ -174,8 +195,10 @@ export class RiskInsightsDataService {
enrichReportData$(
applications: ApplicationHealthReportDetail[],
): Observable<ApplicationHealthReportDetailEnriched[]> {
// TODO Compare applications on report to updated critical applications
// TODO Compare applications on report to any new applications
return of(applications).pipe(
withLatestFrom(this.organizationDetails$, this.criticalApps$),
withLatestFrom(this.organizationDetails$, this.criticalAppsService.criticalAppsList$),
switchMap(async ([apps, orgDetails, criticalApps]) => {
if (!orgDetails) {
return [];
@@ -200,19 +223,11 @@ export class RiskInsightsDataService {
);
}
// ------------------------------- Drawer management methods -------------------------------
// ------------------------- Drawer functions -----------------------------
isActiveDrawerType$ = (drawerType: DrawerType): Observable<boolean> => {
return this.drawerDetails$.pipe(map((details) => details.activeDrawerType === drawerType));
};
isActiveDrawerType = (drawerType: DrawerType): boolean => {
return this.drawerDetailsSubject.value.activeDrawerType === drawerType;
};
isDrawerOpenForInvoker$ = (applicationName: string) => {
return this.drawerDetails$.pipe(map((details) => details.invokerId === applicationName));
};
isDrawerOpenForInvoker = (applicationName: string): boolean => {
return this.drawerDetailsSubject.value.invokerId === applicationName;
};
@@ -228,10 +243,7 @@ export class RiskInsightsDataService {
});
};
setDrawerForOrgAtRiskMembers = (
atRiskMemberDetails: AtRiskMemberDetail[],
invokerId: string = "",
): void => {
setDrawerForOrgAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose =
open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId;
@@ -239,6 +251,15 @@ export class RiskInsightsDataService {
if (shouldClose) {
this.closeDrawer();
} else {
const reportResults = await firstValueFrom(this.reportResults$);
if (!reportResults) {
return;
}
const atRiskMemberDetails = this.reportService.generateAtRiskMemberList(
reportResults.reportData,
);
this.drawerDetailsSubject.next({
open: true,
invokerId,
@@ -250,10 +271,7 @@ export class RiskInsightsDataService {
}
};
setDrawerForAppAtRiskMembers = (
atRiskMembersDialogParams: AppAtRiskMembersDialogParams,
invokerId: string = "",
): void => {
setDrawerForAppAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose =
open && activeDrawerType === DrawerType.AppAtRiskMembers && currentInvokerId === invokerId;
@@ -261,21 +279,29 @@ export class RiskInsightsDataService {
if (shouldClose) {
this.closeDrawer();
} else {
const reportResults = await firstValueFrom(this.reportResults$);
if (!reportResults) {
return;
}
const atRiskMembers = {
members:
reportResults.reportData.find((app) => app.applicationName === invokerId)
?.atRiskMemberDetails ?? [],
applicationName: invokerId,
};
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.AppAtRiskMembers,
atRiskMemberDetails: [],
appAtRiskMembers: atRiskMembersDialogParams,
appAtRiskMembers: atRiskMembers,
atRiskAppDetails: null,
});
}
};
setDrawerForOrgAtRiskApps = (
atRiskApps: AtRiskApplicationDetail[],
invokerId: string = "",
): void => {
setDrawerForOrgAtRiskApps = async (invokerId: string = ""): Promise<void> => {
const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value;
const shouldClose =
open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId;
@@ -283,13 +309,21 @@ export class RiskInsightsDataService {
if (shouldClose) {
this.closeDrawer();
} else {
const reportResults = await firstValueFrom(this.reportResults$);
if (!reportResults) {
return;
}
const atRiskAppDetails = this.reportService.generateAtRiskApplicationList(
reportResults.reportData,
);
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.OrgAtRiskApps,
atRiskMemberDetails: [],
appAtRiskMembers: null,
atRiskAppDetails: atRiskApps,
atRiskAppDetails,
});
}
};
@@ -311,23 +345,31 @@ export class RiskInsightsDataService {
.getRiskInsightsReport$(organizationId, userId)
.pipe(
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) => ({
data: enrichedReport,
summary: report.summary,
report: enrichedReport,
summary: report.summaryData,
applications: report.applicationData,
creationDate: report.creationDate,
})),
);
}),
catchError((error: unknown) => {
// console.error("An error occurred when fetching the last report", error);
return EMPTY;
}),
finalize(() => {
this.isLoadingSubject.next(false);
}),
)
.subscribe({
next: ({ data, summary }) => {
next: ({ report, summary, applications, creationDate }) => {
this.reportResultsSubject.next({
data,
summary,
dateCreated: new Date(),
reportData: report,
summaryData: summary,
applicationData: applications,
creationDate: creationDate,
});
this.errorSubject.next(null);
this.isLoadingSubject.next(false);
@@ -343,6 +385,7 @@ export class RiskInsightsDataService {
private _runApplicationsReport() {
return this.isRunningReport$.pipe(
distinctUntilChanged(),
// Only run this report if the flag for running is true
filter((isRunning) => isRunning),
withLatestFrom(this.organizationDetails$, this.userId$),
exhaustMap(([_, organizationDetails, userId]) => {
@@ -353,22 +396,30 @@ export class RiskInsightsDataService {
// Generate the report
return this.reportService.generateApplicationsReport$(organizationId).pipe(
map((data) => ({
data,
summary: this.reportService.generateApplicationsSummary(data),
map((report) => ({
report,
summary: this.reportService.generateApplicationsSummary(report),
applications: this.reportService.generateOrganizationApplications(report),
})),
switchMap(({ data, summary }) =>
this.enrichReportData$(data).pipe(
map((enrichedData) => ({ data: enrichedData, summary })),
// Enrich report with critical markings
switchMap(({ report, summary, applications }) =>
this.enrichReportData$(report).pipe(
map((enrichedReport) => ({ report: enrichedReport, summary, applications })),
),
),
tap(({ data, summary }) => {
this.reportResultsSubject.next({ data, summary, dateCreated: new Date() });
// Load the updated data into the UI
tap(({ report, summary, applications }) => {
this.reportResultsSubject.next({
reportData: report,
summaryData: summary,
applicationData: applications,
creationDate: new Date(),
});
this.errorSubject.next(null);
}),
switchMap(({ data, summary }) => {
// Just returns ID
return this.reportService.saveRiskInsightsReport$(data, summary, {
switchMap(({ report, summary, applications }) => {
// Save the generated data
return this.reportService.saveRiskInsightsReport$(report, summary, applications, {
organizationId,
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 { 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";
describe("RiskInsightsEncryptionService", () => {
@@ -31,6 +34,10 @@ describe("RiskInsightsEncryptionService", () => {
};
const orgKey$ = new BehaviorSubject(OrgRecords);
let mockDecryptedData: DecryptedReportData;
let mockEncryptedData: EncryptedReportData;
let mockKey: EncString;
beforeEach(() => {
service = new RiskInsightsEncryptionService(
mockKeyService,
@@ -47,6 +54,18 @@ describe("RiskInsightsEncryptionService", () => {
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData));
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", () => {
@@ -55,22 +74,40 @@ describe("RiskInsightsEncryptionService", () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
// 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
expect(mockKeyService.orgKeys$).toHaveBeenCalledWith(userId);
expect(mockKeyGenerationService.createKey).toHaveBeenCalledWith(512);
// Assert all variables were encrypted
expect(mockEncryptService.encryptString).toHaveBeenCalledWith(
JSON.stringify(testData),
JSON.stringify(mockDecryptedData.reportData),
contentEncryptionKey,
);
expect(mockEncryptService.encryptString).toHaveBeenCalledWith(
JSON.stringify(mockDecryptedData.summaryData),
contentEncryptionKey,
);
expect(mockEncryptService.encryptString).toHaveBeenCalledWith(
JSON.stringify(mockDecryptedData.applicationData),
contentEncryptionKey,
);
expect(mockEncryptService.wrapSymmetricKey).toHaveBeenCalledWith(
contentEncryptionKey,
orgKey,
);
// Mocked encrypt returns ENCRYPTED_TEXT
expect(result).toEqual({
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),
});
});
@@ -82,9 +119,9 @@ describe("RiskInsightsEncryptionService", () => {
mockEncryptService.wrapSymmetricKey.mockResolvedValue(new EncString(ENCRYPTED_KEY));
// Act & Assert: call the method under test and expect rejection
await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow(
"Encryption failed, encrypted strings are null",
);
await expect(
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 () => {
@@ -94,18 +131,18 @@ describe("RiskInsightsEncryptionService", () => {
mockEncryptService.wrapSymmetricKey.mockResolvedValue(new EncString(""));
// Act & Assert: call the method under test and expect rejection
await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow(
"Encryption failed, encrypted strings are null",
);
await expect(
service.encryptRiskInsightsReport({ organizationId: orgId, userId }, mockDecryptedData),
).rejects.toThrow("Encryption failed, encrypted strings are null");
});
it("should throw if org key is not found", async () => {
// when we cannot get an organization key, we should throw an error
mockKeyService.orgKeys$.mockReturnValue(new BehaviorSubject({}));
await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow(
"Organization key not found",
);
await expect(
service.encryptRiskInsightsReport({ organizationId: orgId, userId }, mockDecryptedData),
).rejects.toThrow("Organization key not found");
});
});
@@ -120,23 +157,21 @@ describe("RiskInsightsEncryptionService", () => {
// actual decryption does not happen here,
// we just want to ensure the method calls are correct
const result = await service.decryptRiskInsightsReport(
orgId,
userId,
new EncString("encrypted-data"),
new EncString("wrapped-key"),
(data) => data as typeof testData,
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
);
expect(mockKeyService.orgKeys$).toHaveBeenCalledWith(userId);
expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith(
new EncString("wrapped-key"),
orgKey,
);
expect(mockEncryptService.decryptString).toHaveBeenCalledWith(
new EncString("encrypted-data"),
contentEncryptionKey,
);
expect(result).toEqual(testData);
expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith(mockKey, orgKey);
expect(mockEncryptService.decryptString).toHaveBeenCalledTimes(3);
// Mock decrypt returns JSON.stringify(testData)
expect(result).toEqual({
reportData: testData,
summaryData: testData,
applicationData: testData,
});
});
it("should invoke data type validation method during decryption", async () => {
@@ -144,77 +179,47 @@ describe("RiskInsightsEncryptionService", () => {
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData));
const mockParseFn = jest.fn((data) => data as typeof testData);
// act: call the decrypt method - with any params
// actual decryption does not happen here,
// we just want to ensure the method calls are correct
const result = await service.decryptRiskInsightsReport(
orgId,
userId,
new EncString("encrypted-data"),
new EncString("wrapped-key"),
mockParseFn,
{ organizationId: orgId, userId },
mockEncryptedData,
mockKey,
);
expect(mockParseFn).toHaveBeenCalledWith(JSON.parse(JSON.stringify(testData)));
expect(result).toEqual(testData);
expect(result).toEqual({
reportData: testData,
summaryData: testData,
applicationData: testData,
});
});
it("should return null if org key is not found", async () => {
mockKeyService.orgKeys$.mockReturnValue(new BehaviorSubject({}));
await expect(
service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
const result = await service.decryptRiskInsightsReport(
orgId,
userId,
new EncString("encrypted-data"),
new EncString("wrapped-key"),
(data) => data as typeof testData,
);
expect(result).toBeNull();
mockEncryptedData,
mockKey,
),
).rejects.toEqual(Error("Organization key not found"));
});
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();
});
await expect(
service.decryptRiskInsightsReport(
{ organizationId: orgId, userId },
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();
});
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();
mockEncryptedData,
mockKey,
),
).rejects.toEqual(Error("fail"));
});
});
});

View File

@@ -1,13 +1,13 @@
import { firstValueFrom, map } from "rxjs";
import { Jsonify } from "type-fest";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
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 { KeyService } from "@bitwarden/key-management";
import { EncryptedDataWithKey } from "../models/password-health";
import { DecryptedReportData, EncryptedReportData, EncryptedDataWithKey } from "../models";
export class RiskInsightsEncryptionService {
constructor(
@@ -16,11 +16,15 @@ export class RiskInsightsEncryptionService {
private keyGeneratorService: KeyGenerationService,
) {}
async encryptRiskInsightsReport<T>(
organizationId: OrganizationId,
userId: UserId,
data: T,
async encryptRiskInsightsReport(
context: {
organizationId: OrganizationId;
userId: UserId;
},
data: DecryptedReportData,
wrappedKey?: EncString,
): Promise<EncryptedDataWithKey> {
const { userId, organizationId } = context;
const orgKey = await firstValueFrom(
this.keyService
.orgKeys$(userId)
@@ -35,10 +39,28 @@ export class RiskInsightsEncryptionService {
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(
JSON.stringify(data),
const { reportData, summaryData, applicationData } = 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,
);
@@ -47,59 +69,87 @@ export class RiskInsightsEncryptionService {
orgKey,
);
if (!dataEncrypted.encryptedString || !wrappedEncryptionKey.encryptedString) {
if (
!encryptedReportData.encryptedString ||
!encryptedSummaryData.encryptedString ||
!encryptedApplicationData.encryptedString ||
!wrappedEncryptionKey.encryptedString
) {
throw new Error("Encryption failed, encrypted strings are null");
}
const encryptedData = dataEncrypted;
const contentEncryptionKeyString = wrappedEncryptionKey;
const encryptedDataPacket: EncryptedDataWithKey = {
organizationId,
encryptedData,
contentEncryptionKey: contentEncryptionKeyString,
encryptedReportData: encryptedReportData,
encryptedSummaryData: encryptedSummaryData,
encryptedApplicationData: encryptedApplicationData,
contentEncryptionKey: wrappedEncryptionKey,
};
return encryptedDataPacket;
}
async decryptRiskInsightsReport<T>(
organizationId: OrganizationId,
userId: UserId,
encryptedData: EncString,
async decryptRiskInsightsReport(
context: {
organizationId: OrganizationId;
userId: UserId;
},
encryptedData: EncryptedReportData,
wrappedKey: EncString,
parser: (data: Jsonify<T>) => T,
): Promise<T | null> {
try {
const orgKey = await firstValueFrom(
this.keyService
.orgKeys$(userId)
.pipe(
map((organizationKeysById) =>
organizationKeysById ? organizationKeysById[organizationId] : null,
),
): Promise<DecryptedReportData> {
const { userId, organizationId } = context;
const orgKey = await firstValueFrom(
this.keyService
.orgKeys$(userId)
.pipe(
map((organizationKeysById) =>
organizationKeysById ? organizationKeysById[organizationId] : null,
),
);
),
);
if (!orgKey) {
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;
if (!orgKey) {
throw new Error("Organization key not found");
}
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 { 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 { makeEncString } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
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 {
GetRiskInsightsReportResponse,
SaveRiskInsightsReportResponse,
} from "../models/api-models.types";
import { EncryptedDataWithKey } from "../models/password-health";
import {
ApplicationHealthReportDetail,
OrganizationReportSummary,
RiskInsightsReportData,
} from "../models/report-models";
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
mockApplicationData,
mockCipherViews,
mockMemberDetails,
mockReportData,
mockSummaryData,
} from "../models/mock-data";
import { mockCiphers } from "./ciphers.mock";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
@@ -45,17 +43,13 @@ describe("RiskInsightsReportService", () => {
// Non changing mock data
const mockOrganizationId = "orgId" as OrganizationId;
const mockUserId = "userId" as UserId;
const ENCRYPTED_TEXT = "This data has been encrypted";
const ENCRYPTED_KEY = "Re-encrypted Cipher Key";
const mockEncryptedText = new EncString(ENCRYPTED_TEXT);
const mockEncryptedKey = new EncString(ENCRYPTED_KEY);
const mockEncryptedKey = makeEncString("test-key");
// Changing mock data
let mockCipherViews: CipherView[];
let mockMemberDetails: MemberCipherDetailsResponse[];
let mockReport: ApplicationHealthReportDetail[];
let mockSummary: OrganizationReportSummary;
let mockEncryptedReport: EncryptedDataWithKey;
let mockDecryptedData: DecryptedReportData;
const mockReportEnc = makeEncString(JSON.stringify(mockReportData));
const mockSummaryEnc = makeEncString(JSON.stringify(mockSummaryData));
const mockApplicationsEnc = makeEncString(JSON.stringify(mockApplicationData));
beforeEach(() => {
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers);
@@ -87,75 +81,15 @@ describe("RiskInsightsReportService", () => {
service = new RiskInsightsReportService(
cipherService,
memberCipherDetailsService,
mockPasswordHealthService,
mockRiskInsightsApiService,
mockRiskInsightsEncryptionService,
mockPasswordHealthService,
);
// Reset mock ciphers before each test
mockCipherViews = [
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,
}),
];
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,
mockDecryptedData = {
reportData: mockReportData,
summaryData: mockSummaryData,
applicationData: mockApplicationData,
};
});
@@ -284,15 +218,22 @@ describe("RiskInsightsReportService", () => {
describe("saveRiskInsightsReport$", () => {
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(
mockEncryptedReport,
mockEncryptedOutput,
);
const saveResponse = new SaveRiskInsightsReportResponse({ id: "" }); // Simulating no ID in response
mockRiskInsightsApiService.saveRiskInsightsReport$.mockReturnValue(of(saveResponse));
service
.saveRiskInsightsReport$(mockReport, mockSummary, {
.saveRiskInsightsReport$(mockReportData, mockSummaryData, mockApplicationData, {
organizationId: mockOrganizationId,
userId: mockUserId,
})
@@ -321,17 +262,19 @@ describe("RiskInsightsReportService", () => {
it("should call with the correct organizationId", async () => {
// we need to ensure that the api is invoked with the specified organizationId
// here it doesn't matter what the Api returns
const apiResponse = {
const apiResponse = new GetRiskInsightsReportResponse({
id: "reportId",
date: new Date().toISOString(),
date: new Date(),
organizationId: mockOrganizationId,
reportData: mockEncryptedReport.encryptedData,
contentEncryptionKey: mockEncryptedReport.contentEncryptionKey,
} as GetRiskInsightsReportResponse;
reportData: mockReportEnc.encryptedString,
summaryData: mockSummaryEnc.encryptedString,
applicationData: mockApplicationsEnc.encryptedString,
contentEncryptionKey: mockEncryptedKey.encryptedString,
});
const decryptedResponse: RiskInsightsReportData = {
data: [],
summary: {
const decryptedResponse: DecryptedReportData = {
reportData: [],
summaryData: {
totalMemberCount: 1,
totalAtRiskMemberCount: 1,
totalApplicationCount: 1,
@@ -342,9 +285,9 @@ describe("RiskInsightsReportService", () => {
totalCriticalAtRiskApplicationCount: 1,
newApplications: [],
},
applicationData: [],
};
const organizationId = "orgId" as OrganizationId;
const userId = "userId" as UserId;
// Mock api returned encrypted data
@@ -355,17 +298,15 @@ describe("RiskInsightsReportService", () => {
Promise.resolve(decryptedResponse),
);
await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId));
await firstValueFrom(service.getRiskInsightsReport$(mockOrganizationId, userId));
expect(mockRiskInsightsApiService.getRiskInsightsReport$).toHaveBeenCalledWith(
organizationId,
mockOrganizationId,
);
expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith(
organizationId,
userId,
expect.anything(), // encryptedData
expect.anything(), // wrappedKey
expect.any(Function), // parser
{ organizationId: mockOrganizationId, userId },
expect.anything(),
expect.anything(),
);
});
@@ -375,32 +316,29 @@ describe("RiskInsightsReportService", () => {
const organizationId = "orgId" as OrganizationId;
const userId = "userId" as UserId;
const mockResponse = {
const mockResponse = new GetRiskInsightsReportResponse({
id: "reportId",
date: new Date().toISOString(),
creationDate: new Date(),
organizationId: organizationId as OrganizationId,
reportData: mockEncryptedReport.encryptedData,
contentEncryptionKey: mockEncryptedReport.contentEncryptionKey,
} as GetRiskInsightsReportResponse;
reportData: mockReportEnc.encryptedString,
summaryData: mockSummaryEnc.encryptedString,
applicationData: mockApplicationsEnc.encryptedString,
contentEncryptionKey: mockEncryptedKey.encryptedString,
});
const decryptedReport = {
data: [{ foo: "bar" }],
};
mockRiskInsightsApiService.getRiskInsightsReport$.mockReturnValue(of(mockResponse));
mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockResolvedValue(
decryptedReport,
mockDecryptedData,
);
const result = await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId));
expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith(
organizationId,
userId,
{ organizationId: mockOrganizationId, userId },
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 {
BehaviorSubject,
catchError,
concatMap,
EMPTY,
first,
firstValueFrom,
forkJoin,
@@ -19,7 +20,6 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
createNewReportData,
createNewSummaryData,
flattenMemberDetails,
getApplicationReportDetail,
getFlattenedCipherDetails,
@@ -45,7 +45,8 @@ import {
CipherHealthReport,
MemberDetails,
PasswordHealthData,
RiskInsightsReportData,
OrganizationReportApplication,
RiskInsightsData,
} from "../models/report-models";
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";
export class RiskInsightsReportService {
private riskInsightsReportSubject = new BehaviorSubject<ApplicationHealthReportDetail[]>([]);
riskInsightsReport$ = this.riskInsightsReportSubject.asObservable();
private riskInsightsSummarySubject = new BehaviorSubject<OrganizationReportSummary>(
createNewSummaryData(),
);
riskInsightsSummary$ = this.riskInsightsSummarySubject.asObservable();
// [FIXME] CipherData
// Cipher data
// private _ciphersSubject = new BehaviorSubject<CipherView[] | null>(null);
@@ -70,9 +63,9 @@ export class RiskInsightsReportService {
constructor(
private cipherService: CipherService,
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
private passwordHealthService: PasswordHealthService,
private riskInsightsApiService: RiskInsightsApiService,
private riskInsightsEncryptionService: RiskInsightsEncryptionService,
private passwordHealthService: PasswordHealthService,
) {}
// [FIXME] CipherData
@@ -152,6 +145,7 @@ export class RiskInsightsReportService {
/**
* Report data for the aggregation of uris to like uris and getting password/member counts,
* members, and at risk statuses.
*
* @param organizationId Id of the organization
* @returns The all applications health report data
*/
@@ -235,17 +229,32 @@ export class RiskInsightsReportService {
// TODO: totalCriticalMemberCount, totalCriticalAtRiskMemberCount, totalCriticalApplicationCount, totalCriticalAtRiskApplicationCount, and newApplications will be handled with future logic implementation
return {
totalMemberCount: uniqueMembers.length,
totalCriticalMemberCount: 0,
totalAtRiskMemberCount: uniqueAtRiskMembers.length,
totalCriticalAtRiskMemberCount: 0,
totalApplicationCount: reports.length,
totalCriticalApplicationCount: 0,
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
totalCriticalMemberCount: 0,
totalCriticalAtRiskMemberCount: 0,
totalCriticalApplicationCount: 0,
totalCriticalAtRiskApplicationCount: 0,
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(
data: ApplicationHealthReportDetail[],
organizationId: OrganizationId,
@@ -272,12 +281,12 @@ export class RiskInsightsReportService {
getRiskInsightsReport$(
organizationId: OrganizationId,
userId: UserId,
): Observable<RiskInsightsReportData> {
): Observable<RiskInsightsData> {
return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe(
switchMap((response): Observable<RiskInsightsReportData> => {
switchMap((response) => {
if (!response) {
// Return an empty report and summary if response is falsy
return of<RiskInsightsReportData>(createNewReportData());
return of<RiskInsightsData>(createNewReportData());
}
if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") {
return throwError(() => new Error("Report key not found"));
@@ -285,15 +294,43 @@ export class RiskInsightsReportService {
if (!response.reportData) {
return throwError(() => new Error("Report data not found"));
}
if (!response.summaryData) {
return throwError(() => new Error("Summary data not found"));
}
if (!response.applicationData) {
return throwError(() => new Error("Application data not found"));
}
return from(
this.riskInsightsEncryptionService.decryptRiskInsightsReport<RiskInsightsReportData>(
organizationId,
userId,
response.reportData,
this.riskInsightsEncryptionService.decryptRiskInsightsReport(
{
organizationId,
userId,
},
{
encryptedReportData: response.reportData,
encryptedSummaryData: response.summaryData,
encryptedApplicationData: response.applicationData,
},
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$(
report: ApplicationHealthReportDetail[],
summary: OrganizationReportSummary,
applications: OrganizationReportApplication[],
encryptionParameters: {
organizationId: OrganizationId;
userId: UserId;
@@ -315,28 +353,43 @@ export class RiskInsightsReportService {
): Observable<SaveRiskInsightsReportResponse> {
return from(
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
encryptionParameters.organizationId,
encryptionParameters.userId,
{
data: report,
summary: summary,
organizationId: encryptionParameters.organizationId,
userId: encryptionParameters.userId,
},
{
reportData: report,
summaryData: summary,
applicationData: applications,
},
),
).pipe(
map(({ encryptedData, contentEncryptionKey }) => ({
data: {
organizationId: encryptionParameters.organizationId,
date: new Date().toISOString(),
reportData: encryptedData.toSdk(),
contentEncryptionKey: contentEncryptionKey.toSdk(),
},
})),
map(
({
encryptedReportData,
encryptedSummaryData,
encryptedApplicationData,
contentEncryptionKey,
}) => ({
data: {
organizationId: encryptionParameters.organizationId,
creationDate: new Date().toISOString(),
reportData: encryptedReportData.toSdk(),
summaryData: encryptedSummaryData.toSdk(),
applicationData: encryptedApplicationData.toSdk(),
contentEncryptionKey: contentEncryptionKey.toSdk(),
},
}),
),
switchMap((encryptedReport) =>
this.riskInsightsApiService.saveRiskInsightsReport$(
encryptedReport,
encryptionParameters.organizationId,
),
),
catchError((error: unknown) => {
return EMPTY;
}),
map((response) => {
if (!isSaveRiskInsightsReportResponse(response)) {
throw new Error("Invalid response from API");
@@ -457,6 +510,13 @@ export class RiskInsightsReportService {
const applicationMap = new Map<string, CipherHealthReport[]>();
cipherHealthData.forEach((cipher: CipherHealthReport) => {
// Warning: Currently does not show ciphers with NO Application
// if (cipher.applications.length === 0) {
// const existingApplication = applicationMap.get("None") || [];
// existingApplication.push(cipher);
// applicationMap.set("None", existingApplication);
// }
cipher.applications.forEach((application) => {
const existingApplication = applicationMap.get(application) || [];
existingApplication.push(cipher);

View File

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

View File

@@ -1,10 +1,6 @@
@if (isLoading$ | async) {
<div *ngIf="isLoading$ | async">
<tools-risk-insights-loading></tools-risk-insights-loading>
</div>
}
@if (!(isLoading$ | async)) {
@if (dataService.isLoading$ | async) {
<dirt-risk-insights-loading></dirt-risk-insights-loading>
} @else {
<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"
>

View File

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

View File

@@ -1,96 +1,102 @@
<div *ngIf="isLoading$ | async">
<tools-risk-insights-loading></tools-risk-insights-loading>
</div>
<div class="tw-mt-4" *ngIf="!(isLoading$ | async) && !dataSource.data.length">
<bit-no-items [icon]="noItemsIcon" class="tw-text-main">
<ng-container slot="title">
<h2 class="tw-font-semibold tw-mt-4">
{{ "noAppsInOrgTitle" | i18n: organization?.name }}
</h2>
</ng-container>
<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>
@if (dataService.isLoading$ | async) {
<dirt-risk-insights-loading></dirt-risk-insights-loading>
} @else {
@let drawerDetails = dataService.drawerDetails$ | async;
@if (!dataSource.data.length) {
<div class="tw-mt-4">
<bit-no-items [icon]="noItemsIcon" class="tw-text-main">
<ng-container slot="title">
<h2 class="tw-font-semibold tw-mt-4">
{{
"noAppsInOrgTitle"
| i18n: (dataService.organizationDetails$ | async)?.organizationName || ""
}}
</h2>
</ng-container>
<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>
</ng-container>
<ng-container slot="button">
<button (click)="goToCreateNewLoginItem()" bitButton buttonType="primary" type="button">
{{ "createNewLoginItem" | i18n }}
</button>
</ng-container>
</bit-no-items>
</div>
<div class="tw-mt-4 tw-flex tw-flex-col" *ngIf="!(isLoading$ | async) && dataSource.data.length">
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
@if (dataService.drawerDetails$ | async; as drawerDetails) {
<div class="tw-flex tw-gap-6">
<button
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"
<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()"
>
</dirt-card>
</button>
<button
type="button"
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>
<i class="bwi tw-mr-2" [ngClass]="selectedUrls.size ? 'bwi-star-f' : 'bwi-star'"></i>
{{ "markAppAsCritical" | i18n }}
</button>
</div>
<app-table-row-scrollable
[dataSource]="dataSource"
[showRowCheckBox]="true"
[showRowMenuForCriticalApps]="false"
[selectedUrls]="selectedUrls"
[openApplication]="drawerDetails.invokerId || ''"
[checkboxChange]="onCheckboxChange"
[showAppAtRiskMembers]="showAppAtRiskMembers"
></app-table-row-scrollable>
<app-table-row-scrollable
[dataSource]="dataSource"
[showRowCheckBox]="true"
[showRowMenuForCriticalApps]="false"
[selectedUrls]="selectedUrls"
[openApplication]="drawerDetails.invokerId || ''"
[checkboxChange]="onCheckboxChange"
[showAppAtRiskMembers]="showAppAtRiskMembers"
></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 { FormControl } from "@angular/forms";
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 {
AllActivitiesService,
CriticalAppsService,
ApplicationHealthReportDetailEnriched,
RiskInsightsDataService,
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
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 { 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 { 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 { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
IconButtonModule,
NoItemsModule,
@@ -45,7 +29,7 @@ import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.compo
import { ApplicationsLoadingComponent } from "./risk-insights-loading.component";
@Component({
selector: "tools-all-applications",
selector: "dirt-all-applications",
templateUrl: "./all-applications.component.html",
imports: [
ApplicationsLoadingComponent,
@@ -60,97 +44,44 @@ import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"
],
})
export class AllApplicationsComponent implements OnInit {
protected dataSource =
new TableDataSource<LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher>();
protected dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
protected selectedUrls: Set<string> = new Set<string>();
protected searchControl = new FormControl("", { nonNullable: true });
protected loading = true;
protected organization = new Organization();
noItemsIcon = Security;
protected markingAsCritical = false;
protected applicationSummary: OrganizationReportSummary = createNewSummaryData();
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(
protected cipherService: CipherService,
protected i18nService: I18nService,
protected activatedRoute: ActivatedRoute,
protected toastService: ToastService,
protected configService: ConfigService,
protected dataService: RiskInsightsDataService,
protected organizationService: OrganizationService,
protected reportService: RiskInsightsReportService,
private accountService: AccountService,
protected criticalAppsService: CriticalAppsService,
protected riskInsightsEncryptionService: RiskInsightsEncryptionService,
protected allActivitiesService: AllActivitiesService,
// protected allActivitiesService: AllActivitiesService,
) {
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
.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 () => {
// TODO: implement
this.toastService.showToast({
@@ -167,41 +98,31 @@ export class AllApplicationsComponent implements OnInit {
markAppsAsCritical = async () => {
this.markingAsCritical = true;
try {
await this.criticalAppsService.setCriticalApps(
this.organization.id as OrganizationId,
Array.from(this.selectedUrls),
);
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"),
this.dataService
.saveCriticalApplications(Array.from(this.selectedUrls))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.toastService.showToast({
variant: "success",
title: "",
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) => {
const info = {
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);
await this.dataService.setDrawerForAppAtRiskMembers(applicationName);
};
onCheckboxChange = (applicationName: string, event: Event) => {

View File

@@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
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 { SharedModule } from "@bitwarden/web-vault/app/shared";
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 {
@Input()
dataSource!: TableDataSource<LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher>;
dataSource!: TableDataSource<ApplicationHealthReportDetailEnriched>;
@Input() showRowMenuForCriticalApps: boolean = false;
@Input() showRowCheckBox: boolean = false;
@Input() selectedUrls: Set<string> = new Set<string>();

View File

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

View File

@@ -4,23 +4,15 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms";
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 {
AllActivitiesService,
CriticalAppsService,
ApplicationHealthReportDetailEnriched,
RiskInsightsDataService,
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import {
LEGACY_ApplicationHealthReportDetailWithCriticalFlag,
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
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 { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
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";
@Component({
selector: "tools-critical-applications",
selector: "dirt-critical-applications",
templateUrl: "./critical-applications.component.html",
imports: [
CardComponent,
@@ -51,60 +43,57 @@ import { RiskInsightsTabType } from "./risk-insights.component";
providers: [DefaultAdminTaskService],
})
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);
protected loading = false;
protected enableRequestPasswordChange = false;
protected organizationId: OrganizationId;
protected applicationSummary = {} as OrganizationReportSummary;
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() {
this.organizationId = this.activatedRoute.snapshot.paramMap.get(
"organizationId",
) as OrganizationId;
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.criticalAppsService.loadOrganizationContext(this.organizationId as OrganizationId, userId);
if (this.organizationId) {
combineLatest([
this.dataService.applications$,
this.criticalAppsService.getAppsListForOrg(this.organizationId as OrganizationId),
])
.pipe(
takeUntilDestroyed(this.destroyRef),
map(([applications, criticalApps]) => {
const criticalUrls = criticalApps.map((ca) => ca.uri);
const data = applications?.map((app) => ({
...app,
isMarkedAsCritical: criticalUrls.includes(app.applicationName),
})) as LEGACY_ApplicationHealthReportDetailWithCriticalFlag[];
return data?.filter((app) => app.isMarkedAsCritical);
}),
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);
this.dataService.criticalReportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (criticalReport) => {
this.dataSource.data = criticalReport?.reportData ?? [];
this.applicationSummary = criticalReport?.summaryData ?? createNewSummaryData();
this.enableRequestPasswordChange = criticalReport?.summaryData?.totalAtRiskMemberCount > 0;
},
error: () => {
this.dataSource.data = [];
this.applicationSummary = createNewSummaryData();
this.enableRequestPasswordChange = false;
},
});
this.activatedRoute.paramMap
.pipe(
takeUntilDestroyed(this.destroyRef),
map((params) => params.get("organizationId")),
switchMap(async (orgId) => {
if (orgId) {
this.organizationId = orgId as OrganizationId;
} else {
return EMPTY;
}
});
}
}),
)
.subscribe();
}
goToAllAppsTab = async () => {
@@ -117,26 +106,25 @@ export class CriticalApplicationsComponent implements OnInit {
);
};
unmarkAsCritical = async (hostname: string) => {
try {
await this.criticalAppsService.dropCriticalApp(
this.organizationId as OrganizationId,
hostname,
);
} catch {
this.toastService.showToast({
message: this.i18nService.t("unexpectedError"),
variant: "error",
title: this.i18nService.t("error"),
removeCriticalApplication = async (hostname: string) => {
this.dataService
.removeCriticalApplication(hostname)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.toastService.showToast({
message: this.i18nService.t("criticalApplicationUnmarkedSuccessfully"),
variant: "success",
});
},
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() {
@@ -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) => {
const data = {
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);
await this.dataService.setDrawerForAppAtRiskMembers(applicationName);
};
}

View File

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

View File

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

View File

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