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:
@@ -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";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
PasswordHealthReportApplicationDropRequest,
|
||||
PasswordHealthReportApplicationsRequest,
|
||||
PasswordHealthReportApplicationsResponse,
|
||||
} from "../models/password-health";
|
||||
} from "../models/api-models.types";
|
||||
|
||||
export class CriticalAppsApiService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user