1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-05 11:13:44 +00:00

Risk insight service updates, initial persistence updates

This commit is contained in:
Leslie Tilton
2025-08-13 11:12:53 -05:00
parent fce1f32546
commit 9545197b41
21 changed files with 1252 additions and 1486 deletions

View File

@@ -5,10 +5,10 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import {
PasswordHealthReportApplicationDropRequest,
PasswordHealthReportApplicationId,
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "../models/password-health";
} from "../models/api-models.types";
import { PasswordHealthReportApplicationId } from "../models/report-models";
import { CriticalAppsApiService } from "./critical-apps-api.service";

View File

@@ -7,7 +7,7 @@ import {
PasswordHealthReportApplicationDropRequest,
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "../models/password-health";
} from "../models/api-models.types";
export class CriticalAppsApiService {
constructor(private apiService: ApiService) {}

View File

@@ -13,10 +13,10 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import {
PasswordHealthReportApplicationId,
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "../models/password-health";
} from "../models/api-models.types";
import { PasswordHealthReportApplicationId } from "../models/report-models";
import { CriticalAppsApiService } from "./critical-apps-api.service";
import { CriticalAppsService } from "./critical-apps.service";

View File

@@ -4,12 +4,9 @@ import {
firstValueFrom,
forkJoin,
from,
map,
Observable,
of,
Subject,
switchMap,
takeUntil,
zip,
} from "rxjs";
@@ -22,7 +19,7 @@ import { KeyService } from "@bitwarden/key-management";
import {
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "../models/password-health";
} from "../models/api-models.types";
import { CriticalAppsApiService } from "./critical-apps-api.service";
@@ -30,16 +27,14 @@ import { CriticalAppsApiService } from "./critical-apps-api.service";
* Encrypts and saves data for a given organization
*/
export class CriticalAppsService {
private orgId = new BehaviorSubject<OrganizationId | null>(null);
private criticalAppsList = new BehaviorSubject<PasswordHealthReportApplicationsResponse[]>([]);
private teardown = new Subject<void>();
// The organization ID of the organization the user is currently viewing
private organizationId = new BehaviorSubject<OrganizationId | null>(null);
organizationId$ = this.organizationId.asObservable();
private fetchOrg$ = this.orgId
.pipe(
switchMap((orgId) => this.retrieveCriticalApps(orgId)),
takeUntil(this.teardown),
)
.subscribe((apps) => this.criticalAppsList.next(apps));
private criticalAppsListSubject = new BehaviorSubject<PasswordHealthReportApplicationsResponse[]>(
[],
);
criticalAppsList$ = this.criticalAppsListSubject.asObservable();
constructor(
private keyService: KeyService,
@@ -47,16 +42,23 @@ export class CriticalAppsService {
private criticalAppsApiService: CriticalAppsApiService,
) {}
// Get a list of critical apps for a given organization
getAppsListForOrg(orgId: string): Observable<PasswordHealthReportApplicationsResponse[]> {
return this.criticalAppsList
.asObservable()
.pipe(map((apps) => apps.filter((app) => app.organizationId === orgId)));
async initialize(organizationId: OrganizationId) {
this.organizationId.next(organizationId);
if (organizationId) {
this.retrieveCriticalApps(organizationId).subscribe({
next: (result) => {
this.criticalAppsListSubject.next(result);
},
error: (error: unknown) => {
throw error;
},
});
}
}
// Reset the critical apps list
setAppsInListForOrg(apps: PasswordHealthReportApplicationsResponse[]) {
this.criticalAppsList.next(apps);
this.criticalAppsListSubject.next(apps);
}
// Save the selected critical apps for a given organization
@@ -79,7 +81,7 @@ export class CriticalAppsService {
);
// add the new entries to the criticalAppsList
const updatedList = [...this.criticalAppsList.value];
const updatedList = [...this.criticalAppsListSubject.value];
for (const responseItem of dbResponse) {
const decryptedUrl = await this.encryptService.decryptString(
new EncString(responseItem.uri),
@@ -93,18 +95,19 @@ export class CriticalAppsService {
} as PasswordHealthReportApplicationsResponse);
}
}
this.criticalAppsList.next(updatedList);
this.criticalAppsListSubject.next(updatedList);
}
// Get the critical apps for a given organization
setOrganizationId(orgId: OrganizationId) {
this.orgId.next(orgId);
this.organizationId.next(orgId);
}
// Drop a critical app for a given organization
// Only one app may be dropped at a time
async dropCriticalApp(orgId: OrganizationId, selectedUrl: string) {
const app = this.criticalAppsList.value.find(
const app = this.criticalAppsListSubject.value.find(
(f) => f.organizationId === orgId && f.uri === selectedUrl,
);
@@ -117,7 +120,9 @@ export class CriticalAppsService {
passwordHealthReportApplicationIds: [app.id],
});
this.criticalAppsList.next(this.criticalAppsList.value.filter((f) => f.uri !== selectedUrl));
this.criticalAppsListSubject.next(
this.criticalAppsListSubject.value.filter((f) => f.uri !== selectedUrl),
);
}
private retrieveCriticalApps(
@@ -155,7 +160,7 @@ export class CriticalAppsService {
}
private async filterNewEntries(orgId: OrganizationId, selectedUrls: string[]): Promise<string[]> {
return await firstValueFrom(this.criticalAppsList).then((criticalApps) => {
return await firstValueFrom(this.criticalAppsListSubject).then((criticalApps) => {
const criticalAppsUri = criticalApps
.filter((f) => f.organizationId === orgId)
.map((f) => f.uri);

View File

@@ -9,7 +9,6 @@ import { MemberCipherDetailsApiService } from "./member-cipher-details-api.servi
import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec";
import { PasswordHealthService } from "./password-health.service";
// FIXME: Remove password-health report service after PR-15498 completion
describe("PasswordHealthService", () => {
let service: PasswordHealthService;
beforeEach(() => {
@@ -53,13 +52,4 @@ describe("PasswordHealthService", () => {
it("should be created", () => {
expect(service).toBeTruthy();
});
it("should initialize properties", () => {
expect(service.reportCiphers).toEqual([]);
expect(service.reportCipherIds).toEqual([]);
expect(service.passwordStrengthMap.size).toBe(0);
expect(service.passwordUseMap.size).toBe(0);
expect(service.exposedPasswordMap.size).toBe(0);
expect(service.totalMembersMap.size).toBe(0);
});
});

View File

@@ -1,186 +1,137 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Inject, Injectable } from "@angular/core";
import { filter, from, map, mergeMap, Observable, toArray } from "rxjs";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
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 { BadgeVariant } from "@bitwarden/components";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
import {
ExposedPasswordDetail,
WeakPasswordDetail,
WeakPasswordScore,
} from "../models/password-health";
@Injectable()
export class PasswordHealthService {
reportCiphers: CipherView[] = [];
reportCipherIds: string[] = [];
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
passwordUseMap = new Map<string, number>();
exposedPasswordMap = new Map<string, number>();
totalMembersMap = new Map<string, number>();
constructor(
private passwordStrengthService: PasswordStrengthServiceAbstraction,
private auditService: AuditService,
private cipherService: CipherService,
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
@Inject("organizationId") private organizationId: string,
) {}
async generateReport() {
const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organizationId);
allCiphers.forEach(async (cipher) => {
this.findWeakPassword(cipher);
this.findReusedPassword(cipher);
await this.findExposedPassword(cipher);
});
const memberCipherDetails = await this.memberCipherDetailsApiService.getMemberCipherDetails(
this.organizationId,
/**
* Finds exposed passwords in a list of ciphers.
*
* @param ciphers The list of ciphers to check.
* @returns An observable that emits an array of ExposedPasswordDetail.
*/
auditPasswordLeaks$(ciphers: CipherView[]): Observable<ExposedPasswordDetail[]> {
return from(ciphers).pipe(
filter((cipher) => this.isValidCipher(cipher)),
mergeMap((cipher) =>
this.auditService
.passwordLeaked(cipher.login.password)
.then((exposedCount) => ({ cipher, exposedCount })),
),
filter(({ exposedCount }) => exposedCount > 0),
map(({ cipher, exposedCount }) => ({
exposedXTimes: exposedCount,
cipherId: cipher.id,
})),
toArray(),
);
memberCipherDetails.forEach((user) => {
user.cipherIds.forEach((cipherId: string) => {
if (this.totalMembersMap.has(cipherId)) {
this.totalMembersMap.set(cipherId, (this.totalMembersMap.get(cipherId) || 0) + 1);
} else {
this.totalMembersMap.set(cipherId, 1);
}
});
});
}
async findExposedPassword(cipher: CipherView) {
const { type, login, isDeleted, viewPassword, id } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
!viewPassword
) {
return;
}
/**
* Extracts username parts from the cipher's username.
* This is used to help determine password strength.
*
* @param cipherUsername The username from the cipher.
* @returns An array of username parts.
*/
extractUsernameParts(cipherUsername: string) {
const atPosition = cipherUsername.indexOf("@");
const userNameToProcess =
atPosition > -1 ? cipherUsername.substring(0, atPosition) : cipherUsername;
const exposedCount = await this.auditService.passwordLeaked(login.password);
if (exposedCount > 0) {
this.exposedPasswordMap.set(id, exposedCount);
this.checkForExistingCipher(cipher);
}
return userNameToProcess
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/)
.filter((i) => i.length >= 3);
}
findReusedPassword(cipher: CipherView) {
const { type, login, isDeleted, viewPassword } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
!viewPassword
) {
return;
/**
* Checks if the cipher has a weak password based on the password strength score.
*
* @param cipher
* @returns
*/
findWeakPasswordDetails(cipher: CipherView): WeakPasswordDetail | null {
// Validate the cipher
if (!this.isValidCipher(cipher)) {
return null;
}
if (this.passwordUseMap.has(login.password)) {
this.passwordUseMap.set(login.password, (this.passwordUseMap.get(login.password) || 0) + 1);
} else {
this.passwordUseMap.set(login.password, 1);
}
// Check the username
const userInput = this.isUserNameNotEmpty(cipher)
? this.extractUsernameParts(cipher.login.username)
: null;
this.checkForExistingCipher(cipher);
}
findWeakPassword(cipher: CipherView): void {
const { type, login, isDeleted, viewPassword } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
!viewPassword
) {
return;
}
const hasUserName = this.isUserNameNotEmpty(cipher);
let userInput: string[] = [];
if (hasUserName) {
const atPosition = login.username.indexOf("@");
if (atPosition > -1) {
userInput = userInput
.concat(
login.username
.substring(0, atPosition)
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/),
)
.filter((i) => i.length >= 3);
} else {
userInput = login.username
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/)
.filter((i) => i.length >= 3);
}
}
const { score } = this.passwordStrengthService.getPasswordStrength(
login.password,
cipher.login.password,
null,
userInput.length > 0 ? userInput : null,
userInput,
);
// If a score is not found or a score is less than 3, it's weak
if (score != null && score <= 2) {
this.passwordStrengthMap.set(cipher.id, this.scoreKey(score));
this.checkForExistingCipher(cipher);
return { score: score, detailValue: this.getPasswordScoreInfo(score) };
}
return null;
}
/**
* Gets the password score information based on the score.
*
* @param score
* @returns An object containing the label and badge variant for the password score.
*/
getPasswordScoreInfo(score: number): WeakPasswordScore {
switch (score) {
case 4:
return { label: "strong", badgeVariant: "success" };
case 3:
return { label: "good", badgeVariant: "primary" };
case 2:
return { label: "weak", badgeVariant: "warning" };
default:
return { label: "veryWeak", badgeVariant: "danger" };
}
}
private isUserNameNotEmpty(c: CipherView): boolean {
/**
* Checks if the username on the cipher is not empty.
*/
isUserNameNotEmpty(c: CipherView): boolean {
return !Utils.isNullOrWhitespace(c.login.username);
}
private scoreKey(score: number): [string, BadgeVariant] {
switch (score) {
case 4:
return ["strong", "success"];
case 3:
return ["good", "primary"];
case 2:
return ["weak", "warning"];
default:
return ["veryWeak", "danger"];
/**
* Validates that the cipher is a login item, has a password
* is not deleted, and the user can view the password
* @param c the input cipher
*/
isValidCipher(c: CipherView): boolean {
const { type, login, isDeleted, viewPassword } = c;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
!viewPassword
) {
return false;
}
}
checkForExistingCipher(ciph: CipherView) {
if (!this.reportCipherIds.includes(ciph.id)) {
this.reportCipherIds.push(ciph.id);
this.reportCiphers.push(ciph);
}
}
groupCiphersByLoginUri(): CipherView[] {
const cipherViews: CipherView[] = [];
const cipherUris: string[] = [];
const ciphers = this.reportCiphers;
ciphers.forEach((ciph) => {
const uris = ciph.login?.uris ?? [];
uris.map((u: { uri: string }) => {
const uri = Utils.getHostname(u.uri).replace("www.", "");
cipherUris.push(uri);
cipherViews.push({ ...ciph, hostURI: uri } as CipherView & { hostURI: string });
});
});
return cipherViews;
return true;
}
}

View File

@@ -4,7 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { makeEncString } from "@bitwarden/common/spec";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { SaveRiskInsightsReportRequest } from "../models/password-health";
import { SaveRiskInsightsReportRequest } from "../models/api-models.types";
import { RiskInsightsApiService } from "./risk-insights-api.service";
@@ -47,7 +47,7 @@ describe("RiskInsightsApiService", () => {
it("should call apiService.send with correct parameters for saveRiskInsightsReport", (done) => {
apiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportResponse));
service.saveRiskInsightsReport(saveRiskInsightsReportRequest).subscribe((result) => {
service.saveRiskInsightsReport$(saveRiskInsightsReportRequest).subscribe((result) => {
expect(result).toEqual(saveRiskInsightsReportResponse);
expect(apiService.send).toHaveBeenCalledWith(
"POST",
@@ -63,7 +63,7 @@ describe("RiskInsightsApiService", () => {
it("should call apiService.send with correct parameters and return the response for saveRiskInsightsReport ", (done) => {
apiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportResponse));
service.saveRiskInsightsReport(saveRiskInsightsReportRequest).subscribe((result) => {
service.saveRiskInsightsReport$(saveRiskInsightsReportRequest).subscribe((result) => {
expect(result).toEqual(saveRiskInsightsReportResponse);
expect(apiService.send).toHaveBeenCalledWith(
"POST",
@@ -80,7 +80,7 @@ describe("RiskInsightsApiService", () => {
const error = { statusCode: 500, message: "Internal Server Error" };
apiService.send.mockReturnValue(Promise.reject(error));
service.saveRiskInsightsReport(saveRiskInsightsReportRequest).subscribe({
service.saveRiskInsightsReport$(saveRiskInsightsReportRequest).subscribe({
next: () => {
fail("Expected error to be thrown");
},
@@ -104,7 +104,7 @@ describe("RiskInsightsApiService", () => {
const error = new Error("Network error");
apiService.send.mockReturnValue(Promise.reject(error));
service.saveRiskInsightsReport(saveRiskInsightsReportRequest).subscribe({
service.saveRiskInsightsReport$(saveRiskInsightsReportRequest).subscribe({
next: () => {
fail("Expected error to be thrown");
},
@@ -127,7 +127,7 @@ describe("RiskInsightsApiService", () => {
it("should call apiService.send with correct parameters and return the response for getRiskInsightsReport ", (done) => {
apiService.send.mockReturnValue(Promise.resolve(getRiskInsightsReportResponse));
service.getRiskInsightsReport(orgId).subscribe((result) => {
service.getRiskInsightsReport$(orgId).subscribe((result) => {
expect(result).toEqual(getRiskInsightsReportResponse);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
@@ -144,7 +144,7 @@ describe("RiskInsightsApiService", () => {
const error = { statusCode: 404 };
apiService.send.mockReturnValue(Promise.reject(error));
service.getRiskInsightsReport(orgId).subscribe((result) => {
service.getRiskInsightsReport$(orgId).subscribe((result) => {
expect(result).toBeNull();
done();
});
@@ -154,7 +154,7 @@ describe("RiskInsightsApiService", () => {
const error = { statusCode: 500, message: "Server error" };
apiService.send.mockReturnValue(Promise.reject(error));
service.getRiskInsightsReport(orgId).subscribe({
service.getRiskInsightsReport$(orgId).subscribe({
next: () => {
// Should not reach here
fail("Expected error to be thrown");

View File

@@ -1,3 +1,4 @@
import { Injectable } from "@angular/core";
import { from, Observable } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -7,12 +8,13 @@ import {
GetRiskInsightsReportResponse,
SaveRiskInsightsReportRequest,
SaveRiskInsightsReportResponse,
} from "../models/password-health";
} from "../models/api-models.types";
@Injectable()
export class RiskInsightsApiService {
constructor(private apiService: ApiService) {}
saveRiskInsightsReport(
saveRiskInsightsReport$(
request: SaveRiskInsightsReportRequest,
): Observable<SaveRiskInsightsReportResponse> {
const dbResponse = this.apiService.send(
@@ -26,7 +28,7 @@ export class RiskInsightsApiService {
return from(dbResponse as Promise<SaveRiskInsightsReportResponse>);
}
getRiskInsightsReport(orgId: OrganizationId): Observable<GetRiskInsightsReportResponse | null> {
getRiskInsightsReport$(orgId: OrganizationId): Observable<GetRiskInsightsReportResponse | null> {
const dbResponse = this.apiService
.send("GET", `/reports/organization-reports/latest/${orgId.toString()}`, null, true, true)
.catch((error: any): any => {

View File

@@ -1,127 +1,356 @@
import { BehaviorSubject } from "rxjs";
import { finalize } from "rxjs/operators";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { BehaviorSubject, firstValueFrom, from, Observable, of } from "rxjs";
import {
distinctUntilChanged,
exhaustMap,
filter,
finalize,
map,
switchMap,
tap,
withLatestFrom,
} from "rxjs/operators";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import {
DrawerDetails,
AppAtRiskMembersDialogParams,
ApplicationHealthReportDetail,
AtRiskApplicationDetail,
AtRiskMemberDetail,
DrawerType,
} from "../models/password-health";
ApplicationHealthReportDetailEnriched,
ReportDetailsAndSummary,
} from "../models/report-models";
import { CriticalAppsService } from "./critical-apps.service";
import { RiskInsightsReportService } from "./risk-insights-report.service";
export class RiskInsightsDataService {
private applicationsSubject = new BehaviorSubject<ApplicationHealthReportDetail[] | null>(null);
applications$ = this.applicationsSubject.asObservable();
/**
* Service for managing risk insights data, including applications and critical applications.
* Handles logic for drawer management and data fetching.
*/
export class RiskInsightsDataService {
// Current user viewing risk insights
private userIdSubject = new BehaviorSubject<UserId | null>(null);
userId$ = this.userIdSubject.asObservable();
// Organization the user is currently viewing
private organizationDetailsSubject = new BehaviorSubject<{
organizationId: OrganizationId;
organizationName: string;
} | null>(null);
organizationDetails$ = this.organizationDetailsSubject.asObservable();
private isLoadingSubject = new BehaviorSubject<boolean>(false);
isLoading$ = this.isLoadingSubject.asObservable();
private isRefreshingSubject = new BehaviorSubject<boolean>(false);
isRefreshing$ = this.isRefreshingSubject.asObservable();
criticalApps$ = this.criticalAppsService.criticalAppsList$;
// ------------------------- Drawer Variables ----------------
// Drawer variables
private drawerDetailsSubject = new BehaviorSubject<DrawerDetails>({
open: false,
invokerId: "",
activeDrawerType: DrawerType.None,
atRiskMemberDetails: [],
appAtRiskMembers: null,
atRiskAppDetails: null,
});
drawerDetails$ = this.drawerDetailsSubject.asObservable();
// ------------------------- Report Variables ----------------
// The last run report details
private reportResultsSubject = new BehaviorSubject<ReportDetailsAndSummary | 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
private errorSubject = new BehaviorSubject<string | null>(null);
error$ = this.errorSubject.asObservable();
private dataLastUpdatedSubject = new BehaviorSubject<Date | null>(null);
dataLastUpdated$ = this.dataLastUpdatedSubject.asObservable();
constructor(
private accountService: AccountService,
private criticalAppsService: CriticalAppsService,
private organizationService: OrganizationService,
private reportService: RiskInsightsReportService,
) {}
openDrawer = false;
drawerInvokerId: string = "";
activeDrawerType: DrawerType = DrawerType.None;
atRiskMemberDetails: AtRiskMemberDetail[] = [];
appAtRiskMembers: AppAtRiskMembersDialogParams | null = null;
atRiskAppDetails: AtRiskApplicationDetail[] | null = null;
async initialize(organizationId: OrganizationId) {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
if (userId) {
this.userIdSubject.next(userId);
}
constructor(private reportService: RiskInsightsReportService) {}
// Fetch organization details
const org = await firstValueFrom(
this.organizationService.organizations$(userId).pipe(getOrganizationById(organizationId)),
);
if (org) {
this.organizationDetailsSubject.next({
organizationId: organizationId,
organizationName: org.name,
});
}
// Load critical applications
await this.criticalAppsService.initialize(organizationId);
// Load existing report
this.fetchLastReport(organizationId, userId);
// Setup new report generation
this._runApplicationsReport().subscribe({
next: (result) => {
this.isRunningReportSubject.next(false);
},
error: () => {
this.errorSubject.next("Failed to save report");
},
});
}
filterReportByCritical(
report$: Observable<ReportDetailsAndSummary>,
): Observable<ReportDetailsAndSummary> {
return report$.pipe(
filter((report) => !!report),
map((r) => ({
...r,
data: r.data.filter((application) => application.isMarkedAsCritical),
})),
);
}
generateCriticalDetails$(
report$: Observable<ReportDetailsAndSummary>,
): Observable<ReportDetailsAndSummary> {
return this.filterReportByCritical(report$);
}
enrichWithCriticalMarking$(
applications: ApplicationHealthReportDetail[],
): Observable<ApplicationHealthReportDetailEnriched[]> {
return this.criticalAppsService.criticalAppsList$.pipe(
map((criticalApps) => {
const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.uri));
return applications.map((app) => ({
...app,
isMarkedAsCritical: criticalApplicationNames.has(app.applicationName),
})) as ApplicationHealthReportDetailEnriched[];
}),
);
}
enrichReportData$(
applications: ApplicationHealthReportDetail[],
): Observable<ApplicationHealthReportDetailEnriched[]> {
return of(applications).pipe(
withLatestFrom(this.organizationDetails$, this.criticalAppsService.criticalAppsList$),
switchMap(async ([apps, orgDetails, criticalApps]) => {
// Get ciphers for application
const cipherMap = await this.reportService.getApplicationCipherMap(
apps,
orgDetails.organizationId,
);
// Find critical apps
const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.uri));
// Update application to be enriched type
return apps.map((app) => ({
...app,
ciphers: cipherMap.get(app.applicationName) || [],
isMarkedAsCritical: criticalApplicationNames.has(app.applicationName),
})) as ApplicationHealthReportDetailEnriched[];
}),
);
}
getCriticalReport$(report$: Observable<ReportDetailsAndSummary>) {
const filteredReports$ = this.filterReportByCritical(report$);
return this.generateCriticalDetails$(filteredReports$);
}
/**
* Fetches the applications report and updates the applicationsSubject.
* @param organizationId The ID of the organization.
*/
fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void {
if (isRefresh) {
this.isRefreshingSubject.next(true);
} else {
this.isLoadingSubject.next(true);
}
fetchLastReport(organizationId: OrganizationId, userId: UserId): void {
this.isLoadingSubject.next(true);
this.reportService
.generateApplicationsReport$(organizationId)
.getRiskInsightsReport$(organizationId, userId)
.pipe(
switchMap((report) => {
return this.enrichReportData$(report.data).pipe(
map((enrichedReport) => ({
data: enrichedReport,
summary: report.summary,
})),
);
}),
finalize(() => {
this.isLoadingSubject.next(false);
this.isRefreshingSubject.next(false);
this.dataLastUpdatedSubject.next(new Date());
}),
)
.subscribe({
next: (reports: ApplicationHealthReportDetail[]) => {
this.applicationsSubject.next(reports);
next: ({ data, summary }) => {
this.reportResultsSubject.next({
data,
summary,
dateCreated: new Date(),
});
this.errorSubject.next(null);
this.isLoadingSubject.next(false);
},
error: () => {
this.applicationsSubject.next([]);
this.errorSubject.next("Failed to fetch report");
this.reportResultsSubject.next(null);
this.isLoadingSubject.next(false);
},
});
}
refreshApplicationsReport(organizationId: OrganizationId): void {
this.fetchApplicationsReport(organizationId, true);
/** Trigger generating a report based on the current applications */
triggerReport(): void {
this.isRunningReportSubject.next(true);
}
isActiveDrawerType = (drawerType: DrawerType): boolean => {
return this.activeDrawerType === drawerType;
private _runApplicationsReport() {
return this.isRunningReport$.pipe(
distinctUntilChanged(),
filter((isRunning) => isRunning),
withLatestFrom(this.organizationDetails$, this.userId$),
exhaustMap(([_, { organizationId }, userId]) => {
if (!organizationId || !userId) {
return;
}
// Generate the report
return this.reportService.generateApplicationsReport$(organizationId).pipe(
map((data) => ({
data,
summary: this.reportService.generateApplicationsSummary(data),
})),
switchMap(({ data, summary }) =>
this.enrichReportData$(data).pipe(
map((enrichedData) => ({ data: enrichedData, summary })),
),
),
tap(({ data, summary }) => {
this.reportResultsSubject.next({ data, summary, dateCreated: new Date() });
this.errorSubject.next(null);
}),
switchMap(({ data, summary }) => {
// Just returns ID
return this.reportService.saveReport$(data, summary, { organizationId, userId });
}),
);
}),
);
}
// ------------------------- Drawer functions -----------------------------
isActiveDrawerType$ = (drawerType: DrawerType): Observable<boolean> => {
return this.drawerDetails$.pipe(map((details) => details.activeDrawerType === drawerType));
};
isActiveDrawerType = (drawerType: DrawerType): Observable<boolean> => {
return this.drawerDetails$.pipe(map((details) => details.activeDrawerType === drawerType));
};
isDrawerOpenForInvoker$ = (applicationName: string) => {
return this.drawerDetails$.pipe(map((details) => details.invokerId === applicationName));
};
isDrawerOpenForInvoker = (applicationName: string) => {
return this.drawerDetails$.pipe(map((details) => details.invokerId === applicationName));
};
closeDrawer = (): void => {
this.drawerDetailsSubject.next({
open: false,
invokerId: "",
activeDrawerType: DrawerType.None,
atRiskMemberDetails: [],
appAtRiskMembers: null,
atRiskAppDetails: null,
});
};
setDrawerForOrgAtRiskMembers = (
atRiskMemberDetails: AtRiskMemberDetail[],
invokerId: string = "",
): void => {
this.resetDrawer(DrawerType.OrgAtRiskMembers);
this.activeDrawerType = DrawerType.OrgAtRiskMembers;
this.drawerInvokerId = invokerId;
this.atRiskMemberDetails = atRiskMemberDetails;
this.openDrawer = !this.openDrawer;
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.OrgAtRiskMembers,
atRiskMemberDetails,
appAtRiskMembers: null,
atRiskAppDetails: null,
});
};
setDrawerForAppAtRiskMembers = (
atRiskMembersDialogParams: AppAtRiskMembersDialogParams,
invokerId: string = "",
): void => {
this.resetDrawer(DrawerType.None);
this.activeDrawerType = DrawerType.AppAtRiskMembers;
this.drawerInvokerId = invokerId;
this.appAtRiskMembers = atRiskMembersDialogParams;
this.openDrawer = !this.openDrawer;
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.AppAtRiskMembers,
atRiskMemberDetails: [],
appAtRiskMembers: atRiskMembersDialogParams,
atRiskAppDetails: null,
});
};
setDrawerForOrgAtRiskApps = (
atRiskApps: AtRiskApplicationDetail[],
invokerId: string = "",
): void => {
this.resetDrawer(DrawerType.OrgAtRiskApps);
this.activeDrawerType = DrawerType.OrgAtRiskApps;
this.drawerInvokerId = invokerId;
this.atRiskAppDetails = atRiskApps;
this.openDrawer = !this.openDrawer;
this.drawerDetailsSubject.next({
open: true,
invokerId,
activeDrawerType: DrawerType.OrgAtRiskApps,
atRiskMemberDetails: [],
appAtRiskMembers: null,
atRiskAppDetails: atRiskApps,
});
};
closeDrawer = (): void => {
this.resetDrawer(DrawerType.None);
};
// ------------------------- Critical Application functions -----------------------------
/**
* Calls the critical apps service with the organization and selected applications
*/
saveCriticalApps = (applications: string[]) =>
this.organizationDetails$.pipe(
exhaustMap(({ organizationId }) => {
return from(this.criticalAppsService.setCriticalApps(organizationId, applications));
}),
);
private resetDrawer = (drawerType: DrawerType): void => {
if (this.activeDrawerType !== drawerType) {
this.openDrawer = false;
}
this.activeDrawerType = DrawerType.None;
this.atRiskMemberDetails = [];
this.appAtRiskMembers = null;
this.atRiskAppDetails = null;
this.drawerInvokerId = "";
};
/**
* Removes a specified application from the organization's list of critical applications
*
* @param applicationName
* @returns
*/
dropCriticalApp(applicationName: string) {
return of(applicationName).pipe(
withLatestFrom(this.organizationDetails$),
exhaustMap(async ([hostname, { organizationId }]) => {
const result = await this.criticalAppsService.dropCriticalApp(organizationId, hostname);
return result;
}),
);
}
}

View File

@@ -9,6 +9,9 @@ import { KeyService } from "@bitwarden/key-management";
import { EncryptedDataWithKey } from "../models/password-health";
/**
* Service for encrypting and decrypting risk insights report data.
*/
export class RiskInsightsEncryptionService {
constructor(
private keyService: KeyService,
@@ -16,6 +19,13 @@ export class RiskInsightsEncryptionService {
private keyGeneratorService: KeyGenerationService,
) {}
/**
* Encrypts the risk insights report data for a specific organization.
* @param organizationId The ID of the organization.
* @param userId The ID of the user.
* @param data The data to encrypt.
* @returns A promise that resolves to the encrypted data with the encryption key.
*/
async encryptRiskInsightsReport<T>(
organizationId: OrganizationId,
userId: UserId,
@@ -55,14 +65,23 @@ export class RiskInsightsEncryptionService {
const encryptionKey = wrappedEncryptionKey.encryptedString;
const encryptedDataPacket = {
organizationId: organizationId,
encryptedData: encryptedData,
encryptionKey: encryptionKey,
organizationId,
encryptedData,
contentEncryptionKey: encryptionKey,
};
return encryptedDataPacket;
}
/**
* Decrypts the risk insights report data for a specific organization.
* @param organizationId The ID of the organization.
* @param userId The ID of the user.
* @param encryptedData The encrypted data to decrypt.
* @param wrappedKey The wrapped encryption key.
* @param parser A function to parse the decrypted JSON data.
* @returns A promise that resolves to the decrypted data or null if decryption fails.
*/
async decryptRiskInsightsReport<T>(
organizationId: OrganizationId,
userId: UserId,

View File

@@ -1,52 +1,91 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { ZXCVBNResult } from "zxcvbn";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { GetRiskInsightsReportResponse } from "../models/password-health";
import { ApplicationHealthReportDetail } from "../models/report-models";
import { mockCiphers } from "./ciphers.mock";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec";
import { PasswordHealthService } from "./password-health.service";
import { RiskInsightsApiService } from "./risk-insights-api.service";
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
import { RiskInsightsReportService } from "./risk-insights-report.service";
describe("RiskInsightsReportService", () => {
let service: RiskInsightsReportService;
const pwdStrengthService = mock<PasswordStrengthServiceAbstraction>();
const auditService = mock<AuditService>();
const ENCRYPTED_TEXT = "This data has been encrypted";
const ENCRYPTED_KEY = "Re-encrypted Cipher Key";
const passwordHealthService = mock<PasswordHealthService>();
const cipherService = mock<CipherService>();
const memberCipherDetailsService = mock<MemberCipherDetailsApiService>();
const mockRiskInsightsApiService = mock<RiskInsightsApiService>();
const mockRiskInsightsEncryptionService = mock<RiskInsightsEncryptionService>({
encryptRiskInsightsReport: jest.fn().mockResolvedValue("encryptedReportData"),
decryptRiskInsightsReport: jest.fn().mockResolvedValue("decryptedReportData"),
});
const orgId = "orgId" as OrganizationId;
const mockRiskInsightsEncryptionService = mock<RiskInsightsEncryptionService>();
const mockReportId = "report-id";
const mockOrganizationId = "org-123" as OrganizationId;
const mockUserId = "user-456" as UserId;
const mockEncryptedText = new EncString(ENCRYPTED_TEXT);
const mockEncryptedKey = new EncString(ENCRYPTED_KEY);
const mockReportDate = new Date().toISOString();
beforeEach(() => {
pwdStrengthService.getPasswordStrength.mockImplementation((password: string) => {
const score = password.length < 4 ? 1 : 4;
return { score } as ZXCVBNResult;
// Mock the password health service methods
passwordHealthService.isValidCipher.mockImplementation(
(cipher) =>
cipher.type === 1 && cipher.login?.password && !cipher.isDeleted && cipher.viewPassword,
);
passwordHealthService.findWeakPasswordDetails.mockImplementation((cipher) => {
const score = cipher.login.password.length < 4 ? 1 : 4;
return score <= 2 ? { score, detailValue: { label: "weak", badgeVariant: "warning" } } : null;
});
auditService.passwordLeaked.mockImplementation((password: string) =>
Promise.resolve(password === "123" ? 100 : 0),
passwordHealthService.auditPasswordLeaks$.mockImplementation((ciphers) =>
of(
ciphers
.filter((cipher) => cipher.login.password === "123")
.map((cipher) => ({ cipherId: cipher.id, exposedXTimes: 100 })),
),
);
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers);
memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberCipherDetails);
// Mock encryption/decryption
mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue({
organizationId: mockOrganizationId,
encryptedData: mockEncryptedText.encryptedString,
contentEncryptionKey: mockEncryptedKey.encryptedString,
});
mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockResolvedValue({
data: [],
summary: {
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
},
});
// Mock API calls
mockRiskInsightsApiService.saveRiskInsightsReport$.mockReturnValue(of({ id: mockReportId }));
mockRiskInsightsApiService.getRiskInsightsReport$.mockReturnValue(
of({
id: mockReportId,
organizationId: mockOrganizationId,
date: mockReportDate,
reportData: mockEncryptedText.encryptedString,
contentEncryptionKey: mockEncryptedKey.encryptedString,
}),
);
service = new RiskInsightsReportService(
pwdStrengthService,
auditService,
passwordHealthService,
cipherService,
memberCipherDetailsService,
mockRiskInsightsApiService,
@@ -54,98 +93,36 @@ describe("RiskInsightsReportService", () => {
);
});
it("should generate the raw data report correctly", async () => {
const result = await firstValueFrom(service.generateRawDataReport$(orgId));
expect(result).toHaveLength(6);
let testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001228ab1");
expect(testCaseResults).toHaveLength(1);
let testCase = testCaseResults[0];
expect(testCase).toBeTruthy();
expect(testCase.cipherMembers).toHaveLength(2);
expect(testCase.trimmedUris).toHaveLength(5);
expect(testCase.weakPasswordDetail).toBeTruthy();
expect(testCase.exposedPasswordDetail).toBeTruthy();
expect(testCase.reusedPasswordCount).toEqual(2);
testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001227tt1");
expect(testCaseResults).toHaveLength(1);
testCase = testCaseResults[0];
expect(testCase).toBeTruthy();
expect(testCase.cipherMembers).toHaveLength(1);
expect(testCase.trimmedUris).toHaveLength(1);
expect(testCase.weakPasswordDetail).toBeFalsy();
expect(testCase.exposedPasswordDetail).toBeFalsy();
expect(testCase.reusedPasswordCount).toEqual(1);
});
it("should generate the raw data + uri report correctly", async () => {
const result = await firstValueFrom(service.generateRawDataUriReport$(orgId));
expect(result).toHaveLength(11);
// Two ciphers that have google.com as their uri. There should be 2 results
const googleResults = result.filter((x) => x.trimmedUri === "google.com");
expect(googleResults).toHaveLength(2);
// There is an invalid uri and it should not be trimmed
const invalidUriResults = result.filter((x) => x.trimmedUri === "this_is-not|a-valid-uri123@+");
expect(invalidUriResults).toHaveLength(1);
// Verify the details for one of the googles matches the password health info
// expected
const firstGoogle = googleResults.filter(
(x) => x.cipherId === "cbea34a8-bde4-46ad-9d19-b05001228ab1" && x.trimmedUri === "google.com",
)[0];
expect(firstGoogle.weakPasswordDetail).toBeTruthy();
expect(firstGoogle.exposedPasswordDetail).toBeTruthy();
expect(firstGoogle.reusedPasswordCount).toEqual(2);
});
it("should generate applications health report data correctly", async () => {
const result = await firstValueFrom(service.generateApplicationsReport$(orgId));
const result = await firstValueFrom(service.generateApplicationsReport$(mockOrganizationId));
expect(result).toHaveLength(8);
// Two ciphers have google.com associated with them. The first cipher
// has 2 members and the second has 4. However, the 2 members in the first
// cipher are also associated with the second. The total amount of members
// should be 4 not 6
// Two ciphers have google.com associated with them
const googleTestResults = result.filter((x) => x.applicationName === "google.com");
expect(googleTestResults).toHaveLength(1);
const googleTest = googleTestResults[0];
// Verify member count (should be unique across ciphers)
expect(googleTest.memberCount).toEqual(4);
// Both ciphers have at risk passwords
expect(googleTest.passwordCount).toEqual(2);
// All members are at risk since both ciphers are at risk
expect(googleTest.atRiskMemberDetails).toHaveLength(4);
expect(googleTest.atRiskPasswordCount).toEqual(2);
// There are 2 ciphers associated with 101domain.com
// Test 101domain.com aggregation
const domain101TestResults = result.filter((x) => x.applicationName === "101domain.com");
expect(domain101TestResults).toHaveLength(1);
const domain101Test = domain101TestResults[0];
expect(domain101Test.passwordCount).toEqual(2);
// The first cipher is at risk. The second cipher is not at risk
expect(domain101Test.atRiskPasswordCount).toEqual(1);
// The first cipher has 2 members. The second cipher the second
// cipher has 4. One of the members in the first cipher is associated
// with the second. So there should be 5 members total.
expect(domain101Test.memberCount).toEqual(5);
// The first cipher is at risk. The total at risk members is 2 and
// at risk password count is 1.
expect(domain101Test.atRiskMemberDetails).toHaveLength(2);
expect(domain101Test.atRiskPasswordCount).toEqual(1);
});
it("should generate applications summary data correctly", async () => {
const reportResult = await firstValueFrom(service.generateApplicationsReport$(orgId));
const reportResult = await firstValueFrom(
service.generateApplicationsReport$(mockOrganizationId),
);
const reportSummary = service.generateApplicationsSummary(reportResult);
expect(reportSummary.totalMemberCount).toEqual(7);
@@ -154,190 +131,65 @@ describe("RiskInsightsReportService", () => {
expect(reportSummary.totalAtRiskApplicationCount).toEqual(7);
});
describe("saveRiskInsightsReport", () => {
it("should encrypt and save the report, then update subjects if response has id", async () => {
const organizationId = "orgId" as OrganizationId;
const userId = "userId" as UserId;
const report = [{ applicationName: "app1" }] as any;
const summary = {
totalMemberCount: 1,
totalAtRiskMemberCount: 1,
totalApplicationCount: 1,
totalAtRiskApplicationCount: 1,
};
it("should save report correctly", async () => {
const mockReport: ApplicationHealthReportDetail[] = []; // Your mock application health report
const mockSummary = {
totalMemberCount: 10,
totalAtRiskMemberCount: 5,
totalApplicationCount: 3,
totalAtRiskApplicationCount: 2,
};
const encryptedReport = {
organizationId: organizationId as OrganizationId,
encryptedData: "encryptedData" as EncryptedString,
encryptionKey: "encryptionKey" as EncryptedString,
};
const result = await firstValueFrom(
service.saveReport$(mockReport, mockSummary, {
organizationId: mockOrganizationId,
userId: mockUserId,
}),
);
mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue(
encryptedReport,
);
const saveResponse = { id: "reportId" };
mockRiskInsightsApiService.saveRiskInsightsReport.mockReturnValue(of(saveResponse));
const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next");
const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next");
await service.saveRiskInsightsReport(organizationId, userId, report, summary);
expect(mockRiskInsightsEncryptionService.encryptRiskInsightsReport).toHaveBeenCalledWith(
organizationId,
userId,
{ data: report, summary },
);
expect(mockRiskInsightsApiService.saveRiskInsightsReport).toHaveBeenCalledWith({
data: expect.objectContaining({
organizationId,
date: expect.any(String), // Date should be generated in the service
reportData: encryptedReport.encryptedData,
reportKey: encryptedReport.encryptionKey,
}),
});
expect(reportSubjectSpy).toHaveBeenCalledWith(report);
expect(summarySubjectSpy).toHaveBeenCalledWith(summary);
});
it("should not update subjects if save response does not have id", async () => {
const organizationId = "orgId" as OrganizationId;
const userId = "userId" as UserId;
const report = [{ applicationName: "app1" }] as any;
const summary = {
totalMemberCount: 1,
totalAtRiskMemberCount: 1,
totalApplicationCount: 1,
totalAtRiskApplicationCount: 1,
};
const encryptedReport = {
organizationId: organizationId as OrganizationId,
encryptedData: "encryptedData" as EncryptedString,
encryptionKey: "encryptionKey" as EncryptedString,
};
mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue(
encryptedReport,
);
const saveResponse = { id: "" }; // Simulating no ID in response
mockRiskInsightsApiService.saveRiskInsightsReport.mockReturnValue(of(saveResponse));
const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next");
const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next");
await service.saveRiskInsightsReport(organizationId, userId, report, summary);
expect(reportSubjectSpy).not.toHaveBeenCalled();
expect(summarySubjectSpy).not.toHaveBeenCalled();
});
expect(mockRiskInsightsEncryptionService.encryptRiskInsightsReport).toHaveBeenCalledWith(
mockOrganizationId,
mockUserId,
{ data: mockReport, summary: mockSummary },
);
expect(mockRiskInsightsApiService.saveRiskInsightsReport$).toHaveBeenCalled();
expect(result).toStrictEqual({ id: "report-id" });
});
describe("getRiskInsightsReport", () => {
beforeEach(() => {
// Reset the mocks before each test
jest.clearAllMocks();
});
it("should get risk insights report correctly", async () => {
const result = await firstValueFrom(
service.getRiskInsightsReport$(mockOrganizationId, mockUserId),
);
it("should call riskInsightsApiService.getRiskInsightsReport with the correct organizationId", () => {
// we need to ensure that the api is invoked with the specified organizationId
// here it doesn't matter what the Api returns
const apiResponse = {
id: "reportId",
date: new Date().toISOString(),
organizationId: "orgId",
reportData: "encryptedReportData",
reportKey: "encryptionKey",
} as GetRiskInsightsReportResponse;
const organizationId = "orgId" as OrganizationId;
const userId = "userId" as UserId;
mockRiskInsightsApiService.getRiskInsightsReport.mockReturnValue(of(apiResponse));
service.getRiskInsightsReport(organizationId, userId);
expect(mockRiskInsightsApiService.getRiskInsightsReport).toHaveBeenCalledWith(organizationId);
expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith(
organizationId,
userId,
expect.anything(), // encryptedData
expect.anything(), // wrappedKey
expect.any(Function), // parser
);
});
it("should set empty report and summary if response is falsy", async () => {
// arrange: Api service returns undefined or null
const organizationId = "orgId" as OrganizationId;
const userId = "userId" as UserId;
// Simulate a falsy response from the API (undefined)
mockRiskInsightsApiService.getRiskInsightsReport.mockReturnValue(of(null));
const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next");
const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next");
// act: call the service method
service.getRiskInsightsReport(organizationId, userId);
// wait for the observable to emit and microtasks to complete
await Promise.resolve();
// assert: verify that the report and summary subjects are updated with empty values
expect(reportSubjectSpy).toHaveBeenCalledWith([]);
expect(summarySubjectSpy).toHaveBeenCalledWith({
expect(mockRiskInsightsApiService.getRiskInsightsReport$).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(result).toEqual({
data: [],
summary: {
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
});
},
});
});
it("should decrypt report and update subjects if response is present", async () => {
// Arrange: setup a mock response from the API
// and ensure the decryption service is called with the correct parameters
const organizationId = "orgId" as OrganizationId;
const userId = "userId" as UserId;
it("should handle empty risk insights report response", async () => {
mockRiskInsightsApiService.getRiskInsightsReport$.mockReturnValue(of(null));
const mockResponse = {
id: "reportId",
date: new Date().toISOString(),
organizationId: organizationId as OrganizationId,
reportData: "encryptedReportData",
reportKey: "encryptionKey",
} as GetRiskInsightsReportResponse;
const result = await firstValueFrom(
service.getRiskInsightsReport$(mockOrganizationId, mockUserId),
);
const decryptedReport = {
data: [{ foo: "bar" }],
summary: {
totalMemberCount: 1,
totalAtRiskMemberCount: 1,
totalApplicationCount: 1,
totalAtRiskApplicationCount: 1,
},
};
mockRiskInsightsApiService.getRiskInsightsReport.mockReturnValue(of(mockResponse));
mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockResolvedValue(
decryptedReport,
);
const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next");
const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next");
service.getRiskInsightsReport(organizationId, userId);
// Wait for all microtasks to complete
await Promise.resolve();
expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith(
organizationId,
userId,
expect.anything(),
expect.anything(),
expect.any(Function),
);
expect(reportSubjectSpy).toHaveBeenCalledWith(decryptedReport.data);
expect(summarySubjectSpy).toHaveBeenCalledWith(decryptedReport.summary);
expect(result).toEqual({
data: [],
summary: {
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
},
});
});
});

View File

@@ -1,114 +1,39 @@
// FIXME: Update this file to be type safe
// @ts-strict-ignore
import {
BehaviorSubject,
concatMap,
first,
firstValueFrom,
from,
map,
Observable,
of,
switchMap,
zip,
} from "rxjs";
import { from, map, switchMap, of, Observable, forkJoin } from "rxjs";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
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 { flattenMemberDetails, getTrimmedCipherUris, getUniqueMembers } from "../helpers";
import { SaveRiskInsightsReportResponse } from "../models/api-models.types";
import {
ApplicationHealthReportDetail,
ApplicationHealthReportSummary,
AtRiskMemberDetail,
AtRiskApplicationDetail,
CipherHealthReportDetail,
CipherHealthReportUriDetail,
ExposedPasswordDetail,
MemberDetailsFlat,
WeakPasswordDetail,
WeakPasswordScore,
ApplicationHealthReportDetailWithCriticalFlagAndCipher,
ReportInsightsReportData,
} from "../models/password-health";
CipherHealthReport,
MemberDetails,
ApplicationHealthReportSummary,
RiskInsightsReportData,
PasswordHealthData,
} from "../models/report-models";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
import { PasswordHealthService } from "./password-health.service";
import { RiskInsightsApiService } from "./risk-insights-api.service";
import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service";
export class RiskInsightsReportService {
constructor(
private passwordStrengthService: PasswordStrengthServiceAbstraction,
private auditService: AuditService,
private cipherService: CipherService,
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
private passwordHealthService: PasswordHealthService,
private riskInsightsApiService: RiskInsightsApiService,
private riskInsightsEncryptionService: RiskInsightsEncryptionService,
) {}
private riskInsightsReportSubject = new BehaviorSubject<ApplicationHealthReportDetail[]>([]);
riskInsightsReport$ = this.riskInsightsReportSubject.asObservable();
private riskInsightsSummarySubject = new BehaviorSubject<ApplicationHealthReportSummary>({
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
});
riskInsightsSummary$ = this.riskInsightsSummarySubject.asObservable();
/**
* Report data from raw cipher health data.
* Can be used in the Raw Data diagnostic tab (just exclude the members in the view)
* and can be used in the raw data + members tab when including the members in the view
* @param organizationId
* @returns Cipher health report data with members and trimmed uris
*/
generateRawDataReport$(organizationId: OrganizationId): Observable<CipherHealthReportDetail[]> {
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId));
const memberCiphers$ = from(
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
);
const results$ = zip(allCiphers$, memberCiphers$).pipe(
map(([allCiphers, memberCiphers]) => {
const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
dtl.cipherIds.map((c) =>
this.getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c),
),
);
return [allCiphers, details] as const;
}),
concatMap(([ciphers, flattenedDetails]) => this.getCipherDetails(ciphers, flattenedDetails)),
first(),
);
return results$;
}
/**
* Report data for raw cipher health broken out into the uris
* Can be used in the raw data + members + uri diagnostic report
* @param organizationId Id of the organization
* @returns Cipher health report data flattened to the uris
*/
generateRawDataUriReport$(
organizationId: OrganizationId,
): Observable<CipherHealthReportUriDetail[]> {
const cipherHealthDetails$ = this.generateRawDataReport$(organizationId);
const results$ = cipherHealthDetails$.pipe(
map((healthDetails) => this.getCipherUriDetails(healthDetails)),
first(),
);
return results$;
}
/**
* Report data for the aggregation of uris to like uris and getting password/member counts,
* members, and at risk statuses.
@@ -118,13 +43,124 @@ export class RiskInsightsReportService {
generateApplicationsReport$(
organizationId: OrganizationId,
): Observable<ApplicationHealthReportDetail[]> {
const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId);
const results$ = cipherHealthUriReport$.pipe(
map((uriDetails) => this.getApplicationHealthReport(uriDetails)),
first(),
);
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId));
const memberCiphers$ = from(
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers)));
return results$;
return forkJoin([allCiphers$, memberCiphers$]).pipe(
switchMap(([ciphers, memberCiphers]) => this._getCipherDetails(ciphers, memberCiphers)),
map((cipherApplications) => {
const groupedByApplication = this._groupCiphersByApplication(cipherApplications);
return Array.from(groupedByApplication.entries()).map(([application, ciphers]) =>
this._getApplicationHealthReport(application, ciphers),
);
}),
);
}
/**
* Gets the risk insights report for a specific organization and user.
*
* @param organizationId
* @param userId
* @returns An observable that emits the decrypted risk insights report data.
*/
getRiskInsightsReport$(
organizationId: OrganizationId,
userId: UserId,
): Observable<RiskInsightsReportData> {
return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe(
switchMap((response) => {
if (!response) {
// Return an empty report and summary if response is falsy
return of<RiskInsightsReportData>({
data: [],
summary: {
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
},
});
}
if (!response.contentEncryptionKey || response.contentEncryptionKey == "") {
throw new Error("Report key not found");
}
return from(
this.riskInsightsEncryptionService.decryptRiskInsightsReport<RiskInsightsReportData>(
organizationId,
userId,
new EncString(response.reportData),
new EncString(response.contentEncryptionKey),
(data) => data as RiskInsightsReportData,
),
);
}),
);
}
/**
* Encrypts the risk insights report data for a specific organization.
* @param organizationId The ID of the organization.
* @param userId The ID of the user.
* @param report The report data to encrypt.
* @returns A promise that resolves to an object containing the encrypted data and encryption key.
*/
saveReport$(
report: ApplicationHealthReportDetail[],
summary: ApplicationHealthReportSummary,
encryptionParameters: {
organizationId: OrganizationId;
userId: UserId;
},
): Observable<SaveRiskInsightsReportResponse> {
return from(
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
encryptionParameters.organizationId,
encryptionParameters.userId,
{
data: report,
summary: summary,
},
),
).pipe(
map((encryptedReport) => ({
data: {
organizationId: encryptionParameters.organizationId,
date: new Date().toISOString(),
reportData: encryptedReport.encryptedData,
reportKey: encryptedReport.contentEncryptionKey,
},
})),
switchMap((encryptedReport) =>
this.riskInsightsApiService.saveRiskInsightsReport$(encryptedReport),
),
);
}
/**
* Gets the summary from the application health report. Returns total members and applications as well
* as the total at risk members and at risk applications
* @param reports The previously calculated application health report data
* @returns A summary object containing report totals
*/
generateApplicationsSummary(
reports: ApplicationHealthReportDetail[],
): ApplicationHealthReportSummary {
const totalMembers = reports.flatMap((x) => x.memberDetails);
const uniqueMembers = getUniqueMembers(totalMembers);
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers);
return {
totalMemberCount: uniqueMembers.length,
totalAtRiskMemberCount: uniqueAtRiskMembers.length,
totalApplicationCount: reports.length,
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
};
}
/**
@@ -175,115 +211,132 @@ export class RiskInsightsReportService {
}));
}
/**
* Gets the summary from the application health report. Returns total members and applications as well
* as the total at risk members and at risk applications
* @param reports The previously calculated application health report data
* @returns A summary object containing report totals
*/
generateApplicationsSummary(
reports: ApplicationHealthReportDetail[],
): ApplicationHealthReportSummary {
const totalMembers = reports.flatMap((x) => x.memberDetails);
const uniqueMembers = this.getUniqueMembers(totalMembers);
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
const uniqueAtRiskMembers = this.getUniqueMembers(atRiskMembers);
return {
totalMemberCount: uniqueMembers.length,
totalAtRiskMemberCount: uniqueAtRiskMembers.length,
totalApplicationCount: reports.length,
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
};
}
async identifyCiphers(
data: ApplicationHealthReportDetail[],
async getApplicationCipherMap(
applications: ApplicationHealthReportDetail[],
organizationId: OrganizationId,
): Promise<ApplicationHealthReportDetailWithCriticalFlagAndCipher[]> {
const cipherViews = await this.cipherService.getAllFromApiForOrganization(organizationId);
): Promise<Map<string, CipherView[]>> {
const allCiphers = await this.cipherService.getAllFromApiForOrganization(organizationId);
const cipherMap = new Map<string, CipherView[]>();
const dataWithCiphers = data.map(
(app, index) =>
({
...app,
ciphers: cipherViews.filter((c) => app.cipherIds.some((a) => a === c.id)),
}) as ApplicationHealthReportDetailWithCriticalFlagAndCipher,
);
return dataWithCiphers;
applications.forEach((app) => {
const filteredCiphers = allCiphers.filter((c) => app.cipherIds.includes(c.id));
cipherMap.set(app.applicationName, filteredCiphers);
});
return cipherMap;
}
getRiskInsightsReport(organizationId: OrganizationId, userId: UserId): void {
this.riskInsightsApiService
.getRiskInsightsReport(organizationId)
.pipe(
switchMap((response) => {
if (!response) {
// Return an empty report and summary if response is falsy
return of<ReportInsightsReportData>({
data: [],
summary: {
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
},
});
}
return from(
this.riskInsightsEncryptionService.decryptRiskInsightsReport<ReportInsightsReportData>(
organizationId,
userId,
new EncString(response.reportData),
new EncString(response.reportKey),
(data) => data as ReportInsightsReportData,
),
);
}),
)
.subscribe({
next: (decryptRiskInsightsReport) => {
this.riskInsightsReportSubject.next(decryptRiskInsightsReport.data);
this.riskInsightsSummarySubject.next(decryptRiskInsightsReport.summary);
},
private _buildPasswordUseMap(ciphers: CipherView[]): Map<string, number> {
const passwordUseMap = new Map<string, number>();
ciphers.forEach((cipher) => {
const password = cipher.login.password;
passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1);
});
return passwordUseMap;
}
private _groupCiphersByApplication(
cipherHealthData: CipherHealthReport[],
): Map<string, CipherHealthReport[]> {
const applicationMap = new Map<string, CipherHealthReport[]>();
cipherHealthData.forEach((cipher: CipherHealthReport) => {
cipher.applications.forEach((application) => {
const existingApplication = applicationMap.get(application) || [];
existingApplication.push(cipher);
applicationMap.set(application, existingApplication);
});
});
return applicationMap;
}
async saveRiskInsightsReport(
organizationId: OrganizationId,
userId: UserId,
report: ApplicationHealthReportDetail[],
summary: ApplicationHealthReportSummary,
): Promise<void> {
const reportWithSummary = {
data: report,
summary: summary,
};
/**
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
* If the item is new, create and add the object with the flattened details
* @param cipherHealthReport Cipher and password health info broken out into their uris
* @returns Application health reports
*/
private _getApplicationHealthReport(
application: string,
ciphers: CipherHealthReport[],
): ApplicationHealthReportDetail {
let aggregatedReport: ApplicationHealthReportDetail | undefined;
const encryptedReport = await this.riskInsightsEncryptionService.encryptRiskInsightsReport(
organizationId,
userId,
reportWithSummary,
);
ciphers.forEach((cipher) => {
const isAtRisk = this._isPasswordAtRisk(cipher.healthData);
aggregatedReport = this._aggregateReport(application, cipher, isAtRisk, aggregatedReport);
});
const saveRequest = {
data: {
organizationId: organizationId,
date: new Date().toISOString(),
reportData: encryptedReport.encryptedData,
reportKey: encryptedReport.encryptionKey,
},
};
return aggregatedReport!;
}
const response = await firstValueFrom(
this.riskInsightsApiService.saveRiskInsightsReport(saveRequest),
);
if (response && response.id) {
this.riskInsightsReportSubject.next(report);
this.riskInsightsSummarySubject.next(summary);
private _aggregateReport(
application: string,
newCipherReport: CipherHealthReport,
isAtRisk: boolean,
existingReport?: ApplicationHealthReportDetail,
): ApplicationHealthReportDetail {
let baseReport = existingReport
? this._updateExistingReport(existingReport, newCipherReport)
: this._createNewReport(application, newCipherReport);
if (isAtRisk) {
baseReport = { ...baseReport, ...this._getAtRiskData(baseReport, newCipherReport) };
}
baseReport.memberCount = baseReport.memberDetails.length;
baseReport.atRiskMemberCount = baseReport.atRiskMemberDetails.length;
return baseReport;
}
private _createNewReport(
application: string,
cipherReport: CipherHealthReport,
): ApplicationHealthReportDetail {
return {
applicationName: application,
cipherIds: [cipherReport.cipher.id],
passwordCount: 1,
memberDetails: [...cipherReport.cipherMembers],
memberCount: cipherReport.cipherMembers.length,
atRiskCipherIds: [],
atRiskMemberCount: 0,
atRiskMemberDetails: [],
atRiskPasswordCount: 0,
};
}
private _updateExistingReport(
existingReport: ApplicationHealthReportDetail,
newCipherReport: CipherHealthReport,
): ApplicationHealthReportDetail {
return {
...existingReport,
passwordCount: existingReport.passwordCount + 1,
memberDetails: getUniqueMembers(
existingReport.memberDetails.concat(newCipherReport.cipherMembers),
),
cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id),
};
}
private _getAtRiskData(report: ApplicationHealthReportDetail, cipherReport: CipherHealthReport) {
const atRiskMemberDetails = getUniqueMembers(
report.atRiskMemberDetails.concat(cipherReport.cipherMembers),
);
return {
atRiskPasswordCount: report.atRiskPasswordCount + 1,
atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id),
atRiskMemberDetails,
atRiskMemberCount: atRiskMemberDetails.length,
};
}
private _isPasswordAtRisk(healthData: PasswordHealthData): boolean {
return !!(
healthData.exposedPasswordDetail ||
healthData.weakPasswordDetail ||
healthData.reusedPasswordCount > 1
);
}
/**
@@ -293,302 +346,35 @@ export class RiskInsightsReportService {
* @param memberDetails Org members
* @returns Cipher password health data with trimmed uris and associated members
*/
private async getCipherDetails(
private _getCipherDetails(
ciphers: CipherView[],
memberDetails: MemberDetailsFlat[],
): Promise<CipherHealthReportDetail[]> {
const cipherHealthReports: CipherHealthReportDetail[] = [];
const passwordUseMap = new Map<string, number>();
const exposedDetails = await this.findExposedPasswords(ciphers);
for (const cipher of ciphers) {
if (this.validateCipher(cipher)) {
const weakPassword = this.findWeakPassword(cipher);
// Looping over all ciphers needs to happen first to determine reused passwords over all ciphers.
// Store in the set and evaluate later
if (passwordUseMap.has(cipher.login.password)) {
passwordUseMap.set(
cipher.login.password,
(passwordUseMap.get(cipher.login.password) || 0) + 1,
);
} else {
passwordUseMap.set(cipher.login.password, 1);
}
const exposedPassword = exposedDetails.find((x) => x.cipherId === cipher.id);
// Get the cipher members
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
// Trim uris to host name and create the cipher health report
const cipherTrimmedUris = this.getTrimmedCipherUris(cipher);
const cipherHealth = {
...cipher,
weakPasswordDetail: weakPassword,
exposedPasswordDetail: exposedPassword,
cipherMembers: cipherMembers,
trimmedUris: cipherTrimmedUris,
} as CipherHealthReportDetail;
cipherHealthReports.push(cipherHealth);
}
}
// loop for reused passwords
cipherHealthReports.forEach((detail) => {
detail.reusedPasswordCount = passwordUseMap.get(detail.login.password) ?? 0;
});
return cipherHealthReports;
}
/**
* Flattens the cipher to trimmed uris. Used for the raw data + uri
* @param cipherHealthReport Cipher health report with uris and members
* @returns Flattened cipher health details to uri
*/
private getCipherUriDetails(
cipherHealthReport: CipherHealthReportDetail[],
): CipherHealthReportUriDetail[] {
return cipherHealthReport.flatMap((rpt) =>
rpt.trimmedUris.map((u) => this.getFlattenedCipherDetails(rpt, u)),
memberDetails: MemberDetails[],
): Observable<CipherHealthReport[]> {
const validCiphers = ciphers.filter((cipher) =>
this.passwordHealthService.isValidCipher(cipher),
);
}
// Build password use map
const passwordUseMap = this._buildPasswordUseMap(validCiphers);
/**
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
* If the item is new, create and add the object with the flattened details
* @param cipherHealthUriReport Cipher and password health info broken out into their uris
* @returns Application health reports
*/
private getApplicationHealthReport(
cipherHealthUriReport: CipherHealthReportUriDetail[],
): ApplicationHealthReportDetail[] {
const appReports: ApplicationHealthReportDetail[] = [];
cipherHealthUriReport.forEach((uri) => {
const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri);
return this.passwordHealthService.auditPasswordLeaks$(validCiphers).pipe(
map((exposedDetails) => {
return validCiphers.map((cipher) => {
const exposedPassword = exposedDetails.find((x) => x.cipherId === cipher.id);
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
let atRisk: boolean = false;
if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) {
atRisk = true;
}
if (index === -1) {
appReports.push(this.getApplicationReportDetail(uri, atRisk));
} else {
appReports[index] = this.getApplicationReportDetail(uri, atRisk, appReports[index]);
}
});
return appReports;
}
private async findExposedPasswords(ciphers: CipherView[]): Promise<ExposedPasswordDetail[]> {
const exposedDetails: ExposedPasswordDetail[] = [];
const promises: Promise<void>[] = [];
ciphers.forEach((ciph) => {
if (this.validateCipher(ciph)) {
const promise = this.auditService
.passwordLeaked(ciph.login.password)
.then((exposedCount) => {
if (exposedCount > 0) {
const detail = {
exposedXTimes: exposedCount,
cipherId: ciph.id,
} as ExposedPasswordDetail;
exposedDetails.push(detail);
}
});
promises.push(promise);
}
});
await Promise.all(promises);
return exposedDetails;
}
private findWeakPassword(cipher: CipherView): WeakPasswordDetail {
const hasUserName = this.isUserNameNotEmpty(cipher);
let userInput: string[] = [];
if (hasUserName) {
const atPosition = cipher.login.username.indexOf("@");
if (atPosition > -1) {
userInput = userInput
.concat(
cipher.login.username
.substring(0, atPosition)
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/),
)
.filter((i) => i.length >= 3);
} else {
userInput = cipher.login.username
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/)
.filter((i) => i.length >= 3);
}
}
const { score } = this.passwordStrengthService.getPasswordStrength(
cipher.login.password,
null,
userInput.length > 0 ? userInput : null,
const result = {
cipher: cipher,
cipherMembers,
healthData: {
weakPasswordDetail: this.passwordHealthService.findWeakPasswordDetails(cipher),
exposedPasswordDetail: exposedPassword,
reusedPasswordCount: passwordUseMap.get(cipher.login.password) ?? 0,
},
applications: getTrimmedCipherUris(cipher),
} as CipherHealthReport;
return result;
});
}),
);
if (score != null && score <= 2) {
const scoreValue = this.weakPasswordScore(score);
const weakPasswordDetail = { score: score, detailValue: scoreValue } as WeakPasswordDetail;
return weakPasswordDetail;
}
return null;
}
private weakPasswordScore(score: number): WeakPasswordScore {
switch (score) {
case 4:
return { label: "strong", badgeVariant: "success" };
case 3:
return { label: "good", badgeVariant: "primary" };
case 2:
return { label: "weak", badgeVariant: "warning" };
default:
return { label: "veryWeak", badgeVariant: "danger" };
}
}
/**
* Create the new application health report detail object with the details from the cipher health report uri detail object
* update or create the at risk values if the item is at risk.
* @param newUriDetail New cipher uri detail
* @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk
* @param existingUriDetail The previously processed Uri item
* @returns The new or updated application health report detail
*/
private getApplicationReportDetail(
newUriDetail: CipherHealthReportUriDetail,
isAtRisk: boolean,
existingUriDetail?: ApplicationHealthReportDetail,
): ApplicationHealthReportDetail {
const reportDetail = {
applicationName: existingUriDetail
? existingUriDetail.applicationName
: newUriDetail.trimmedUri,
passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1,
memberDetails: existingUriDetail
? this.getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
: newUriDetail.cipherMembers,
atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [],
atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0,
atRiskCipherIds: existingUriDetail ? existingUriDetail.atRiskCipherIds : [],
atRiskMemberCount: existingUriDetail ? existingUriDetail.atRiskMemberDetails.length : 0,
cipherIds: existingUriDetail
? existingUriDetail.cipherIds.concat(newUriDetail.cipherId)
: [newUriDetail.cipherId],
} as ApplicationHealthReportDetail;
if (isAtRisk) {
reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1;
reportDetail.atRiskCipherIds.push(newUriDetail.cipherId);
reportDetail.atRiskMemberDetails = this.getUniqueMembers(
reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers),
);
reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length;
}
reportDetail.memberCount = reportDetail.memberDetails.length;
return reportDetail;
}
/**
* Get a distinct array of members from a combined list. Input list may contain
* duplicate members.
* @param orgMembers Input list of members
* @returns Distinct array of members
*/
private getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] {
const existingEmails = new Set<string>();
const distinctUsers = orgMembers.filter((member) => {
if (existingEmails.has(member.email)) {
return false;
}
existingEmails.add(member.email);
return true;
});
return distinctUsers;
}
private getFlattenedCipherDetails(
detail: CipherHealthReportDetail,
uri: string,
): CipherHealthReportUriDetail {
return {
cipherId: detail.id,
reusedPasswordCount: detail.reusedPasswordCount,
weakPasswordDetail: detail.weakPasswordDetail,
exposedPasswordDetail: detail.exposedPasswordDetail,
cipherMembers: detail.cipherMembers,
trimmedUri: uri,
cipher: detail as CipherView,
};
}
private getMemberDetailsFlat(
userGuid: string,
userName: string,
email: string,
cipherId: string,
): MemberDetailsFlat {
return {
userGuid: userGuid,
userName: userName,
email: email,
cipherId: cipherId,
};
}
/**
* Trim the cipher uris down to get the password health application.
* The uri should only exist once after being trimmed. No duplication.
* Example:
* - Untrimmed Uris: https://gmail.com, gmail.com/login
* - Both would trim to gmail.com
* - The cipher trimmed uri list should only return on instance in the list
* @param cipher
* @returns distinct list of trimmed cipher uris
*/
private getTrimmedCipherUris(cipher: CipherView): string[] {
const cipherUris: string[] = [];
const uris = cipher.login?.uris ?? [];
uris.map((u: { uri: string }) => {
const uri = Utils.getDomain(u.uri) ?? u.uri;
if (!cipherUris.includes(uri)) {
cipherUris.push(uri);
}
});
return cipherUris;
}
private isUserNameNotEmpty(c: CipherView): boolean {
return !Utils.isNullOrWhitespace(c.login.username);
}
/**
* Validates that the cipher is a login item, has a password
* is not deleted, and the user can view the password
* @param c the input cipher
*/
private validateCipher(c: CipherView): boolean {
const { type, login, isDeleted, viewPassword } = c;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
!viewPassword
) {
return false;
}
return true;
}
}

View File

@@ -1,16 +1,20 @@
import { NgModule } from "@angular/core";
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { CriticalAppsService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import {
CriticalAppsApiService,
MemberCipherDetailsApiService,
PasswordHealthService,
RiskInsightsDataService,
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
import { CriticalAppsService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/critical-apps.service";
import { RiskInsightsApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-api.service";
import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction";
@@ -27,24 +31,6 @@ import { RiskInsightsComponent } from "./risk-insights.component";
provide: MemberCipherDetailsApiService,
deps: [ApiService],
},
{
provide: RiskInsightsReportService,
deps: [
PasswordStrengthServiceAbstraction,
AuditService,
CipherService,
MemberCipherDetailsApiService,
],
},
{
provide: RiskInsightsDataService,
deps: [RiskInsightsReportService],
},
{
provide: RiskInsightsEncryptionService,
useClass: RiskInsightsEncryptionService,
deps: [KeyService, EncryptService, KeyGenerationService],
},
safeProvider({
provide: CriticalAppsService,
useClass: CriticalAppsService,
@@ -55,6 +41,29 @@ import { RiskInsightsComponent } from "./risk-insights.component";
useClass: CriticalAppsApiService,
deps: [ApiService],
}),
{ provide: PasswordHealthService, deps: [PasswordStrengthServiceAbstraction, AuditService] },
{
provide: RiskInsightsApiService,
},
{
provide: RiskInsightsReportService,
deps: [
CipherService,
MemberCipherDetailsApiService,
PasswordHealthService,
RiskInsightsApiService,
RiskInsightsEncryptionService,
],
},
{
provide: RiskInsightsDataService,
deps: [AccountService, CriticalAppsService, OrganizationService, RiskInsightsReportService],
},
{
provide: RiskInsightsEncryptionService,
useClass: RiskInsightsEncryptionService,
deps: [KeyService, EncryptService, KeyGenerationService],
},
],
})
export class AccessIntelligenceModule {}

View File

@@ -1,80 +1,88 @@
<div *ngIf="isLoading$ | async">
@if (dataService.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>
} @else {
@if (dataService.drawerDetails$ | async; as drawerDetails) {
<div class="tw-mt-4" *ngIf="!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: (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>
<div class="tw-mt-4 tw-flex tw-flex-col" *ngIf="dataSource.data.length">
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
<div class="tw-flex tw-gap-6">
<dirt-card
#allAppsOrgAtRiskMembers
class="tw-flex-1 tw-cursor-pointer"
[ngClass]="{
'tw-bg-primary-100': drawerDetails.invokerId === 'allAppsOrgAtRiskMembers',
}"
[title]="'atRiskMembers' | i18n"
[value]="applicationSummary.totalAtRiskMemberCount"
[maxValue]="applicationSummary.totalMemberCount"
(click)="showOrgAtRiskMembers('allAppsOrgAtRiskMembers')"
>
</dirt-card>
<dirt-card
#allAppsOrgAtRiskApplications
class="tw-flex-1 tw-cursor-pointer"
[ngClass]="{
'tw-bg-primary-100': drawerDetails.invokerId === 'allAppsOrgAtRiskApplications',
}"
[title]="'atRiskApplications' | i18n"
[value]="applicationSummary.totalAtRiskApplicationCount"
[maxValue]="applicationSummary.totalApplicationCount"
(click)="showOrgAtRiskApps('allAppsOrgAtRiskApplications')"
>
</dirt-card>
</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>
</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>
<div class="tw-flex tw-gap-6">
<dirt-card
#allAppsOrgAtRiskMembers
class="tw-flex-1 tw-cursor-pointer"
[ngClass]="{ 'tw-bg-primary-100': dataService.drawerInvokerId === 'allAppsOrgAtRiskMembers' }"
[title]="'atRiskMembers' | i18n"
[value]="applicationSummary.totalAtRiskMemberCount"
[maxValue]="applicationSummary.totalMemberCount"
(click)="showOrgAtRiskMembers('allAppsOrgAtRiskMembers')"
>
</dirt-card>
<dirt-card
#allAppsOrgAtRiskApplications
class="tw-flex-1 tw-cursor-pointer"
[ngClass]="{
'tw-bg-primary-100': dataService.drawerInvokerId === 'allAppsOrgAtRiskApplications',
}"
[title]="'atRiskApplications' | i18n"
[value]="applicationSummary.totalAtRiskApplicationCount"
[maxValue]="applicationSummary.totalApplicationCount"
(click)="showOrgAtRiskApps('allAppsOrgAtRiskApplications')"
>
</dirt-card>
</div>
<div class="tw-flex tw-mt-8 tw-mb-4 tw-gap-4">
<bit-search
[placeholder]="'searchApps' | i18n"
class="tw-grow"
[formControl]="searchControl"
></bit-search>
<button
type="button"
[buttonType]="'primary'"
bitButton
[disabled]="!selectedUrls.size"
[loading]="markingAsCritical"
(click)="markAppsAsCritical()"
>
<i class="bwi tw-mr-2" [ngClass]="selectedUrls.size ? 'bwi-star-f' : 'bwi-star'"></i>
{{ "markAppAsCritical" | i18n }}
</button>
</div>
<app-table-row-scrollable
[dataSource]="dataSource"
[showRowCheckBox]="true"
[showRowMenuForCriticalApps]="false"
[selectedUrls]="selectedUrls"
[isDrawerIsOpenForThisRecord]="isDrawerOpenForTableRow"
[checkboxChange]="onCheckboxChange"
[showAppAtRiskMembers]="showAppAtRiskMembers"
></app-table-row-scrollable>
</div>
<app-table-row-scrollable
[dataSource]="dataSource"
[showRowCheckBox]="true"
[showRowMenuForCriticalApps]="false"
[selectedUrls]="selectedUrls"
[openApplication]="drawerDetails.invokerId"
[checkboxChange]="onCheckboxChange"
[showAppAtRiskMembers]="showAppAtRiskMembers"
></app-table-row-scrollable>
</div>
}
}

View File

@@ -2,7 +2,7 @@ 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 { catchError, debounceTime, exhaustMap, finalize, of, tap } from "rxjs";
import {
CriticalAppsService,
@@ -10,22 +10,13 @@ import {
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import {
ApplicationHealthReportDetail,
ApplicationHealthReportDetailWithCriticalFlag,
ApplicationHealthReportDetailWithCriticalFlagAndCipher,
ApplicationHealthReportDetailEnriched,
ApplicationHealthReportSummary,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
} 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 { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
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,
@@ -59,12 +50,9 @@ import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"
],
})
export class AllApplicationsComponent implements OnInit {
protected dataSource =
new TableDataSource<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 = Icons.Security;
protected markingAsCritical = false;
protected applicationSummary: ApplicationHealthReportSummary = {
@@ -75,65 +63,6 @@ export class AllApplicationsComponent implements OnInit {
};
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),
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 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);
}
if (organization) {
this.organization = organization;
}
});
this.isLoading$ = this.dataService.isLoading$;
}
}
constructor(
protected cipherService: CipherService,
@@ -144,7 +73,6 @@ export class AllApplicationsComponent implements OnInit {
protected dataService: RiskInsightsDataService,
protected organizationService: OrganizationService,
protected reportService: RiskInsightsReportService,
private accountService: AccountService,
protected criticalAppsService: CriticalAppsService,
protected riskInsightsEncryptionService: RiskInsightsEncryptionService,
) {
@@ -153,6 +81,21 @@ export class AllApplicationsComponent implements OnInit {
.subscribe((v) => (this.dataSource.filter = v));
}
async ngOnInit() {
const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId");
if (organizationId) {
this.dataService.reportResults$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((report) => {
if (report) {
this.dataSource.data = report.data;
// this.applicationSummary = this.reportService.generateApplicationsSummary(report.data);
}
});
}
}
goToCreateNewLoginItem = async () => {
// TODO: implement
this.toastService.showToast({
@@ -162,34 +105,37 @@ export class AllApplicationsComponent implements OnInit {
});
};
isMarkedAsCriticalItem(applicationName: string) {
return this.selectedUrls.has(applicationName);
}
markAppsAsCritical = async () => {
this.markingAsCritical = true;
try {
await this.criticalAppsService.setCriticalApps(
this.organization.id,
Array.from(this.selectedUrls),
);
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"),
});
} finally {
this.selectedUrls.clear();
this.markingAsCritical = false;
}
markAppsAsCritical = () => {
of(Array.from(this.selectedUrls))
.pipe(
takeUntilDestroyed(this.destroyRef),
exhaustMap((urls) =>
this.dataService.saveCriticalApps(urls).pipe(
catchError((error: unknown) => {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("applicationsMarkedAsCriticalFail"),
});
throw error;
}),
),
),
tap(() => {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"),
});
}),
finalize(() => {
this.selectedUrls.clear();
this.markingAsCritical = false;
}),
)
.subscribe();
};
trackByFunction(_: number, item: ApplicationHealthReportDetail) {
return item.applicationName;
}
showAppAtRiskMembers = async (applicationName: string) => {
const info = {
members:
@@ -218,10 +164,4 @@ export class AllApplicationsComponent implements OnInit {
this.selectedUrls.delete(applicationName);
}
};
getSelectedUrls = () => Array.from(this.selectedUrls);
isDrawerOpenForTableRow = (applicationName: string): boolean => {
return this.dataService.drawerInvokerId === applicationName;
};
}

View File

@@ -13,7 +13,7 @@
<td
bitCell
*ngIf="showRowCheckBox"
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
>
<input
bitCheckbox
@@ -27,7 +27,7 @@
<td
bitCell
*ngIf="!showRowCheckBox"
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
>
<i class="bwi bwi-star-f" *ngIf="row.isMarkedAsCritical"></i>
</td>
@@ -36,33 +36,24 @@
</td>
<td
class="tw-cursor-pointer"
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
(click)="showAppAtRiskMembers(row.applicationName)"
(keypress)="showAppAtRiskMembers(row.applicationName)"
bitCell
>
<span>{{ row.applicationName }}</span>
</td>
<td
bitCell
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
>
<td bitCell [ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }">
<span>
{{ row.atRiskPasswordCount }}
</span>
</td>
<td
bitCell
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
>
<td bitCell [ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }">
<span>
{{ row.passwordCount }}
</span>
</td>
<td
bitCell
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
>
<td bitCell [ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }">
<span>
{{ row.atRiskMemberCount }}
</span>
@@ -70,14 +61,14 @@
<td
bitCell
data-testid="total-membership"
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
>
{{ row.memberCount }}
</td>
<td
bitCell
*ngIf="showRowMenuForCriticalApps"
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
>
<button
[bitMenuTriggerFor]="rowMenu"

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 { ApplicationHealthReportDetailWithCriticalFlagAndCipher } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { ApplicationHealthReportDetailEnriched } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
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";
@@ -13,11 +13,11 @@ import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pip
templateUrl: "./app-table-row-scrollable.component.html",
})
export class AppTableRowScrollableComponent {
@Input() dataSource!: TableDataSource<ApplicationHealthReportDetailWithCriticalFlagAndCipher>;
@Input() dataSource!: TableDataSource<ApplicationHealthReportDetailEnriched>;
@Input() showRowMenuForCriticalApps: boolean = false;
@Input() showRowCheckBox: boolean = false;
@Input() selectedUrls: Set<string> = new Set<string>();
@Input() isDrawerIsOpenForThisRecord!: (applicationName: string) => boolean;
@Input() openApplication: string = "";
@Input() showAppAtRiskMembers!: (applicationName: string) => void;
@Input() unmarkAsCriticalApp!: (applicationName: string) => void;
@Input() checkboxChange!: (applicationName: string, $event: Event) => void;

View File

@@ -1,4 +1,4 @@
<div *ngIf="loading">
<div *ngIf="dataService.isLoading$ | async">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
@@ -6,26 +6,31 @@
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div class="tw-mt-4" *ngIf="!dataSource.data.length">
<bit-no-items [icon]="noItemsIcon" class="tw-text-main">
<ng-container slot="title">
<h2 class="tw-font-semibold tw-mt-4">
{{ "noCriticalAppsTitle" | i18n }}
</h2>
</ng-container>
<ng-container slot="description">
<p class="tw-text-muted">
{{ "noCriticalAppsDescription" | i18n }}
</p>
</ng-container>
<ng-container slot="button">
<button (click)="goToAllAppsTab()" bitButton buttonType="primary" type="button">
{{ "markCriticalApps" | i18n }}
</button>
</ng-container>
</bit-no-items>
</div>
<div class="tw-mt-4 tw-flex tw-flex-col" *ngIf="!loading && dataSource.data.length">
@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">
{{ "noCriticalAppsTitle" | i18n }}
</h2>
</ng-container>
<ng-container slot="description">
<p class="tw-text-muted">
{{ "noCriticalAppsDescription" | i18n }}
</p>
</ng-container>
<ng-container slot="button">
<button (click)="goToAllAppsTab()" bitButton buttonType="primary" type="button">
{{ "markCriticalApps" | i18n }}
</button>
</ng-container>
</bit-no-items>
</div>
}
<div
class="tw-mt-4 tw-flex tw-flex-col"
*ngIf="!(dataService.isLoading$ | async) && dataSource.data.length"
>
<div class="tw-flex tw-justify-between tw-mb-4">
<h2 bitTypography="h2">{{ "criticalApplications" | i18n }}</h2>
<button
@@ -43,32 +48,36 @@
}}
</button>
</div>
<div class="tw-flex tw-gap-6">
<dirt-card
#criticalAppsAtRiskMembers
class="tw-flex-1 tw-cursor-pointer"
[ngClass]="{
'tw-bg-primary-100': dataService.drawerInvokerId === 'criticalAppsAtRiskMembers',
}"
[title]="'atRiskMembers' | i18n"
[value]="applicationSummary.totalAtRiskMemberCount"
[maxValue]="applicationSummary.totalMemberCount"
(click)="showOrgAtRiskMembers('criticalAppsAtRiskMembers')"
>
</dirt-card>
<dirt-card
#criticalAppsAtRiskApplications
class="tw-flex-1 tw-cursor-pointer"
[ngClass]="{
'tw-bg-primary-100': dataService.drawerInvokerId === 'criticalAppsAtRiskApplications',
}"
[title]="'atRiskApplications' | i18n"
[value]="applicationSummary.totalAtRiskApplicationCount"
[maxValue]="applicationSummary.totalApplicationCount"
(click)="showOrgAtRiskApps('criticalAppsAtRiskApplications')"
>
</dirt-card>
</div>
@if (summary) {
<div class="tw-flex tw-gap-6">
<dirt-card
#criticalAppsAtRiskMembers
class="tw-flex-1 tw-cursor-pointer"
[ngClass]="{
'tw-bg-primary-100': dataService.isDrawerOpenForInvoker$('criticalAppsAtRiskMembers'),
}"
[title]="'atRiskMembers' | i18n"
[value]="summary.totalAtRiskMemberCount"
[maxValue]="summary.totalMemberCount"
(click)="showOrgAtRiskMembers('criticalAppsAtRiskMembers')"
>
</dirt-card>
<dirt-card
#criticalAppsAtRiskApplications
class="tw-flex-1 tw-cursor-pointer"
[ngClass]="{
'tw-bg-primary-100': dataService.isDrawerOpenForInvoker$(
'criticalAppsAtRiskApplications'
),
}"
[title]="'atRiskApplications' | i18n"
[value]="summary.totalAtRiskApplicationCount"
[maxValue]="summary.totalApplicationCount"
(click)="showOrgAtRiskApps('criticalAppsAtRiskApplications')"
>
</dirt-card>
</div>
}
<div class="tw-flex tw-mt-8 tw-mb-4 tw-gap-4">
<bit-search
[placeholder]="'searchApps' | i18n"
@@ -81,7 +90,7 @@
[dataSource]="dataSource"
[showRowCheckBox]="false"
[showRowMenuForCriticalApps]="true"
[isDrawerIsOpenForThisRecord]="isDrawerOpenForTableRow"
[openApplication]="(dataService.drawerDetails$ | async).invokerId"
[showAppAtRiskMembers]="showAppAtRiskMembers"
[unmarkAsCriticalApp]="unmarkAsCriticalApp"
></app-table-row-scrollable>

View File

@@ -4,19 +4,16 @@ 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, map, switchMap } from "rxjs";
import { debounceTime } from "rxjs";
import {
CriticalAppsService,
RiskInsightsDataService,
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import {
ApplicationHealthReportDetailWithCriticalFlag,
ApplicationHealthReportDetailWithCriticalFlagAndCipher,
ApplicationHealthReportDetailEnriched,
ApplicationHealthReportSummary,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTaskType } from "@bitwarden/common/vault/tasks";
@@ -53,14 +50,14 @@ import { RiskInsightsTabType } from "./risk-insights.component";
providers: [DefaultAdminTaskService],
})
export class CriticalApplicationsComponent implements OnInit {
protected dataSource =
new TableDataSource<ApplicationHealthReportDetailWithCriticalFlagAndCipher>();
protected selectedIds: Set<number> = new Set<number>();
protected searchControl = new FormControl("", { nonNullable: true });
private destroyRef = inject(DestroyRef);
protected loading = false;
protected dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
protected searchControl = new FormControl("", { nonNullable: true });
protected organizationId: OrganizationId;
protected applicationSummary = {} as ApplicationHealthReportSummary;
protected summary = {} as ApplicationHealthReportSummary;
noItemsIcon = Icons.Security;
enableRequestPasswordChange = false;
@@ -69,10 +66,8 @@ export class CriticalApplicationsComponent implements OnInit {
protected router: Router,
protected toastService: ToastService,
protected dataService: RiskInsightsDataService,
protected criticalAppsService: CriticalAppsService,
protected reportService: RiskInsightsReportService,
protected i18nService: I18nService,
private configService: ConfigService,
private adminTaskService: DefaultAdminTaskService,
) {
this.searchControl.valueChanges
@@ -84,37 +79,13 @@ export class CriticalApplicationsComponent implements OnInit {
this.organizationId = this.activatedRoute.snapshot.paramMap.get(
"organizationId",
) as OrganizationId;
combineLatest([
this.dataService.applications$,
this.criticalAppsService.getAppsListForOrg(this.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 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.dataService
.getCriticalReport$(this.dataService.reportResults$)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((criticalReport) => {
if (criticalReport) {
this.summary = this.reportService.generateApplicationsSummary(criticalReport.data);
this.dataSource.data = criticalReport.data;
}
});
}
@@ -129,12 +100,9 @@ export class CriticalApplicationsComponent implements OnInit {
);
};
unmarkAsCriticalApp = async (hostname: string) => {
removeCriticalApp = async (hostname: string) => {
try {
await this.criticalAppsService.dropCriticalApp(
this.organizationId as OrganizationId,
hostname,
);
await this.dataService.dropCriticalApp(hostname);
} catch {
this.toastService.showToast({
message: this.i18nService.t("unexpectedError"),
@@ -181,6 +149,7 @@ export class CriticalApplicationsComponent implements OnInit {
}
}
// Open side drawer to show at risk members for an application
showAppAtRiskMembers = async (applicationName: string) => {
const data = {
members:
@@ -191,6 +160,7 @@ export class CriticalApplicationsComponent implements OnInit {
this.dataService.setDrawerForAppAtRiskMembers(data, applicationName);
};
// Open side drawer to show at risk members for the entire organization
showOrgAtRiskMembers = async (invokerId: string) => {
const data = this.reportService.generateAtRiskMemberList(this.dataSource.data);
this.dataService.setDrawerForOrgAtRiskMembers(data, invokerId);
@@ -200,11 +170,4 @@ export class CriticalApplicationsComponent implements OnInit {
const data = this.reportService.generateAtRiskApplicationList(this.dataSource.data);
this.dataService.setDrawerForOrgAtRiskApps(data, invokerId);
};
trackByFunction(_: number, item: ApplicationHealthReportDetailWithCriticalFlag) {
return item.applicationName;
}
isDrawerOpenForTableRow = (applicationName: string) => {
return this.dataService.drawerInvokerId === applicationName;
};
}

View File

@@ -5,29 +5,30 @@
{{ "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>
@if (reportResults?.dateCreated) {
<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: (reportResults?.dateCreated | date: "MMMM d, y 'at' h:mm a")
}}</span>
}
<span class="tw-flex tw-justify-center tw-w-16">
<a
*ngIf="!(isRefreshing$ | async)"
*ngIf="!isRunningReport"
bitButton
buttonType="unstyled"
class="tw-border-none !tw-font-normal tw-cursor-pointer !tw-py-0"
[bitAction]="refreshData.bind(this)"
[bitAction]="runReport"
>
{{ "refresh" | i18n }}
{{ "runRiskInsightsReport" | i18n }}
</a>
<span>
<i
*ngIf="isRefreshing$ | async"
*ngIf="isRunningReport"
class="bwi bwi-spinner bwi-spin tw-text-muted tw-text-[1.2rem]"
aria-hidden="true"
></i>
@@ -35,106 +36,114 @@
</span>
</div>
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: reportResults?.data?.length ?? 0 }}">
<tools-all-applications></tools-all-applications>
</bit-tab>
<bit-tab>
<ng-template bitTabLabel>
<i class="bwi bwi-star"></i>
{{ "criticalApplicationsWithCount" | i18n: (criticalApps$ | async)?.length ?? 0 }}
{{
"criticalApplicationsWithCount" | i18n: (dataService.criticalApps$ | async)?.length ?? 0
}}
</ng-template>
<tools-critical-applications></tools-critical-applications>
</bit-tab>
</bit-tab-group>
<bit-drawer
style="width: 30%"
[(open)]="dataService.openDrawer"
(openChange)="dataService.closeDrawer()"
>
<ng-container *ngIf="dataService.isActiveDrawerType(drawerTypes.OrgAtRiskMembers)">
<bit-drawer-header
title="{{ 'atRiskMembersWithCount' | i18n: dataService.atRiskMemberDetails.length }}"
>
</bit-drawer-header>
<bit-drawer-body>
<span bitTypography="body1" class="tw-text-muted tw-text-sm">{{
(dataService.atRiskMemberDetails.length > 0
? "atRiskMembersDescription"
: "atRiskMembersDescriptionNone"
) | i18n
}}</span>
<ng-container *ngIf="dataService.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">
{{ "atRiskPasswords" | i18n }}
@if (dataService.drawerDetails$ | async; as drawerDetails) {
<bit-drawer
style="width: 30%"
[(open)]="isDrawerOpen"
(openChange)="dataService.closeDrawer()"
>
<ng-container *ngIf="dataService.isActiveDrawerType$(drawerTypes.OrgAtRiskMembers)">
<bit-drawer-header
title="{{ 'atRiskMembersWithCount' | i18n: drawerDetails.atRiskMemberDetails.length }}"
>
</bit-drawer-header>
<bit-drawer-body>
<span bitTypography="body1" class="tw-text-muted tw-text-sm">{{
(drawerDetails.atRiskMemberDetails.length > 0
? "atRiskMembersDescription"
: "atRiskMembersDescriptionNone"
) | i18n
}}</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">
{{ "atRiskPasswords" | i18n }}
</div>
</div>
<ng-container *ngFor="let member of drawerDetails.atRiskMemberDetails">
<div class="tw-flex tw-justify-between tw-mt-2">
<div>{{ member.email }}</div>
<div>{{ member.atRiskPasswordCount }}</div>
</div>
</ng-container>
</ng-container>
</bit-drawer-body>
</ng-container>
@if (dataService.isActiveDrawerType$(drawerTypes.AppAtRiskMembers) | async) {
<bit-drawer-header title="{{ drawerDetails.appAtRiskMembers.applicationName }}">
</bit-drawer-header>
<bit-drawer-body>
<div bitTypography="body1" class="tw-mb-2">
{{ "atRiskMembersWithCount" | i18n: drawerDetails.appAtRiskMembers.members.length }}
</div>
<ng-container *ngFor="let member of dataService.atRiskMemberDetails">
<div class="tw-flex tw-justify-between tw-mt-2">
<div bitTypography="body1" class="tw-text-muted tw-text-sm tw-mb-2">
{{
(drawerDetails.appAtRiskMembers.members.length > 0
? "atRiskMembersDescriptionWithApp"
: "atRiskMembersDescriptionWithAppNone"
) | i18n: drawerDetails.appAtRiskMembers.applicationName
}}
</div>
<div class="tw-mt-1">
<ng-container *ngFor="let member of drawerDetails.appAtRiskMembers.members">
<div>{{ member.email }}</div>
<div>{{ member.atRiskPasswordCount }}</div>
</div>
</ng-container>
</ng-container>
</bit-drawer-body>
</ng-container>
<ng-container *ngIf="dataService.isActiveDrawerType(drawerTypes.AppAtRiskMembers)">
<bit-drawer-header title="{{ dataService.appAtRiskMembers.applicationName }}">
</bit-drawer-header>
<bit-drawer-body>
<div bitTypography="body1" class="tw-mb-2">
{{ "atRiskMembersWithCount" | i18n: dataService.appAtRiskMembers.members.length }}
</div>
<div bitTypography="body1" class="tw-text-muted tw-text-sm tw-mb-2">
{{
(dataService.appAtRiskMembers.members.length > 0
? "atRiskMembersDescriptionWithApp"
: "atRiskMembersDescriptionWithAppNone"
) | i18n: dataService.appAtRiskMembers.applicationName
}}
</div>
<div class="tw-mt-1">
<ng-container *ngFor="let member of dataService.appAtRiskMembers.members">
<div>{{ member.email }}</div>
</ng-container>
</div>
</bit-drawer-body>
</ng-container>
<ng-container *ngIf="dataService.isActiveDrawerType(drawerTypes.OrgAtRiskApps)">
<bit-drawer-header
title="{{ 'atRiskApplicationsWithCount' | i18n: dataService.atRiskAppDetails.length }}"
>
</bit-drawer-header>
<bit-drawer-body>
<span bitTypography="body2" class="tw-text-muted tw-text-sm">{{
(dataService.atRiskAppDetails.length > 0
? "atRiskApplicationsDescription"
: "atRiskApplicationsDescriptionNone"
) | i18n
}}</span>
<ng-container *ngIf="dataService.atRiskAppDetails.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">
{{ "application" | i18n }}
</div>
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
{{ "atRiskPasswords" | i18n }}
</div>
</ng-container>
</div>
<ng-container *ngFor="let app of dataService.atRiskAppDetails">
<div class="tw-flex tw-justify-between tw-mt-2">
<div>{{ app.applicationName }}</div>
<div>{{ app.atRiskPasswordCount }}</div>
</bit-drawer-body>
}
@if (dataService.isActiveDrawerType$(drawerTypes.OrgAtRiskApps) | async) {
<bit-drawer-header
title="{{
'atRiskApplicationsWithCount' | i18n: drawerDetails.atRiskAppDetails.length
}}"
>
</bit-drawer-header>
<bit-drawer-body>
<span bitTypography="body2" class="tw-text-muted tw-text-sm">{{
(drawerDetails.atRiskAppDetails.length > 0
? "atRiskApplicationsDescription"
: "atRiskApplicationsDescriptionNone"
) | i18n
}}</span>
<ng-container *ngIf="drawerDetails.atRiskAppDetails.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">
{{ "application" | i18n }}
</div>
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
{{ "atRiskPasswords" | i18n }}
</div>
</div>
<ng-container *ngFor="let app of drawerDetails.atRiskAppDetails">
<div class="tw-flex tw-justify-between tw-mt-2">
<div>{{ app.applicationName }}</div>
<div>{{ app.atRiskPasswordCount }}</div>
</div>
</ng-container>
</ng-container>
</ng-container>
</bit-drawer-body>
</ng-container>
</bit-drawer>
</bit-drawer-body>
}
</bit-drawer>
}
</bit-layout>
</ng-container>

View File

@@ -2,20 +2,15 @@ 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, Observable } from "rxjs";
import { EMPTY } from "rxjs";
import { map, switchMap } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import {
CriticalAppsService,
RiskInsightsDataService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import {
ApplicationHealthReportDetail,
DrawerType,
PasswordHealthReportApplicationsResponse,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
ReportDetailsAndSummary,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
AsyncActionsModule,
@@ -57,72 +52,80 @@ export enum RiskInsightsTabType {
],
})
export class RiskInsightsComponent implements OnInit {
private destroyRef = inject(DestroyRef);
private _isDrawerOpen: boolean = false;
tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps;
dataLastUpdated: Date = new Date();
criticalApps$: Observable<PasswordHealthReportApplicationsResponse[]> = new Observable();
appsCount: number = 0;
criticalAppsCount: number = 0;
notifiedMembersCount: number = 0;
private organizationId: OrganizationId = "" as OrganizationId;
private destroyRef = inject(DestroyRef);
isLoading$: Observable<boolean> = new Observable<boolean>();
isRefreshing$: Observable<boolean> = new Observable<boolean>();
dataLastUpdated$: Observable<Date | null> = new Observable<Date | null>();
refetching: boolean = false;
reportResults: ReportDetailsAndSummary | null = null;
isRunningReport: boolean = false;
constructor(
private route: ActivatedRoute,
private router: Router,
private configService: ConfigService,
protected dataService: RiskInsightsDataService,
private criticalAppsService: CriticalAppsService,
) {
this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => {
this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps;
});
const orgId = this.route.snapshot.paramMap.get("organizationId") ?? "";
this.criticalApps$ = this.criticalAppsService.getAppsListForOrg(orgId);
}
) {}
async ngOnInit() {
// Setup tabs from route
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => {
this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps;
});
// Setup organization id from route
this.route.paramMap
.pipe(
takeUntilDestroyed(this.destroyRef),
map((params) => params.get("organizationId")),
switchMap((orgId) => {
switchMap(async (orgId) => {
if (orgId) {
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$;
// Initialize the data service with the organization id
await this.dataService.initialize(orgId as OrganizationId);
} else {
return EMPTY;
}
}),
)
.subscribe({
next: (applications: ApplicationHealthReportDetail[] | null) => {
if (applications) {
this.appsCount = applications.length;
}
this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId);
},
.subscribe();
// Subscribe to drawer changes
this.dataService.drawerDetails$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((details) => {
this._isDrawerOpen = details.open;
});
// Subscribe to report details
this.dataService.reportResults$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((reportResults) => (this.reportResults = reportResults));
// Subscribe to is running report flag
this.dataService.isRunningReport$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((isRunning) => (this.isRunningReport = isRunning));
}
/**
* Refreshes the data by re-fetching the applications report.
* This will automatically notify child components subscribed to the RiskInsightsDataService observables.
*/
refreshData(): void {
if (this.organizationId) {
this.dataService.refreshApplicationsReport(this.organizationId);
runReport = () => {
this.dataService.triggerReport();
};
get isDrawerOpen() {
return this._isDrawerOpen;
}
set isDrawerOpen(value: boolean) {
if (this._isDrawerOpen !== value) {
this._isDrawerOpen = value;
// Close the drawer in the service if the drawer component closed the drawer
if (!value) {
this.dataService.closeDrawer();
}
}
}