1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 19:34:03 +00:00

initial prototype commit

This commit is contained in:
Tom
2026-01-15 19:09:47 -05:00
parent 16e6756643
commit cb5f2f0a73
29 changed files with 3234 additions and 6 deletions

View File

@@ -21,7 +21,9 @@ import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
import { organizationRedirectGuard } from "../guards/org-redirect.guard";
import { EventsComponent } from "../manage/events.component";
import { CipherHealthTestComponent } from "./cipher-health-test.component";
import { ReportsHomeComponent } from "./reports-home.component";
import { RiskInsightsPrototypeComponent } from "./risk-insights-prototype/risk-insights-prototype.component";
const routes: Routes = [
{
@@ -82,6 +84,22 @@ const routes: Routes = [
},
canActivate: [isPaidOrgGuard()],
},
{
path: "cipher-health-test",
component: CipherHealthTestComponent,
data: {
titleId: "cipherHealthTest",
},
canActivate: [isPaidOrgGuard()],
},
{
path: "risk-insights-prototype",
component: RiskInsightsPrototypeComponent,
data: {
titleId: "riskInsightsPrototype",
},
canActivate: [isPaidOrgGuard()],
},
],
},
{

View File

@@ -4,11 +4,20 @@ import { ReportsSharedModule } from "../../../dirt/reports";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared/shared.module";
import { CipherHealthTestComponent } from "./cipher-health-test.component";
import { OrganizationReportingRoutingModule } from "./organization-reporting-routing.module";
import { ReportsHomeComponent } from "./reports-home.component";
import { RiskInsightsPrototypeComponent } from "./risk-insights-prototype/risk-insights-prototype.component";
@NgModule({
imports: [SharedModule, ReportsSharedModule, OrganizationReportingRoutingModule, HeaderModule],
imports: [
SharedModule,
ReportsSharedModule,
OrganizationReportingRoutingModule,
HeaderModule,
CipherHealthTestComponent,
RiskInsightsPrototypeComponent,
],
declarations: [ReportsHomeComponent],
})
export class OrganizationReportingModule {}

View File

@@ -83,6 +83,14 @@ export class ReportsHomeComponent implements OnInit {
? ReportVariant.Enabled
: ReportVariant.RequiresEnterprise,
},
{
...reports[ReportType.CipherHealthTest],
variant: reportRequiresUpgrade,
},
{
...reports[ReportType.RiskInsightsPrototype],
variant: reportRequiresUpgrade,
},
];
return reportsArray;

View File

@@ -0,0 +1,6 @@
<div class="tw-p-4">
<h2 class="tw-text-xl tw-font-semibold tw-mb-4">{{ "applications" | i18n }}</h2>
<p class="tw-text-muted">
{{ "riskInsightsPrototypeApplicationsPlaceholder" | i18n }}
</p>
</div>

View File

@@ -0,0 +1,13 @@
import { CommonModule } from "@angular/common";
import { Component, ChangeDetectionStrategy } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@Component({
selector: "app-risk-insights-prototype-applications",
templateUrl: "./risk-insights-prototype-applications.component.html",
standalone: true,
imports: [CommonModule, JslibModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RiskInsightsPrototypeApplicationsComponent {}

View File

@@ -0,0 +1,222 @@
<div class="tw-flex tw-flex-col tw-gap-4">
<!-- Controls Section -->
<div class="tw-p-4 tw-bg-background-alt tw-rounded-lg">
<div class="tw-flex tw-flex-wrap tw-items-center tw-gap-6 tw-mb-4">
<!-- Weak Password Checkbox -->
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
<input
type="checkbox"
bitCheckbox
[checked]="enableWeakPassword()"
(change)="toggleWeakPassword()"
[disabled]="isProcessing()"
/>
<span class="tw-text-main">{{ "enableWeakPasswordCheck" | i18n }}</span>
</label>
<!-- HIBP Checkbox -->
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
<input
type="checkbox"
bitCheckbox
[checked]="enableHibp()"
(change)="toggleHibp()"
[disabled]="isProcessing()"
/>
<span class="tw-text-main">{{ "enableHibpCheck" | i18n }}</span>
</label>
<!-- Reused Password Checkbox -->
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
<input
type="checkbox"
bitCheckbox
[checked]="enableReusedPassword()"
(change)="toggleReusedPassword()"
[disabled]="isProcessing()"
/>
<span class="tw-text-main">{{ "enableReusedPasswordCheck" | i18n }}</span>
</label>
</div>
<!-- Run Report Button -->
<button bitButton buttonType="primary" (click)="runReport()" [disabled]="isProcessing()">
@if (isProcessing()) {
<i class="bwi bwi-spinner bwi-spin tw-mr-2" aria-hidden="true"></i>
{{ "processing" | i18n }}
} @else {
<i class="bwi bwi-play tw-mr-2" aria-hidden="true"></i>
{{ "runReport" | i18n }}
}
</button>
</div>
<!-- Progress Section -->
@if (showProgress()) {
<div class="tw-p-4 tw-bg-background-alt tw-rounded-lg">
<!-- Main Progress -->
<div class="tw-mb-4">
<div class="tw-flex tw-justify-between tw-text-sm tw-mb-1">
<span class="tw-text-muted">{{ progressMessage() || "Processing..." }}</span>
<span class="tw-text-muted">{{ getOverallProgress() | number: "1.0-0" }}%</span>
</div>
<bit-progress [barWidth]="getOverallProgress()" bgColor="primary"></bit-progress>
</div>
<!-- HIBP Progress (only when HIBP is enabled and running) -->
@if (enableHibp() && processingPhase() === ProcessingPhase.RunningHibp) {
<div>
<div class="tw-flex tw-justify-between tw-text-sm tw-mb-1">
<span class="tw-text-muted">
{{ "checkingExposedPasswords" | i18n }}: {{ hibpProgress().current }} /
{{ hibpProgress().total }}
</span>
<span class="tw-text-muted">{{ hibpProgress().percent }}%</span>
</div>
<bit-progress [barWidth]="hibpProgress().percent" bgColor="warning"></bit-progress>
</div>
}
<!-- Completion Status -->
@if (processingPhase() === ProcessingPhase.Complete) {
<div class="tw-flex tw-items-center tw-gap-2 tw-text-success-600">
<i class="bwi bwi-check-circle" aria-hidden="true"></i>
<span>{{ "reportComplete" | i18n }}</span>
</div>
}
<!-- Error Status -->
@if (processingPhase() === ProcessingPhase.Error && error()) {
<div class="tw-flex tw-items-center tw-gap-2 tw-text-danger-600">
<i class="bwi bwi-error" aria-hidden="true"></i>
<span>{{ error() }}</span>
</div>
}
</div>
}
<!-- Results Table -->
@if (items().length > 0) {
<div class="tw-text-sm tw-text-muted tw-mb-2">
{{ "totalItems" | i18n }}: {{ items().length | number }}
</div>
<bit-table-scroll [dataSource]="dataSource" [rowSize]="ROW_SIZE" [trackBy]="trackByItemId">
<ng-container header>
<th bitCell class="tw-min-w-[200px]">{{ "name" | i18n }}</th>
<th bitCell class="tw-text-center tw-w-24">{{ "weak" | i18n }}</th>
<th bitCell class="tw-text-center tw-w-24">{{ "reused" | i18n }}</th>
<th bitCell class="tw-text-center tw-w-24">{{ "exposed" | i18n }}</th>
<th bitCell class="tw-text-center tw-w-24">{{ "members" | i18n }}</th>
<th bitCell class="tw-text-center tw-w-28">{{ "status" | i18n }}</th>
</ng-container>
<ng-template bitRowDef let-row>
<!-- Cipher Name -->
<td bitCell>
<div class="tw-font-medium tw-truncate tw-max-w-[200px]" [title]="row.cipherName">
{{ row.cipherName }}
</div>
@if (row.cipherSubtitle) {
<div class="tw-text-xs tw-text-muted tw-truncate tw-max-w-[200px]">
{{ row.cipherSubtitle }}
</div>
}
</td>
<!-- Weak Password Status -->
<td bitCell class="tw-text-center">
@if (row.weakPassword === null) {
@if (enableWeakPassword()) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
aria-hidden="true"
title="{{ 'loading' | i18n }}"
></i>
} @else {
<span class="tw-text-muted">-</span>
}
} @else if (row.weakPassword) {
<span bitBadge variant="warning">{{ "weak" | i18n }}</span>
} @else {
<i class="bwi bwi-check tw-text-success-600" aria-hidden="true"></i>
}
</td>
<!-- Reused Password Status -->
<td bitCell class="tw-text-center">
@if (row.reusedPassword === null) {
@if (enableReusedPassword()) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
aria-hidden="true"
title="{{ 'loading' | i18n }}"
></i>
} @else {
<span class="tw-text-muted">-</span>
}
} @else if (row.reusedPassword) {
<span bitBadge variant="warning">{{ "yes" | i18n }}</span>
} @else {
<i class="bwi bwi-check tw-text-success-600" aria-hidden="true"></i>
}
</td>
<!-- Exposed Password Status -->
<td bitCell class="tw-text-center">
@if (row.exposedPassword === null) {
@if (enableHibp()) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
aria-hidden="true"
title="{{ 'loading' | i18n }}"
></i>
} @else {
<span class="tw-text-muted">-</span>
}
} @else if (row.exposedPassword) {
<span bitBadge variant="danger" [title]="row.exposedCount + ' times'">
{{ row.exposedCount | number }}x
</span>
} @else {
<i class="bwi bwi-check tw-text-success-600" aria-hidden="true"></i>
}
</td>
<!-- Member Count -->
<td bitCell class="tw-text-center">
@if (row.memberAccessPending) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
aria-hidden="true"
title="{{ 'loading' | i18n }}"
></i>
} @else {
<span>{{ row.memberCount | number }}</span>
}
</td>
<!-- Status -->
<td bitCell class="tw-text-center">
@if (row.status === null) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
aria-hidden="true"
title="{{ 'loading' | i18n }}"
></i>
} @else if (row.status === RiskInsightsItemStatus.AtRisk) {
<span bitBadge variant="danger">{{ "atRisk" | i18n }}</span>
} @else {
<span bitBadge variant="success">{{ "healthy" | i18n }}</span>
}
</td>
</ng-template>
</bit-table-scroll>
} @else if (processingPhase() === ProcessingPhase.Idle) {
<!-- Initial State -->
<div class="tw-text-center tw-py-8 tw-text-muted">
<i class="bwi bwi-report tw-text-4xl tw-mb-4" aria-hidden="true"></i>
<p>{{ "riskInsightsPrototypeItemsPlaceholder" | i18n }}</p>
</div>
}
</div>

View File

@@ -0,0 +1,215 @@
/* eslint-disable no-restricted-imports -- Prototype feature using licensed services */
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
effect,
inject,
OnInit,
signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
CipherAccessMappingService,
PasswordHealthService,
RiskInsightsPrototypeOrchestrationService,
RiskInsightsPrototypeService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
import {
ProcessingPhase,
RiskInsightsItem,
RiskInsightsItemStatus,
} from "@bitwarden/common/dirt/reports/risk-insights";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
BadgeModule,
ButtonModule,
CheckboxModule,
ProgressModule,
TableDataSource,
TableModule,
} from "@bitwarden/components";
/* eslint-enable no-restricted-imports */
/**
* Items tab component for the Risk Insights Prototype.
*
* Displays a table of cipher items with health status and member counts.
* Features:
* - Progressive loading with status indicators
* - Virtual scrolling table for large datasets
* - Configurable health checks (weak, reused, exposed passwords)
*/
@Component({
selector: "app-risk-insights-prototype-items",
templateUrl: "./risk-insights-prototype-items.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
TableModule,
ProgressModule,
CheckboxModule,
ButtonModule,
BadgeModule,
],
providers: [
RiskInsightsPrototypeOrchestrationService,
RiskInsightsPrototypeService,
CipherAccessMappingService,
PasswordHealthService,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RiskInsightsPrototypeItemsComponent implements OnInit {
// ============================================================================
// Injected Dependencies
// ============================================================================
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly orchestrator = inject(RiskInsightsPrototypeOrchestrationService);
// ============================================================================
// Expose Orchestrator Signals to Template
// ============================================================================
// Configuration flags
readonly enableWeakPassword = this.orchestrator.enableWeakPassword;
readonly enableHibp = this.orchestrator.enableHibp;
readonly enableReusedPassword = this.orchestrator.enableReusedPassword;
// Processing state
readonly processingPhase = this.orchestrator.processingPhase;
readonly progressMessage = this.orchestrator.progressMessage;
// Progress tracking
readonly cipherProgress = this.orchestrator.cipherProgress;
readonly healthProgress = this.orchestrator.healthProgress;
readonly memberProgress = this.orchestrator.memberProgress;
readonly hibpProgress = this.orchestrator.hibpProgress;
// Results
readonly items = this.orchestrator.items;
// Error state
readonly error = this.orchestrator.error;
// Expose constants for template access
readonly ProcessingPhase = ProcessingPhase;
readonly RiskInsightsItemStatus = RiskInsightsItemStatus;
// ============================================================================
// Component State
// ============================================================================
/** Table data source for virtual scrolling */
protected readonly dataSource = new TableDataSource<RiskInsightsItem>();
/** Row size for virtual scrolling (in pixels) */
protected readonly ROW_SIZE = 52;
/** Whether the component has been initialized */
protected readonly initialized = signal(false);
// ============================================================================
// Lifecycle
// ============================================================================
constructor() {
// Effect to sync items signal to table data source
effect(() => {
const items = this.items();
this.dataSource.data = items;
});
}
ngOnInit(): void {
// Get organization ID from route and initialize orchestrator
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const organizationId = params["organizationId"] as OrganizationId;
if (organizationId) {
this.orchestrator.initializeForOrganization(organizationId);
this.initialized.set(true);
}
});
}
// ============================================================================
// UI Actions
// ============================================================================
/** Start processing - run the report */
protected runReport(): void {
this.orchestrator.startProcessing();
}
/** Toggle weak password check */
protected toggleWeakPassword(): void {
this.orchestrator.toggleEnableWeakPassword();
}
/** Toggle HIBP check */
protected toggleHibp(): void {
this.orchestrator.toggleEnableHibp();
}
/** Toggle reused password check */
protected toggleReusedPassword(): void {
this.orchestrator.toggleEnableReusedPassword();
}
// ============================================================================
// Computed Properties
// ============================================================================
/** Check if processing is currently running */
protected isProcessing(): boolean {
const phase = this.processingPhase();
return (
phase !== ProcessingPhase.Idle &&
phase !== ProcessingPhase.Complete &&
phase !== ProcessingPhase.Error
);
}
/** Check if progress section should be shown */
protected showProgress(): boolean {
return this.isProcessing() || this.processingPhase() === ProcessingPhase.Complete;
}
/** Calculate overall progress percentage */
protected getOverallProgress(): number {
const phase = this.processingPhase();
switch (phase) {
case ProcessingPhase.Idle:
return 0;
case ProcessingPhase.LoadingCiphers:
return this.cipherProgress().percent * 0.2; // 0-20%
case ProcessingPhase.RunningHealthChecks:
return 20 + this.healthProgress().percent * 0.2; // 20-40%
case ProcessingPhase.LoadingMembers:
return 40 + this.memberProgress().percent * 0.4; // 40-80%
case ProcessingPhase.RunningHibp:
return 80 + this.hibpProgress().percent * 0.2; // 80-100%
case ProcessingPhase.Complete:
return 100;
default:
return 0;
}
}
// ============================================================================
// TrackBy Functions
// ============================================================================
/** TrackBy function for items */
protected trackByItemId(_index: number, item: RiskInsightsItem): string {
return item.cipherId;
}
}

View File

@@ -0,0 +1,6 @@
<div class="tw-p-4">
<h2 class="tw-text-xl tw-font-semibold tw-mb-4">{{ "members" | i18n }}</h2>
<p class="tw-text-muted">
{{ "riskInsightsPrototypeMembersPlaceholder" | i18n }}
</p>
</div>

View File

@@ -0,0 +1,13 @@
import { CommonModule } from "@angular/common";
import { Component, ChangeDetectionStrategy } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@Component({
selector: "app-risk-insights-prototype-members",
templateUrl: "./risk-insights-prototype-members.component.html",
standalone: true,
imports: [CommonModule, JslibModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RiskInsightsPrototypeMembersComponent {}

View File

@@ -0,0 +1,23 @@
<app-header></app-header>
<div class="tw-min-h-screen tw-flex tw-flex-col">
<div class="tw-mb-4">
<p class="tw-text-main tw-max-w-4xl">
{{ "riskInsightsPrototypeDesc" | i18n }}
</p>
</div>
<div class="tw-flex-1 tw-flex tw-flex-col">
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
<bit-tab [label]="'items' | i18n">
<app-risk-insights-prototype-items></app-risk-insights-prototype-items>
</bit-tab>
<bit-tab [label]="'applications' | i18n">
<app-risk-insights-prototype-applications></app-risk-insights-prototype-applications>
</bit-tab>
<bit-tab [label]="'members' | i18n">
<app-risk-insights-prototype-members></app-risk-insights-prototype-members>
</bit-tab>
</bit-tab-group>
</div>
</div>

View File

@@ -0,0 +1,50 @@
import { CommonModule } from "@angular/common";
import { Component, ChangeDetectionStrategy, DestroyRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { TabsModule } from "@bitwarden/components";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { RiskInsightsPrototypeApplicationsComponent } from "./applications/risk-insights-prototype-applications.component";
import { RiskInsightsPrototypeItemsComponent } from "./items/risk-insights-prototype-items.component";
import { RiskInsightsPrototypeMembersComponent } from "./members/risk-insights-prototype-members.component";
@Component({
selector: "app-risk-insights-prototype",
templateUrl: "./risk-insights-prototype.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
TabsModule,
HeaderModule,
RiskInsightsPrototypeItemsComponent,
RiskInsightsPrototypeApplicationsComponent,
RiskInsightsPrototypeMembersComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RiskInsightsPrototypeComponent {
private destroyRef = inject(DestroyRef);
tabIndex = 0;
constructor(
private route: ActivatedRoute,
private router: Router,
) {
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => {
this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : 0;
});
}
async onTabChange(newIndex: number): Promise<void> {
await this.router.navigate([], {
relativeTo: this.route,
queryParams: { tabIndex: newIndex },
queryParamsHandling: "merge",
});
}
}

View File

@@ -20,6 +20,8 @@ export enum ReportType {
Inactive2fa = "inactive2fa",
DataBreach = "dataBreach",
MemberAccessReport = "memberAccessReport",
CipherHealthTest = "cipherHealthTest",
RiskInsightsPrototype = "riskInsightsPrototype",
}
type ReportWithoutVariant = Omit<ReportEntry, "variant">;
@@ -67,4 +69,16 @@ export const reports: Record<ReportType, ReportWithoutVariant> = {
route: "member-access-report",
icon: UserLockIcon,
},
[ReportType.CipherHealthTest]: {
title: "cipherHealthTest",
description: "cipherHealthTestDesc",
route: "cipher-health-test",
icon: UnlockedIcon,
},
[ReportType.RiskInsightsPrototype]: {
title: "riskInsightsPrototype",
description: "riskInsightsPrototypeDesc",
route: "risk-insights-prototype",
icon: UnlockedIcon,
},
};

View File

@@ -10739,6 +10739,69 @@
"memberAccessReportAuthenticationEnabledFalse": {
"message": "Off"
},
"cipherHealthTest": {
"message": "Risk insights diagnostics"
},
"cipherHealthTestDesc": {
"message": "Test password health and member access mapping in parallel. View detailed performance diagnostics and analyze cipher security risks."
},
"riskInsightsPrototype": {
"message": "Risk Insights Prototype"
},
"riskInsightsPrototypeDesc": {
"message": "Prototype report for analyzing password risk across items, applications, and members."
},
"applications": {
"message": "Applications"
},
"riskInsightsPrototypeItemsPlaceholder": {
"message": "Items tab content will be displayed here."
},
"riskInsightsPrototypeApplicationsPlaceholder": {
"message": "Applications tab content will be displayed here."
},
"riskInsightsPrototypeMembersPlaceholder": {
"message": "Members tab content will be displayed here."
},
"enableWeakPasswordCheck": {
"message": "Enable Weak Password Check"
},
"enableHibpCheck": {
"message": "Enable Exposed Password Check"
},
"enableReusedPasswordCheck": {
"message": "Enable Reused Password Check"
},
"runReport": {
"message": "Run Report"
},
"checkingExposedPasswords": {
"message": "Checking exposed passwords"
},
"reportComplete": {
"message": "Report complete"
},
"totalItems": {
"message": "Total items"
},
"weak": {
"message": "Weak"
},
"reused": {
"message": "Reused"
},
"exposed": {
"message": "Exposed"
},
"atRisk": {
"message": "At-Risk"
},
"healthy": {
"message": "Healthy"
},
"processing": {
"message": "Processing..."
},
"kdfIterationRecommends": {
"message": "We recommend 600,000 or more"
},

View File

@@ -0,0 +1,354 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import {
CollectionAdminService,
CollectionAdminView,
CollectionAccessSelectionView,
OrganizationUserApiService,
OrganizationUserUserDetailsResponse,
} from "@bitwarden/admin-console/common";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
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 { LogService } from "@bitwarden/logging";
import { CipherAccessMappingService } from "./cipher-access-mapping.service";
describe("CipherAccessMappingService", () => {
let service: CipherAccessMappingService;
let cipherService: MockProxy<CipherService>;
let collectionAdminService: MockProxy<CollectionAdminService>;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let logService: MockProxy<LogService>;
const mockOrgId = "org123" as OrganizationId;
const mockUserId = "user123" as UserId;
beforeEach(() => {
cipherService = mock<CipherService>();
collectionAdminService = mock<CollectionAdminService>();
organizationUserApiService = mock<OrganizationUserApiService>();
logService = mock<LogService>();
service = new CipherAccessMappingService(
cipherService,
collectionAdminService,
organizationUserApiService,
logService,
);
});
// Helper function to create a collection access selection view
function createCollectionAccessSelectionView(
id: string,
readOnly = false,
hidePasswords = false,
manage = false,
): CollectionAccessSelectionView {
return {
id,
readOnly,
hidePasswords,
manage,
} as CollectionAccessSelectionView;
}
// Helper function to create a mock org user response
function createMockOrgUserResponse(
userId: string,
email: string,
groups: string[] = [],
): OrganizationUserUserDetailsResponse {
return {
userId,
email,
groups,
} as OrganizationUserUserDetailsResponse;
}
describe("getAllCiphersWithMemberAccess", () => {
it("should map ciphers with direct user access", async () => {
// Setup mock cipher
const mockCipher = new CipherView();
mockCipher.id = "cipher1";
mockCipher.name = "Test Cipher";
mockCipher.type = CipherType.Login;
mockCipher.collectionIds = ["collection1"];
// Setup mock collection
const mockCollection = {
id: "collection1",
name: "Collection 1",
users: [createCollectionAccessSelectionView("user1", false, false, true)],
groups: [],
} as unknown as CollectionAdminView;
// Setup mock org user response
const mockOrgUser = createMockOrgUserResponse("user1", "user1@example.com", []);
const mockListResponse = {
data: [mockOrgUser],
} as ListResponse<OrganizationUserUserDetailsResponse>;
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
collectionAdminService.collectionAdminViews$ = jest
.fn()
.mockReturnValue(of([mockCollection])) as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
const result = await service.getAllCiphersWithMemberAccess(mockOrgId, mockUserId);
expect(result).toHaveLength(1);
expect(result[0].cipher.id).toBe("cipher1");
expect(result[0].totalMemberCount).toBe(1);
expect(result[0].members[0].userId).toBe("user1");
expect(result[0].members[0].email).toBe("user1@example.com");
expect(result[0].members[0].accessPaths[0].type).toBe("direct");
expect(result[0].members[0].effectivePermissions.canEdit).toBe(true);
expect(result[0].members[0].effectivePermissions.canManage).toBe(true);
});
it("should map ciphers with group-based access", async () => {
// Setup mock cipher
const mockCipher = new CipherView();
mockCipher.id = "cipher1";
mockCipher.name = "Test Cipher";
mockCipher.type = CipherType.Login;
mockCipher.collectionIds = ["collection1"];
// Setup mock collection with group access
const mockCollection = {
id: "collection1",
name: "Collection 1",
users: [],
groups: [createCollectionAccessSelectionView("group1", true, false, false)],
} as unknown as CollectionAdminView;
// Setup mock org user in group
const mockOrgUser = createMockOrgUserResponse("user2", "user2@example.com", ["group1"]);
const mockListResponse = {
data: [mockOrgUser],
} as ListResponse<OrganizationUserUserDetailsResponse>;
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
collectionAdminService.collectionAdminViews$ = jest
.fn()
.mockReturnValue(of([mockCollection])) as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
const result = await service.getAllCiphersWithMemberAccess(mockOrgId, mockUserId);
expect(result).toHaveLength(1);
expect(result[0].totalMemberCount).toBe(1);
expect(result[0].members[0].userId).toBe("user2");
expect(result[0].members[0].accessPaths[0].type).toBe("group");
expect(result[0].members[0].accessPaths[0].groupId).toBe("group1");
expect(result[0].members[0].effectivePermissions.canEdit).toBe(false);
expect(result[0].members[0].effectivePermissions.canViewPasswords).toBe(true);
});
it("should handle unassigned ciphers", async () => {
// Setup mock cipher with no collections
const mockCipher = new CipherView();
mockCipher.id = "cipher1";
mockCipher.name = "Unassigned Cipher";
mockCipher.type = CipherType.Login;
mockCipher.collectionIds = [];
const mockListResponse = {
data: [],
} as ListResponse<OrganizationUserUserDetailsResponse>;
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
collectionAdminService.collectionAdminViews$ = jest.fn().mockReturnValue(of([])) as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
const result = await service.getAllCiphersWithMemberAccess(mockOrgId, mockUserId);
expect(result).toHaveLength(1);
expect(result[0].unassigned).toBe(true);
expect(result[0].totalMemberCount).toBe(0);
expect(result[0].members).toHaveLength(0);
});
it("should combine multiple access paths for same user", async () => {
// Setup mock cipher assigned to two collections
const mockCipher = new CipherView();
mockCipher.id = "cipher1";
mockCipher.name = "Test Cipher";
mockCipher.type = CipherType.Login;
mockCipher.collectionIds = ["collection1", "collection2"];
// Setup two collections with same user
const mockCollection1 = {
id: "collection1",
name: "Collection 1",
users: [createCollectionAccessSelectionView("user1", true, true, false)],
groups: [],
} as unknown as CollectionAdminView;
const mockCollection2 = {
id: "collection2",
name: "Collection 2",
users: [createCollectionAccessSelectionView("user1", false, false, true)],
groups: [],
} as unknown as CollectionAdminView;
const mockOrgUser = createMockOrgUserResponse("user1", "user1@example.com", []);
const mockListResponse = {
data: [mockOrgUser],
} as ListResponse<OrganizationUserUserDetailsResponse>;
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
collectionAdminService.collectionAdminViews$ = jest
.fn()
.mockReturnValue(of([mockCollection1, mockCollection2])) as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
const result = await service.getAllCiphersWithMemberAccess(mockOrgId, mockUserId);
expect(result).toHaveLength(1);
expect(result[0].totalMemberCount).toBe(1);
expect(result[0].members[0].accessPaths).toHaveLength(2);
// Most permissive should win
expect(result[0].members[0].effectivePermissions.canEdit).toBe(true);
expect(result[0].members[0].effectivePermissions.canViewPasswords).toBe(true);
expect(result[0].members[0].effectivePermissions.canManage).toBe(true);
});
});
describe("getSimplifiedCipherAccessMap", () => {
it("should return simplified mapping of cipher IDs to user IDs", async () => {
const mockCipher = new CipherView();
mockCipher.id = "cipher1";
mockCipher.name = "Test Cipher";
mockCipher.collectionIds = ["collection1"];
const mockCollection = {
id: "collection1",
users: [createCollectionAccessSelectionView("user1")],
groups: [],
} as unknown as CollectionAdminView;
const mockOrgUser = createMockOrgUserResponse("user1", "user1@example.com", []);
const mockListResponse = {
data: [mockOrgUser],
} as ListResponse<OrganizationUserUserDetailsResponse>;
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
collectionAdminService.collectionAdminViews$ = jest
.fn()
.mockReturnValue(of([mockCollection])) as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
const result = await service.getSimplifiedCipherAccessMap(mockOrgId, mockUserId);
expect(result).toHaveLength(1);
expect(result[0].cipherId).toBe("cipher1");
expect(result[0].cipherName).toBe("Test Cipher");
expect(result[0].userIds.size).toBe(1);
expect(result[0].userIds.has("user1")).toBe(true);
});
});
describe("findMembersForCipher", () => {
it("should return members with access to specific cipher", async () => {
const mockCipher = new CipherView();
mockCipher.id = "cipher1";
mockCipher.name = "Test Cipher";
mockCipher.collectionIds = ["collection1"];
const mockCollection = {
id: "collection1",
users: [createCollectionAccessSelectionView("user1")],
groups: [],
} as unknown as CollectionAdminView;
const mockOrgUser = createMockOrgUserResponse("user1", "user1@example.com", []);
const mockListResponse = {
data: [mockOrgUser],
} as ListResponse<OrganizationUserUserDetailsResponse>;
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
collectionAdminService.collectionAdminViews$ = jest
.fn()
.mockReturnValue(of([mockCollection])) as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
const result = await service.findMembersForCipher(mockOrgId, mockUserId, "cipher1");
expect(result).not.toBeNull();
expect(result).toHaveLength(1);
expect(result![0].userId).toBe("user1");
});
it("should return null for non-existent cipher", async () => {
const mockListResponse = {
data: [],
} as ListResponse<OrganizationUserUserDetailsResponse>;
cipherService.getAllFromApiForOrganization.mockResolvedValue([]);
collectionAdminService.collectionAdminViews$ = jest.fn().mockReturnValue(of([])) as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
const result = await service.findMembersForCipher(mockOrgId, mockUserId, "nonexistent");
expect(result).toBeNull();
});
});
describe("generateCipherMemberCountReport", () => {
it("should generate report sorted by member count", async () => {
const cipher1 = new CipherView();
cipher1.id = "cipher1";
cipher1.name = "Cipher 1";
cipher1.collectionIds = ["collection1"];
const cipher2 = new CipherView();
cipher2.id = "cipher2";
cipher2.name = "Cipher 2";
cipher2.collectionIds = ["collection2"];
const collection1 = {
id: "collection1",
users: [createCollectionAccessSelectionView("user1")],
groups: [],
} as unknown as CollectionAdminView;
const collection2 = {
id: "collection2",
users: [
createCollectionAccessSelectionView("user2"),
createCollectionAccessSelectionView("user3"),
],
groups: [],
} as unknown as CollectionAdminView;
const mockOrgUsers = [
createMockOrgUserResponse("user1", "user1@example.com", []),
createMockOrgUserResponse("user2", "user2@example.com", []),
createMockOrgUserResponse("user3", "user3@example.com", []),
];
const mockListResponse = {
data: mockOrgUsers,
} as ListResponse<OrganizationUserUserDetailsResponse>;
cipherService.getAllFromApiForOrganization.mockResolvedValue([cipher1, cipher2]);
collectionAdminService.collectionAdminViews$ = jest
.fn()
.mockReturnValue(of([collection1, collection2])) as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
const result = await service.generateCipherMemberCountReport(mockOrgId, mockUserId);
expect(result).toHaveLength(2);
// Should be sorted descending by member count
expect(result[0].cipherId).toBe("cipher2");
expect(result[0].memberCount).toBe(2);
expect(result[1].cipherId).toBe("cipher1");
expect(result[1].memberCount).toBe(1);
});
});
});

View File

@@ -0,0 +1,991 @@
import { Injectable, inject } from "@angular/core";
import {
catchError,
combineLatest,
concatMap,
from,
map,
Observable,
of,
switchMap,
take,
tap,
} from "rxjs";
import {
CollectionAdminService,
CollectionAdminView,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LogService } from "@bitwarden/logging";
/**
* Represents a member's access to a cipher with permission details
*/
export interface CipherMemberAccess {
userId: string;
email: string | null;
accessPaths: CipherAccessPath[];
effectivePermissions: EffectiveCipherPermissions;
}
/**
* Describes how a member gained access to a cipher
*/
export interface CipherAccessPath {
type: "direct" | "group";
collectionId: string;
collectionName: string;
groupId?: string;
groupName?: string;
permissions: {
readOnly: boolean;
hidePasswords: boolean;
manage: boolean;
};
}
/**
* The effective permissions after combining all access paths
*/
export interface EffectiveCipherPermissions {
canEdit: boolean; // Has at least one non-readOnly path
canViewPasswords: boolean; // Has at least one non-hidePasswords path
canManage: boolean; // Has at least one manage path
}
/**
* Complete cipher with all members who have access
*/
export interface CipherWithMemberAccess {
cipher: CipherView;
members: CipherMemberAccess[];
totalMemberCount: number;
unassigned: boolean; // True if cipher has no collections
}
/**
* Simplified mapping of cipher ID to user IDs
*/
export interface SimplifiedCipherAccessMap {
cipherId: string;
cipherName: string;
userIds: Set<string>;
}
/**
* Service for mapping organization ciphers to members who have access
*
* This service provides functionality to:
* 1. Fetch all ciphers in an organization (using getAllFromApiForOrganization)
* 2. Determine which members have access to each cipher
* 3. Track access paths (direct collection assignment vs group-based)
* 4. Calculate effective permissions
*
* Use Cases:
* - Testing cipher access logic
* - Auditing member access to sensitive items
* - Generating access reports
*/
/**
* Timing information for diagnostics
*/
export interface CipherAccessMappingTimings {
fetchCollectionsMs: number;
fetchUsersMs: number;
buildGroupMemberMapMs: number;
buildUserEmailMapMs: number;
}
/**
* Count information for diagnostics
*/
export interface CipherAccessMappingCounts {
cipherCount: number;
collectionCount: number;
groupCount: number;
memberCount: number;
}
/**
* Result with timing information
*/
export interface CipherAccessMappingTimedResult {
data: CipherWithMemberAccess[];
timings: CipherAccessMappingTimings;
counts: CipherAccessMappingCounts;
}
/**
* State for progressive member access loading (const object pattern per ADR-0025)
*/
export const MemberAccessLoadState = Object.freeze({
NotStarted: "not-started",
LoadingPrerequisites: "loading-prerequisites",
ProcessingBatches: "processing-batches",
Complete: "complete",
Error: "error",
} as const);
export type MemberAccessLoadState =
(typeof MemberAccessLoadState)[keyof typeof MemberAccessLoadState];
/**
* Progressive result emitted as batches complete during streaming
*/
export interface CipherAccessMappingProgressiveResult {
/** Current state of member access loading */
state: MemberAccessLoadState;
/** Ciphers processed so far (grows with each batch) */
processedCiphers: CipherWithMemberAccess[];
/** Total number of ciphers to process */
totalCipherCount: number;
/** Number of ciphers processed so far */
processedCount: number;
/** Percentage complete (0-100) */
progressPercent: number;
/** Timing diagnostics (partial until complete) */
timings: Partial<CipherAccessMappingTimings>;
/** Entity counts (partial until complete) */
counts: Partial<CipherAccessMappingCounts>;
/** Error message if state is Error */
error?: string;
}
/**
* Cached organization users data to avoid duplicate API calls
*/
interface CachedOrganizationUsers {
organizationId: OrganizationId;
groupMemberMap: Map<string, { groupName: string; memberIds: string[] }>;
userEmailMap: Map<string, string>;
fetchedAt: number;
}
@Injectable()
export class CipherAccessMappingService {
private readonly cipherService = inject(CipherService);
private readonly collectionAdminService = inject(CollectionAdminService);
private readonly organizationUserApiService = inject(OrganizationUserApiService);
private readonly logService = inject(LogService);
/** Cache for organization users to avoid duplicate API calls */
private _usersCache: CachedOrganizationUsers | null = null;
private readonly CACHE_TTL_MS = 60000; // 1 minute cache
/**
* Gets all ciphers with member access AND timing diagnostics
*/
getAllCiphersWithMemberAccessTimed$(
organizationId: OrganizationId,
currentUserId: UserId,
): Observable<CipherAccessMappingTimedResult> {
const timings: CipherAccessMappingTimings = {
fetchCollectionsMs: 0,
fetchUsersMs: 0,
buildGroupMemberMapMs: 0,
buildUserEmailMapMs: 0,
};
this.logService.info(
`[CipherAccessMappingService] Fetching all ciphers for organization ${organizationId}`,
);
// STEP 1: Fetch all ciphers in the organization (admin view)
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId)).pipe(
tap((ciphers) =>
this.logService.info(`[CipherAccessMappingService] Found ${ciphers.length} ciphers`),
),
);
// STEP 2: Fetch all collections with access details (users and groups)
this.logService.info("[CipherAccessMappingService] Fetching collections with access details");
const collectionsStart = performance.now();
const collections$ = this.collectionAdminService
.collectionAdminViews$(organizationId, currentUserId)
.pipe(
tap((collections) => {
timings.fetchCollectionsMs = performance.now() - collectionsStart;
this.logService.info(
`[CipherAccessMappingService] Found ${collections.length} collections`,
);
}),
);
// STEP 3 & 4: Build group member map and user email map
// These are async operations, so we convert to observables
const groupStart = performance.now();
const groupMemberMap$ = from(this.buildGroupMemberMap(organizationId)).pipe(
tap(() => {
timings.buildGroupMemberMapMs = performance.now() - groupStart;
}),
);
const emailStart = performance.now();
const userEmailMap$ = from(this.buildUserEmailMap(organizationId)).pipe(
tap(() => {
timings.buildUserEmailMapMs = performance.now() - emailStart;
timings.fetchUsersMs = timings.buildGroupMemberMapMs + timings.buildUserEmailMapMs;
}),
);
// Combine all data sources
return combineLatest([allCiphers$, collections$, groupMemberMap$, userEmailMap$]).pipe(
map(([allCiphers, collections, groupMemberMap, userEmailMap]) => {
// Build collection map for quick lookup
const collectionMap = new Map<string, CollectionAdminView>();
collections.forEach((collection) => {
collectionMap.set(collection.id, collection);
});
// STEP 5: For each cipher, determine member access
this.logService.info("[CipherAccessMappingService] Mapping member access to ciphers");
const ciphersWithAccess: CipherWithMemberAccess[] = [];
for (const cipher of allCiphers) {
const memberAccessMap = new Map<string, CipherMemberAccess>();
// Check if cipher is unassigned (no collections)
const isUnassigned = !cipher.collectionIds || cipher.collectionIds.length === 0;
if (!isUnassigned) {
// Cipher is assigned to collections
for (const collectionId of cipher.collectionIds) {
const collection = collectionMap.get(collectionId);
if (!collection) {
this.logService.warning(
`[CipherAccessMappingService] Collection ${collectionId} not found for cipher ${cipher.id}`,
);
continue;
}
// A) Process direct user assignments to this collection
for (const userAccess of collection.users) {
const userId = userAccess.id;
if (!memberAccessMap.has(userId)) {
memberAccessMap.set(userId, {
userId,
email: userEmailMap.get(userId) ?? null,
accessPaths: [],
effectivePermissions: {
canEdit: false,
canViewPasswords: false,
canManage: false,
},
});
}
const memberAccess = memberAccessMap.get(userId)!;
memberAccess.accessPaths.push({
type: "direct",
collectionId: collection.id,
collectionName: collection.name || "Unknown",
permissions: {
readOnly: userAccess.readOnly,
hidePasswords: userAccess.hidePasswords,
manage: userAccess.manage,
},
});
// Update effective permissions
this.updateEffectivePermissions(memberAccess, userAccess);
}
// B) Process group assignments to this collection
for (const groupAccess of collection.groups) {
const groupId = groupAccess.id;
const groupMemberData = groupMemberMap.get(groupId);
if (!groupMemberData || groupMemberData.memberIds.length === 0) {
this.logService.warning(
`[CipherAccessMappingService] No members found for group ${groupId}`,
);
continue;
}
// Add access for each member in the group
for (const userId of groupMemberData.memberIds) {
if (!memberAccessMap.has(userId)) {
memberAccessMap.set(userId, {
userId,
email: userEmailMap.get(userId) ?? null,
accessPaths: [],
effectivePermissions: {
canEdit: false,
canViewPasswords: false,
canManage: false,
},
});
}
const memberAccess = memberAccessMap.get(userId)!;
memberAccess.accessPaths.push({
type: "group",
collectionId: collection.id,
collectionName: collection.name || "Unknown",
groupId: groupId,
groupName: groupMemberData.groupName,
permissions: {
readOnly: groupAccess.readOnly,
hidePasswords: groupAccess.hidePasswords,
manage: groupAccess.manage,
},
});
// Update effective permissions
this.updateEffectivePermissions(memberAccess, groupAccess);
}
}
}
}
// Convert the map to an array
const members = Array.from(memberAccessMap.values());
ciphersWithAccess.push({
cipher,
totalMemberCount: members.length,
members,
unassigned: isUnassigned,
});
}
this.logService.info(
`[CipherAccessMappingService] Completed mapping for ${ciphersWithAccess.length} ciphers`,
);
// Calculate counts for diagnostics
const uniqueMembers = new Set<string>();
ciphersWithAccess.forEach((cipher) => {
cipher.members.forEach((member) => uniqueMembers.add(member.userId));
});
const counts: CipherAccessMappingCounts = {
cipherCount: allCiphers.length,
collectionCount: collections.length,
groupCount: groupMemberMap.size,
memberCount: uniqueMembers.size,
};
return {
data: ciphersWithAccess,
timings,
counts,
};
}),
);
}
/**
* Gets all ciphers in an organization and maps which members have access
*
* @param organizationId - The organization ID
* @param currentUserId - The current user's ID (for collection fetching)
* @returns Observable of array of ciphers with their member access details
*/
getAllCiphersWithMemberAccess$(
organizationId: OrganizationId,
currentUserId: UserId,
): Observable<CipherWithMemberAccess[]> {
return this.getAllCiphersWithMemberAccessTimed$(organizationId, currentUserId).pipe(
map((result) => result.data),
);
}
/**
* Simplified version that just returns cipher ID -> user IDs mapping
*
* @param organizationId - The organization ID
* @param currentUserId - The current user's ID
* @returns Observable of map of cipher IDs to sets of user IDs with access
*/
getSimplifiedCipherAccessMap$(
organizationId: OrganizationId,
currentUserId: UserId,
): Observable<SimplifiedCipherAccessMap[]> {
this.logService.info(
`[CipherAccessMappingService] Building simplified cipher access map for ${organizationId}`,
);
return this.getAllCiphersWithMemberAccess$(organizationId, currentUserId).pipe(
map((ciphersWithAccess) => {
const result: SimplifiedCipherAccessMap[] = ciphersWithAccess.map((c) => {
const userIds = new Set<string>(c.members.map((m) => m.userId));
return {
cipherId: c.cipher.id,
cipherName: c.cipher.name,
userIds,
};
});
this.logService.info(
`[CipherAccessMappingService] Completed simplified mapping for ${result.length} ciphers`,
);
return result;
}),
);
}
/**
* Finds all ciphers a specific user has access to
*
* @param organizationId - The organization ID
* @param currentUserId - The current user's ID (for collection fetching)
* @param targetUserId - The user to find ciphers for
* @returns Observable of ciphers the target user can access
*/
findCiphersForUser$(
organizationId: OrganizationId,
currentUserId: UserId,
targetUserId: string,
): Observable<CipherWithMemberAccess[]> {
return this.getAllCiphersWithMemberAccess$(organizationId, currentUserId).pipe(
map((allCiphersWithAccess) => {
const userCiphers = allCiphersWithAccess.filter((c) =>
c.members.some((m) => m.userId === targetUserId),
);
this.logService.info(
`[CipherAccessMappingService] User ${targetUserId} has access to ${userCiphers.length} ciphers`,
);
return userCiphers;
}),
);
}
/**
* Finds all members who have access to a specific cipher
*
* @param organizationId - The organization ID
* @param currentUserId - The current user's ID (for collection fetching)
* @param cipherId - The cipher to find members for
* @returns Observable of members with access to the cipher, or null if cipher not found
*/
findMembersForCipher$(
organizationId: OrganizationId,
currentUserId: UserId,
cipherId: string,
): Observable<CipherMemberAccess[] | null> {
return this.getAllCiphersWithMemberAccess$(organizationId, currentUserId).pipe(
map((allCiphersWithAccess) => {
const targetCipher = allCiphersWithAccess.find((c) => c.cipher.id === cipherId);
if (!targetCipher) {
this.logService.warning(
`[CipherAccessMappingService] Cipher ${cipherId} not found in organization`,
);
return null;
}
this.logService.info(
`[CipherAccessMappingService] Found ${targetCipher.totalMemberCount} members with access to cipher ${cipherId}`,
);
return targetCipher.members;
}),
);
}
/**
* Generates a report of ciphers with their distinct member count
*
* @param organizationId - The organization ID
* @param currentUserId - The current user's ID
* @returns Observable of array of cipher summaries sorted by member count descending
*/
generateCipherMemberCountReport$(
organizationId: OrganizationId,
currentUserId: UserId,
): Observable<
{ cipherId: string; cipherName: string; memberCount: number; unassigned: boolean }[]
> {
return this.getAllCiphersWithMemberAccess$(organizationId, currentUserId).pipe(
map((ciphersWithAccess) => {
const report = ciphersWithAccess.map((c) => ({
cipherId: c.cipher.id,
cipherName: c.cipher.name,
memberCount: c.totalMemberCount,
unassigned: c.unassigned,
}));
// Sort by member count descending
report.sort((a, b) => b.memberCount - a.memberCount);
this.logService.info(
`[CipherAccessMappingService] Generated report for ${report.length} ciphers`,
);
return report;
}),
);
}
/**
* Exports cipher access data to JSON format
*
* @param organizationId - The organization ID
* @param currentUserId - The current user's ID
* @returns Observable of JSON string of cipher access data
*/
exportToJSON$(organizationId: OrganizationId, currentUserId: UserId): Observable<string> {
return this.getAllCiphersWithMemberAccess$(organizationId, currentUserId).pipe(
map((ciphersWithAccess) => {
// Transform to a serializable format
const exportData = ciphersWithAccess.map((c) => ({
cipherId: c.cipher.id,
cipherName: c.cipher.name,
cipherType: c.cipher.type,
organizationId: c.cipher.organizationId,
collectionIds: c.cipher.collectionIds,
unassigned: c.unassigned,
totalMemberCount: c.totalMemberCount,
members: c.members.map((m) => ({
userId: m.userId,
email: m.email,
effectivePermissions: m.effectivePermissions,
accessPaths: m.accessPaths,
})),
}));
const jsonOutput = JSON.stringify(exportData, null, 2);
this.logService.info(
`[CipherAccessMappingService] Exported ${exportData.length} ciphers to JSON`,
);
return jsonOutput;
}),
);
}
/**
* Gets ciphers with member access using progressive streaming.
* Emits partial results as batches complete, enabling incremental UI updates.
*
* Key differences from getAllCiphersWithMemberAccessTimed$:
* 1. Accepts already-fetched ciphers (decouples cipher fetch from member mapping)
* 2. Uses single API call for users (via fetchOrganizationUsersOnce)
* 3. Processes in batches with setTimeout(0) to yield to event loop
* 4. Emits after each batch for progressive UI updates
*
* @param organizationId - The organization ID
* @param currentUserId - The current user's ID (for collection fetching)
* @param ciphers - Pre-fetched ciphers to process
* @param batchSize - Number of ciphers to process per batch (default 500)
* @returns Observable that emits progressive results after each batch
*/
getAllCiphersWithMemberAccessProgressive$(
organizationId: OrganizationId,
currentUserId: UserId,
ciphers: CipherView[],
batchSize: number = 500,
): Observable<CipherAccessMappingProgressiveResult> {
const timings: Partial<CipherAccessMappingTimings> = {};
const counts: Partial<CipherAccessMappingCounts> = { cipherCount: ciphers.length };
this.logService.info(
`[CipherAccessMappingService] Starting progressive member access mapping for ${ciphers.length} ciphers`,
);
// Fetch prerequisites in parallel: collections and users (single API call)
const collectionsStart = performance.now();
const collections$ = this.collectionAdminService
.collectionAdminViews$(organizationId, currentUserId)
.pipe(
take(1), // Complete after first emission (hot observable)
tap((collections) => {
timings.fetchCollectionsMs = performance.now() - collectionsStart;
counts.collectionCount = collections.length;
this.logService.info(
`[CipherAccessMappingService] Fetched ${collections.length} collections in ${timings.fetchCollectionsMs?.toFixed(0)}ms`,
);
}),
);
const usersStart = performance.now();
const users$ = from(this.fetchOrganizationUsersOnce(organizationId)).pipe(
tap(({ groupMemberMap, userEmailMap }) => {
timings.fetchUsersMs = performance.now() - usersStart;
timings.buildGroupMemberMapMs = timings.fetchUsersMs;
timings.buildUserEmailMapMs = 0; // Included in single call
counts.groupCount = groupMemberMap.size;
counts.memberCount = userEmailMap.size;
this.logService.info(
`[CipherAccessMappingService] Fetched users in ${timings.fetchUsersMs?.toFixed(0)}ms`,
);
}),
);
// Combine prerequisites, then process batches
return combineLatest([collections$, users$]).pipe(
switchMap(([collections, { groupMemberMap, userEmailMap }]) => {
// Build collection lookup map
const collectionMap = new Map<string, CollectionAdminView>();
collections.forEach((c) => collectionMap.set(c.id, c));
// Create batches
const batches: CipherView[][] = [];
for (let i = 0; i < ciphers.length; i += batchSize) {
batches.push(ciphers.slice(i, i + batchSize));
}
this.logService.info(
`[CipherAccessMappingService] Processing ${batches.length} batches of ~${batchSize} ciphers each`,
);
// Accumulate results across batches
const processedCiphers: CipherWithMemberAccess[] = [];
// Process batches sequentially with event loop yields
return from(batches).pipe(
concatMap((batch, batchIndex) => {
return new Observable<CipherAccessMappingProgressiveResult>((observer) => {
// Use setTimeout(0) to yield to event loop between batches
setTimeout(() => {
const batchResults = this.processCipherBatch(
batch,
collectionMap,
groupMemberMap,
userEmailMap,
);
processedCiphers.push(...batchResults);
const processedCount = processedCiphers.length;
const progressPercent = Math.round((processedCount / ciphers.length) * 100);
this.logService.info(
`[CipherAccessMappingService] Batch ${batchIndex + 1}/${batches.length} complete: ${processedCount}/${ciphers.length} (${progressPercent}%)`,
);
const isLastBatch = batchIndex === batches.length - 1;
observer.next({
state: isLastBatch
? MemberAccessLoadState.Complete
: MemberAccessLoadState.ProcessingBatches,
processedCiphers: [...processedCiphers], // Copy for immutability
totalCipherCount: ciphers.length,
processedCount,
progressPercent,
timings,
counts,
});
observer.complete();
}, 0);
});
}),
);
}),
catchError((error: unknown) => {
this.logService.error("[CipherAccessMappingService] Progressive mapping error", error);
const errorMessage =
error instanceof Error
? error.message
: "Unknown error occurred during member access mapping";
return of({
state: MemberAccessLoadState.Error,
processedCiphers: [],
totalCipherCount: ciphers.length,
processedCount: 0,
progressPercent: 0,
timings,
counts,
error: errorMessage,
});
}),
);
}
// ============================================================================
// PRIVATE HELPER METHODS
// ============================================================================
/**
* Builds a map of groupId -> member user IDs and group name
*
* Note: The GroupDetailsView doesn't include members directly.
* We need to fetch organization users and check their group memberships.
*/
private async buildGroupMemberMap(
organizationId: OrganizationId,
): Promise<Map<string, { groupName: string; memberIds: string[] }>> {
const groupMemberMap = new Map<string, { groupName: string; memberIds: string[] }>();
// Fetch all organization users with groups
const orgUsersResponse = await this.organizationUserApiService.getAllUsers(organizationId, {
includeGroups: true,
});
// Build reverse mapping: for each user, add them to their groups
for (const orgUser of orgUsersResponse.data) {
if (!orgUser.groups || orgUser.groups.length === 0) {
continue;
}
for (const groupId of orgUser.groups) {
let groupData = groupMemberMap.get(groupId);
if (!groupData) {
// Initialize group data (name will be updated if we have it)
groupData = { groupName: "Unknown Group", memberIds: [] };
groupMemberMap.set(groupId, groupData);
}
// Use orgUser.id (organization user ID) to match collection assignments and email map
groupData.memberIds.push(orgUser.id);
}
}
this.logService.info(
`[CipherAccessMappingService] Built group member map for ${groupMemberMap.size} groups`,
);
return groupMemberMap;
}
/**
* Builds a map of userId -> email for quick lookup
*/
private async buildUserEmailMap(organizationId: OrganizationId): Promise<Map<string, string>> {
const userEmailMap = new Map<string, string>();
const orgUsersResponse = await this.organizationUserApiService.getAllUsers(organizationId);
for (const orgUser of orgUsersResponse.data) {
// Use orgUser.id as the key (organization user ID), not orgUser.userId which can be null
// This is the ID that will be used in collection assignments and group member IDs
if (orgUser.id && orgUser.email) {
userEmailMap.set(orgUser.id, orgUser.email);
}
}
this.logService.info(
`[CipherAccessMappingService] Built user email map for ${userEmailMap.size} users`,
);
return userEmailMap;
}
/**
* Fetches organization users ONCE and builds both maps from the same API response.
* Eliminates the duplicate API calls that existed in the original implementation.
* Includes caching to avoid repeated calls within a short time window.
*
* @param organizationId - The organization ID
* @returns Both groupMemberMap and userEmailMap built from a single API call
*/
private async fetchOrganizationUsersOnce(organizationId: OrganizationId): Promise<{
groupMemberMap: Map<string, { groupName: string; memberIds: string[] }>;
userEmailMap: Map<string, string>;
}> {
// Check cache first
if (
this._usersCache &&
this._usersCache.organizationId === organizationId &&
Date.now() - this._usersCache.fetchedAt < this.CACHE_TTL_MS
) {
this.logService.info(
`[CipherAccessMappingService] Using cached user data for organization ${organizationId}`,
);
return {
groupMemberMap: this._usersCache.groupMemberMap,
userEmailMap: this._usersCache.userEmailMap,
};
}
this.logService.info(
`[CipherAccessMappingService] Fetching organization users (single API call) for ${organizationId}`,
);
// Single API call with includeGroups: true - gets all user data needed
const orgUsersResponse = await this.organizationUserApiService.getAllUsers(organizationId, {
includeGroups: true,
});
// Build both maps from the same response
const groupMemberMap = new Map<string, { groupName: string; memberIds: string[] }>();
const userEmailMap = new Map<string, string>();
for (const orgUser of orgUsersResponse.data) {
// Build email map
if (orgUser.id && orgUser.email) {
userEmailMap.set(orgUser.id, orgUser.email);
}
// Build group member map (reverse mapping: user -> their groups)
if (orgUser.groups && orgUser.groups.length > 0) {
for (const groupId of orgUser.groups) {
let groupData = groupMemberMap.get(groupId);
if (!groupData) {
groupData = { groupName: "Unknown Group", memberIds: [] };
groupMemberMap.set(groupId, groupData);
}
groupData.memberIds.push(orgUser.id);
}
}
}
// Update cache
this._usersCache = {
organizationId,
groupMemberMap,
userEmailMap,
fetchedAt: Date.now(),
};
this.logService.info(
`[CipherAccessMappingService] Built maps from single API call: ${userEmailMap.size} users, ${groupMemberMap.size} groups`,
);
return { groupMemberMap, userEmailMap };
}
/**
* Processes a batch of ciphers to calculate member access.
* Extracted as a reusable helper for both progressive and non-progressive methods.
*
* @param ciphers - The batch of ciphers to process
* @param collectionMap - Map of collection ID to CollectionAdminView
* @param groupMemberMap - Map of group ID to group data with member IDs
* @param userEmailMap - Map of user ID to email
* @returns Array of ciphers with member access calculated
*/
private processCipherBatch(
ciphers: CipherView[],
collectionMap: Map<string, CollectionAdminView>,
groupMemberMap: Map<string, { groupName: string; memberIds: string[] }>,
userEmailMap: Map<string, string>,
): CipherWithMemberAccess[] {
const results: CipherWithMemberAccess[] = [];
for (const cipher of ciphers) {
const memberAccessMap = new Map<string, CipherMemberAccess>();
const isUnassigned = !cipher.collectionIds || cipher.collectionIds.length === 0;
if (!isUnassigned) {
for (const collectionId of cipher.collectionIds) {
const collection = collectionMap.get(collectionId);
if (!collection) {
continue;
}
// Process direct user assignments
for (const userAccess of collection.users) {
const userId = userAccess.id;
if (!memberAccessMap.has(userId)) {
memberAccessMap.set(userId, {
userId,
email: userEmailMap.get(userId) ?? null,
accessPaths: [],
effectivePermissions: {
canEdit: false,
canViewPasswords: false,
canManage: false,
},
});
}
const memberAccess = memberAccessMap.get(userId)!;
memberAccess.accessPaths.push({
type: "direct",
collectionId: collection.id,
collectionName: collection.name || "Unknown",
permissions: {
readOnly: userAccess.readOnly,
hidePasswords: userAccess.hidePasswords,
manage: userAccess.manage,
},
});
this.updateEffectivePermissions(memberAccess, userAccess);
}
// Process group assignments
for (const groupAccess of collection.groups) {
const groupId = groupAccess.id;
const groupMemberData = groupMemberMap.get(groupId);
if (!groupMemberData || groupMemberData.memberIds.length === 0) {
continue;
}
for (const userId of groupMemberData.memberIds) {
if (!memberAccessMap.has(userId)) {
memberAccessMap.set(userId, {
userId,
email: userEmailMap.get(userId) ?? null,
accessPaths: [],
effectivePermissions: {
canEdit: false,
canViewPasswords: false,
canManage: false,
},
});
}
const memberAccess = memberAccessMap.get(userId)!;
memberAccess.accessPaths.push({
type: "group",
collectionId: collection.id,
collectionName: collection.name || "Unknown",
groupId: groupId,
groupName: groupMemberData.groupName,
permissions: {
readOnly: groupAccess.readOnly,
hidePasswords: groupAccess.hidePasswords,
manage: groupAccess.manage,
},
});
this.updateEffectivePermissions(memberAccess, groupAccess);
}
}
}
}
results.push({
cipher,
members: Array.from(memberAccessMap.values()),
totalMemberCount: memberAccessMap.size,
unassigned: isUnassigned,
});
}
return results;
}
/**
* Updates the effective permissions based on a new access path
* Uses "most permissive" logic - if ANY path grants a permission, it's granted
*/
private updateEffectivePermissions(
memberAccess: CipherMemberAccess,
accessPermissions: { readOnly: boolean; hidePasswords: boolean; manage: boolean },
): void {
// Can edit if at least one path is NOT read-only
if (!accessPermissions.readOnly) {
memberAccess.effectivePermissions.canEdit = true;
}
// Can view passwords if at least one path does NOT hide passwords
if (!accessPermissions.hidePasswords) {
memberAccess.effectivePermissions.canViewPasswords = true;
}
// Can manage if at least one path grants manage permission
if (accessPermissions.manage) {
memberAccess.effectivePermissions.canManage = true;
}
}
}

View File

@@ -1,4 +1,15 @@
import { filter, from, map, mergeMap, Observable, toArray } from "rxjs";
import { Injectable } from "@angular/core";
import {
bufferCount,
filter,
from,
map,
mergeMap,
Observable,
scan,
startWith,
toArray,
} from "rxjs";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -12,6 +23,38 @@ import {
WeakPasswordScore,
} from "../../models/password-health";
/**
* State of HIBP (Have I Been Pwned) password checking (per ADR-0025 - no enums)
*/
export const HibpCheckState = Object.freeze({
NotStarted: "not-started",
Checking: "checking",
Complete: "complete",
Error: "error",
} as const);
export type HibpCheckState = (typeof HibpCheckState)[keyof typeof HibpCheckState];
/**
* Progress result for progressive HIBP password checking
*/
export interface HibpProgressResult {
/** Current state of the HIBP check */
state: HibpCheckState;
/** Number of passwords checked so far */
checkedCount: number;
/** Total number of passwords to check */
totalCount: number;
/** Progress percentage (0-100) */
progressPercent: number;
/** Exposed passwords found so far */
exposedPasswords: ExposedPasswordDetail[];
/** Elapsed time in milliseconds */
elapsedMs: number;
/** Error message if state is Error */
error?: string;
}
@Injectable()
export class PasswordHealthService {
constructor(
private auditService: AuditService,
@@ -42,6 +85,93 @@ export class PasswordHealthService {
);
}
/**
* Progressive version of auditPasswordLeaks$ that emits progress updates
* as passwords are checked against HIBP.
*
* @param ciphers The list of ciphers to check.
* @param batchEmitSize How often to emit progress updates (default: 500 passwords).
* @returns An observable that emits HibpProgressResult with progress updates.
*/
auditPasswordLeaksProgressive$(
ciphers: CipherView[],
batchEmitSize: number = 500,
): Observable<HibpProgressResult> {
const validCiphers = ciphers.filter((c) => this.isValidCipher(c));
const totalCount = validCiphers.length;
const startTime = performance.now();
if (totalCount === 0) {
// No valid ciphers to check - emit immediate completion
return from([
{
state: HibpCheckState.Complete,
checkedCount: 0,
totalCount: 0,
progressPercent: 100,
exposedPasswords: [],
elapsedMs: 0,
} as HibpProgressResult,
]);
}
// Accumulator for scan operator
interface ProgressAccumulator {
checkedCount: number;
exposedPasswords: ExposedPasswordDetail[];
}
return from(validCiphers).pipe(
// Use mergeMap with concurrency matching audit service (100 concurrent)
mergeMap(
(cipher) =>
from(this.auditService.passwordLeaked(cipher.login.password!)).pipe(
map((exposedCount) => ({ cipherId: cipher.id, exposedCount })),
),
100,
),
// Buffer results and emit every batchEmitSize checks
bufferCount(batchEmitSize),
// Use scan to accumulate results across batches
scan(
(acc: ProgressAccumulator, batch) => {
const newExposed = batch
.filter((result) => result.exposedCount > 0)
.map((result) => ({
cipherId: result.cipherId,
exposedXTimes: result.exposedCount,
}));
return {
checkedCount: acc.checkedCount + batch.length,
exposedPasswords: [...acc.exposedPasswords, ...newExposed],
};
},
{ checkedCount: 0, exposedPasswords: [] } as ProgressAccumulator,
),
// Map accumulated state to progress result
map(
(acc): HibpProgressResult => ({
state: acc.checkedCount >= totalCount ? HibpCheckState.Complete : HibpCheckState.Checking,
checkedCount: acc.checkedCount,
totalCount,
progressPercent: Math.round((acc.checkedCount / totalCount) * 100),
exposedPasswords: acc.exposedPasswords,
elapsedMs: performance.now() - startTime,
}),
),
// Start with initial progress state
startWith({
state: HibpCheckState.Checking,
checkedCount: 0,
totalCount,
progressPercent: 0,
exposedPasswords: [],
elapsedMs: 0,
} as HibpProgressResult),
);
}
/**
* Extracts username parts from the cipher's username.
* This is used to help determine password strength.

View File

@@ -0,0 +1,545 @@
import { Injectable, signal, inject, DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { from, Observable, of } from "rxjs";
import { catchError, switchMap, tap, last, map } from "rxjs/operators";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAccessMappingService, MemberAccessLoadState } from "./cipher-access-mapping.service";
import { PasswordHealthService } from "./password-health.service";
import { RiskInsightsPrototypeService } from "./risk-insights-prototype.service";
import {
ProcessingPhase,
ProgressInfo,
RiskInsightsItem,
RiskInsightsItemStatus,
calculateRiskStatus,
} from "./risk-insights-prototype.types";
/**
* Orchestration service for the Risk Insights Prototype.
*
* Coordinates progressive loading in phases:
* - Phase 1: Load ciphers and display immediately
* - Phase 2: Run health checks (weak + reused) if enabled
* - Phase 3: Load member counts progressively
* - Phase 4: Run HIBP checks last (if enabled), updating items progressively
*
* Uses Angular Signals internally (per ADR-0027), exposed as read-only signals.
*/
@Injectable()
export class RiskInsightsPrototypeOrchestrationService {
// ============================================================================
// Injected Dependencies
// ============================================================================
private readonly accountService = inject(AccountService);
private readonly cipherService = inject(CipherService);
private readonly cipherAccessMappingService = inject(CipherAccessMappingService);
private readonly passwordHealthService = inject(PasswordHealthService);
private readonly riskInsightsService = inject(RiskInsightsPrototypeService);
private readonly destroyRef = inject(DestroyRef);
// ============================================================================
// Private State
// ============================================================================
private organizationId: OrganizationId | null = null;
private currentUserId: UserId | null = null;
private cipherIndexMap = new Map<string, number>();
private allCiphers: CipherView[] = [];
private passwordUseMap: Map<string, string[]> = new Map();
// ============================================================================
// Internal Signals (private, writable)
// ============================================================================
// Configuration flags (default all to false per requirements)
private readonly _enableWeakPassword = signal(false);
private readonly _enableHibp = signal(false);
private readonly _enableReusedPassword = signal(false);
// Processing state
private readonly _processingPhase = signal<ProcessingPhase>(ProcessingPhase.Idle);
private readonly _progressMessage = signal("");
// Progress tracking
private readonly _cipherProgress = signal<ProgressInfo>({ current: 0, total: 0, percent: 0 });
private readonly _healthProgress = signal<ProgressInfo>({ current: 0, total: 0, percent: 0 });
private readonly _memberProgress = signal<ProgressInfo>({ current: 0, total: 0, percent: 0 });
private readonly _hibpProgress = signal<ProgressInfo>({ current: 0, total: 0, percent: 0 });
// Results
private readonly _items = signal<RiskInsightsItem[]>([]);
// Error state
private readonly _error = signal<string | null>(null);
// ============================================================================
// Public Read-only Signals (for template binding)
// ============================================================================
// Configuration flags
readonly enableWeakPassword = this._enableWeakPassword.asReadonly();
readonly enableHibp = this._enableHibp.asReadonly();
readonly enableReusedPassword = this._enableReusedPassword.asReadonly();
// Processing state
readonly processingPhase = this._processingPhase.asReadonly();
readonly progressMessage = this._progressMessage.asReadonly();
// Progress tracking
readonly cipherProgress = this._cipherProgress.asReadonly();
readonly healthProgress = this._healthProgress.asReadonly();
readonly memberProgress = this._memberProgress.asReadonly();
readonly hibpProgress = this._hibpProgress.asReadonly();
// Results
readonly items = this._items.asReadonly();
// Error state
readonly error = this._error.asReadonly();
// Expose constants for template access
readonly ProcessingPhase = ProcessingPhase;
// ============================================================================
// Public Methods - Initialization
// ============================================================================
/**
* Initialize the service for a specific organization.
*/
initializeForOrganization(organizationId: OrganizationId): void {
this.organizationId = organizationId;
this.accountService.activeAccount$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((account) => {
if (account) {
this.currentUserId = account.id as UserId;
}
});
}
// ============================================================================
// Public Methods - Configuration
// ============================================================================
toggleEnableWeakPassword(): void {
this._enableWeakPassword.update((current) => !current);
}
toggleEnableHibp(): void {
this._enableHibp.update((current) => !current);
}
toggleEnableReusedPassword(): void {
this._enableReusedPassword.update((current) => !current);
}
setEnableWeakPassword(enabled: boolean): void {
this._enableWeakPassword.set(enabled);
}
setEnableHibp(enabled: boolean): void {
this._enableHibp.set(enabled);
}
setEnableReusedPassword(enabled: boolean): void {
this._enableReusedPassword.set(enabled);
}
// ============================================================================
// Public Methods - Actions
// ============================================================================
/**
* Starts progressive loading:
* Phase 1: Load ciphers, display immediately
* Phase 2: Run health checks (weak + reused) if enabled
* Phase 3: Load member counts progressively
* Phase 4: Run HIBP checks last (if enabled)
*/
startProcessing(): void {
if (!this.organizationId || !this.currentUserId) {
this._processingPhase.set(ProcessingPhase.Error);
this._error.set("Organization ID or User ID not available");
return;
}
this.resetState();
this._processingPhase.set(ProcessingPhase.LoadingCiphers);
this._progressMessage.set("Loading ciphers...");
// PHASE 1: Load ciphers
from(this.cipherService.getAllFromApiForOrganization(this.organizationId))
.pipe(
tap((ciphers) => {
this.allCiphers = ciphers;
// Transform to items and display immediately
const items = this.riskInsightsService.transformCiphersToItems(ciphers);
this._items.set(items);
// Build cipher index map for O(1) updates
this.cipherIndexMap.clear();
items.forEach((item, index) => {
this.cipherIndexMap.set(item.cipherId, index);
});
this._cipherProgress.set({
current: items.length,
total: items.length,
percent: 100,
});
// Build password use map for reuse detection
this.passwordUseMap = this.riskInsightsService.buildPasswordUseMap(ciphers);
}),
// PHASE 2: Run health checks if enabled
switchMap(() => this.runHealthChecksIfEnabled$()),
// PHASE 3: Load member counts
switchMap(() => this.runMemberCountsLoading$()),
// PHASE 4: Run HIBP checks if enabled (runs last)
tap(() => {
if (this._enableHibp()) {
this._processingPhase.set(ProcessingPhase.RunningHibp);
this._progressMessage.set("Checking for exposed passwords...");
this.runHibpChecks$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
complete: () => {
this._processingPhase.set(ProcessingPhase.Complete);
this._progressMessage.set("");
},
error: (_err: unknown) => {
// HIBP check error - silently ignore for prototype
},
});
} else {
this._processingPhase.set(ProcessingPhase.Complete);
this._progressMessage.set("");
this.finalizeItemStatuses();
}
}),
takeUntilDestroyed(this.destroyRef),
catchError((err: unknown) => {
this._processingPhase.set(ProcessingPhase.Error);
const errorMessage = err instanceof Error ? err.message : "An error occurred";
this._error.set(errorMessage);
return of(undefined);
}),
)
.subscribe();
}
/**
* Reset all state to initial values.
*/
resetState(): void {
this._items.set([]);
this._processingPhase.set(ProcessingPhase.Idle);
this._progressMessage.set("");
this._cipherProgress.set({ current: 0, total: 0, percent: 0 });
this._healthProgress.set({ current: 0, total: 0, percent: 0 });
this._memberProgress.set({ current: 0, total: 0, percent: 0 });
this._hibpProgress.set({ current: 0, total: 0, percent: 0 });
this._error.set(null);
this.cipherIndexMap.clear();
this.allCiphers = [];
this.passwordUseMap.clear();
}
// ============================================================================
// Private Methods - Health Checks
// ============================================================================
private runHealthChecksIfEnabled$(): Observable<void> {
const enableWeak = this._enableWeakPassword();
const enableReused = this._enableReusedPassword();
if (!enableWeak && !enableReused) {
// No health checks enabled, skip this phase
return of(undefined);
}
this._processingPhase.set(ProcessingPhase.RunningHealthChecks);
this._progressMessage.set("Analyzing password health...");
const totalCiphers = this.allCiphers.length;
let processedCount = 0;
return this.riskInsightsService.checkWeakPasswordsBatched$(this.allCiphers, 100).pipe(
tap((weakResults) => {
// Update items with weak password results
const currentItems = [...this._items()];
const reusedCipherIds = this.riskInsightsService.findReusedPasswordCipherIds(
currentItems.map((i) => i.cipherId),
this.passwordUseMap,
this.allCiphers,
);
for (const result of weakResults) {
const index = this.cipherIndexMap.get(result.cipherId);
if (index === undefined) {
continue;
}
let item = currentItems[index];
// Update weak password status if enabled
if (enableWeak) {
item = this.riskInsightsService.updateItemWithWeakPassword(
item,
result.weakPasswordDetail,
enableWeak,
enableReused,
this._enableHibp(),
);
}
// Update reused password status if enabled
if (enableReused) {
const isReused = reusedCipherIds.has(result.cipherId);
item = this.riskInsightsService.updateItemWithReusedPassword(
item,
isReused,
enableWeak,
enableReused,
this._enableHibp(),
);
}
currentItems[index] = item;
processedCount++;
}
this._items.set(currentItems);
this._healthProgress.set({
current: processedCount,
total: totalCiphers,
percent: Math.round((processedCount / totalCiphers) * 100),
});
}),
map((): void => undefined),
);
}
// ============================================================================
// Private Methods - Member Counts
// ============================================================================
private runMemberCountsLoading$(): Observable<void> {
if (!this.organizationId || !this.currentUserId) {
return of(undefined);
}
this._processingPhase.set(ProcessingPhase.LoadingMembers);
this._progressMessage.set("Loading member access data...");
const BATCH_SIZE = 200;
let lastUpdateTime = 0;
const UPDATE_THROTTLE_MS = 100;
return this.cipherAccessMappingService
.getAllCiphersWithMemberAccessProgressive$(
this.organizationId,
this.currentUserId,
this.allCiphers,
BATCH_SIZE,
)
.pipe(
tap((progressResult) => {
const now = performance.now();
const isComplete = progressResult.state === MemberAccessLoadState.Complete;
const shouldUpdate = isComplete || now - lastUpdateTime >= UPDATE_THROTTLE_MS;
if (shouldUpdate) {
lastUpdateTime = now;
this._memberProgress.set({
current: progressResult.processedCount,
total: progressResult.totalCipherCount,
percent: progressResult.progressPercent,
});
this._progressMessage.set(
`Loading member access: ${progressResult.processedCount}/${progressResult.totalCipherCount}`,
);
// Update items with member counts
this.updateItemsWithMemberCounts(progressResult.processedCiphers);
}
}),
last(),
map((): void => undefined),
catchError((_err: unknown) => {
// Member access error - silently continue for prototype
return of(undefined);
}),
);
}
private updateItemsWithMemberCounts(
processedCiphers: Array<{ cipher: CipherView; totalMemberCount: number }>,
): void {
const currentItems = [...this._items()];
let hasChanges = false;
for (const processed of processedCiphers) {
const index = this.cipherIndexMap.get(processed.cipher.id);
if (index === undefined) {
continue;
}
const item = currentItems[index];
if (item.memberCount === processed.totalMemberCount) {
continue;
}
currentItems[index] = this.riskInsightsService.updateItemWithMemberCount(
item,
processed.totalMemberCount,
);
hasChanges = true;
}
if (hasChanges) {
this._items.set(currentItems);
}
}
// ============================================================================
// Private Methods - HIBP Checks
// ============================================================================
private runHibpChecks$(): Observable<void> {
const validCiphers = this.allCiphers.filter(
(c) => c.login?.password && !c.isDeleted && c.viewPassword,
);
if (validCiphers.length === 0) {
return of(undefined);
}
return this.passwordHealthService.auditPasswordLeaksProgressive$(validCiphers, 500).pipe(
tap((result) => {
this._hibpProgress.set({
current: result.checkedCount,
total: result.totalCount,
percent: result.progressPercent,
});
this._progressMessage.set(
`Checking exposed passwords: ${result.checkedCount}/${result.totalCount}`,
);
// Update items with exposed password data
this.updateItemsWithExposedPasswords(result.exposedPasswords);
}),
last(),
map((): void => undefined),
);
}
private updateItemsWithExposedPasswords(
exposedPasswords: Array<{ cipherId: string; exposedXTimes: number }>,
): void {
const currentItems = [...this._items()];
const exposedMap = new Map(exposedPasswords.map((ep) => [ep.cipherId, ep.exposedXTimes]));
let hasChanges = false;
// Update exposed items
for (const [cipherId, exposedCount] of exposedMap) {
const index = this.cipherIndexMap.get(cipherId);
if (index === undefined) {
continue;
}
const item = currentItems[index];
currentItems[index] = this.riskInsightsService.updateItemWithExposedPassword(
item,
exposedCount,
this._enableWeakPassword(),
this._enableReusedPassword(),
this._enableHibp(),
);
hasChanges = true;
}
// Mark items not in exposed list as not exposed
for (let i = 0; i < currentItems.length; i++) {
const item = currentItems[i];
if (item.exposedPassword === null && !exposedMap.has(item.cipherId)) {
currentItems[i] = this.riskInsightsService.updateItemWithExposedPassword(
item,
0,
this._enableWeakPassword(),
this._enableReusedPassword(),
this._enableHibp(),
);
hasChanges = true;
}
}
if (hasChanges) {
this._items.set(currentItems);
}
}
// ============================================================================
// Private Methods - Finalization
// ============================================================================
/**
* Finalize item statuses when no HIBP check is enabled.
* Sets all items to healthy if no checks are enabled, or calculates final status.
*/
private finalizeItemStatuses(): void {
const enableWeak = this._enableWeakPassword();
const enableReused = this._enableReusedPassword();
const enableHibp = this._enableHibp();
const currentItems = [...this._items()];
let hasChanges = false;
for (let i = 0; i < currentItems.length; i++) {
const item = currentItems[i];
// If no checks enabled, mark as healthy
if (!enableWeak && !enableReused && !enableHibp) {
if (item.status !== RiskInsightsItemStatus.Healthy) {
currentItems[i] = {
...item,
status: RiskInsightsItemStatus.Healthy,
};
hasChanges = true;
}
continue;
}
// Calculate final status based on enabled checks
const newStatus = calculateRiskStatus(
item.weakPassword,
item.reusedPassword,
enableHibp ? item.exposedPassword : false, // If HIBP not enabled, don't count as factor
enableWeak,
enableReused,
enableHibp,
);
if (item.status !== newStatus) {
currentItems[i] = {
...item,
status: newStatus,
};
hasChanges = true;
}
}
if (hasChanges) {
this._items.set(currentItems);
}
}
}

View File

@@ -0,0 +1,330 @@
import { Injectable, inject } from "@angular/core";
import { from, Observable } from "rxjs";
import { concatMap, map, toArray } from "rxjs/operators";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import type { WeakPasswordDetail } from "../../models/password-health";
import { PasswordHealthService } from "./password-health.service";
import {
createRiskInsightsItem,
RiskInsightsItem,
RiskInsightsItemStatus,
calculateRiskStatus,
} from "./risk-insights-prototype.types";
/**
* Result of weak password check for a single cipher
*/
export interface WeakPasswordCheckResult {
cipherId: string;
weakPasswordDetail: WeakPasswordDetail;
}
/**
* Service for transforming and processing cipher data for the Risk Insights Prototype.
*
* Handles:
* - Transforming CipherView to RiskInsightsItem
* - Running weak password checks in batches
* - Building password use maps for reuse detection
* - Calculating risk status
*/
@Injectable()
export class RiskInsightsPrototypeService {
private readonly passwordHealthService = inject(PasswordHealthService);
/**
* Transform ciphers to RiskInsightsItems with placeholder values.
* The items will be progressively enriched with health and member data.
*
* @param ciphers The ciphers to transform
* @returns Array of RiskInsightsItems with placeholder values
*/
transformCiphersToItems(ciphers: CipherView[]): RiskInsightsItem[] {
return ciphers
.filter((cipher) => this.isValidCipher(cipher))
.map((cipher) => createRiskInsightsItem(cipher));
}
/**
* Check weak passwords for a batch of ciphers using requestAnimationFrame
* to avoid blocking the UI.
*
* @param ciphers The ciphers to check
* @param batchSize Number of ciphers to process per frame (default: 100)
* @returns Observable that emits weak password results for each batch
*/
checkWeakPasswordsBatched$(
ciphers: CipherView[],
batchSize: number = 100,
): Observable<WeakPasswordCheckResult[]> {
const validCiphers = ciphers.filter((c) => this.isValidCipher(c));
const batches = this.createBatches(validCiphers, batchSize);
return from(batches).pipe(
concatMap((batch, batchIndex) =>
this.processWeakPasswordBatch(batch, batchIndex, batches.length),
),
toArray(),
map((batchResults) => batchResults.flat()),
);
}
/**
* Process a single batch of ciphers for weak passwords using requestAnimationFrame.
*/
private processWeakPasswordBatch(
batch: CipherView[],
_batchIndex: number,
_totalBatches: number,
): Observable<WeakPasswordCheckResult[]> {
return new Observable<WeakPasswordCheckResult[]>((subscriber) => {
requestAnimationFrame(() => {
const results: WeakPasswordCheckResult[] = batch.map((cipher) => ({
cipherId: cipher.id,
weakPasswordDetail: this.passwordHealthService.findWeakPasswordDetails(cipher),
}));
subscriber.next(results);
subscriber.complete();
});
});
}
/**
* Build a map of password hashes to cipher IDs for detecting reused passwords.
* Only includes ciphers with valid passwords.
*
* @param ciphers The ciphers to analyze
* @returns Map where key is password hash, value is array of cipher IDs using that password
*/
buildPasswordUseMap(ciphers: CipherView[]): Map<string, string[]> {
const passwordUseMap = new Map<string, string[]>();
for (const cipher of ciphers) {
if (!this.isValidCipher(cipher)) {
continue;
}
const password = cipher.login?.password;
if (!password) {
continue;
}
// Use a simple hash of the password as the key
// This avoids storing actual passwords in memory
const passwordKey = this.hashPassword(password);
const existing = passwordUseMap.get(passwordKey);
if (existing) {
existing.push(cipher.id);
} else {
passwordUseMap.set(passwordKey, [cipher.id]);
}
}
return passwordUseMap;
}
/**
* Check which ciphers have reused passwords based on a password use map.
*
* @param cipherIds The cipher IDs to check
* @param passwordUseMap Map of password hashes to cipher IDs
* @param ciphers The original ciphers (for password lookup)
* @returns Set of cipher IDs that have reused passwords
*/
findReusedPasswordCipherIds(
cipherIds: string[],
passwordUseMap: Map<string, string[]>,
ciphers: CipherView[],
): Set<string> {
const reusedCipherIds = new Set<string>();
const cipherMap = new Map(ciphers.map((c) => [c.id, c]));
for (const cipherId of cipherIds) {
const cipher = cipherMap.get(cipherId);
if (!cipher || !this.isValidCipher(cipher)) {
continue;
}
const password = cipher.login?.password;
if (!password) {
continue;
}
const passwordKey = this.hashPassword(password);
const usedBy = passwordUseMap.get(passwordKey);
// Password is reused if more than one cipher uses it
if (usedBy && usedBy.length > 1) {
reusedCipherIds.add(cipherId);
}
}
return reusedCipherIds;
}
/**
* Update an item with weak password status.
*/
updateItemWithWeakPassword(
item: RiskInsightsItem,
weakPasswordDetail: WeakPasswordDetail,
enableWeakCheck: boolean,
enableReusedCheck: boolean,
enableHibpCheck: boolean,
): RiskInsightsItem {
const weakPassword = weakPasswordDetail !== null;
const newStatus = calculateRiskStatus(
weakPassword,
item.reusedPassword,
item.exposedPassword,
enableWeakCheck,
enableReusedCheck,
enableHibpCheck,
);
return {
...item,
weakPassword,
status: newStatus,
};
}
/**
* Update an item with reused password status.
*/
updateItemWithReusedPassword(
item: RiskInsightsItem,
isReused: boolean,
enableWeakCheck: boolean,
enableReusedCheck: boolean,
enableHibpCheck: boolean,
): RiskInsightsItem {
const newStatus = calculateRiskStatus(
item.weakPassword,
isReused,
item.exposedPassword,
enableWeakCheck,
enableReusedCheck,
enableHibpCheck,
);
return {
...item,
reusedPassword: isReused,
status: newStatus,
};
}
/**
* Update an item with exposed password status.
*/
updateItemWithExposedPassword(
item: RiskInsightsItem,
exposedCount: number,
enableWeakCheck: boolean,
enableReusedCheck: boolean,
enableHibpCheck: boolean,
): RiskInsightsItem {
const exposedPassword = exposedCount > 0;
const newStatus = calculateRiskStatus(
item.weakPassword,
item.reusedPassword,
exposedPassword,
enableWeakCheck,
enableReusedCheck,
enableHibpCheck,
);
return {
...item,
exposedPassword,
exposedCount,
status: newStatus,
};
}
/**
* Update an item with member count.
*/
updateItemWithMemberCount(item: RiskInsightsItem, memberCount: number): RiskInsightsItem {
return {
...item,
memberCount,
memberAccessPending: false,
};
}
/**
* Update an item's status when all checks are complete.
* This is called when no health checks are enabled but we still need a status.
*/
finalizeItemStatus(
item: RiskInsightsItem,
enableWeakCheck: boolean,
enableReusedCheck: boolean,
enableHibpCheck: boolean,
): RiskInsightsItem {
// If no checks enabled, mark as healthy
if (!enableWeakCheck && !enableReusedCheck && !enableHibpCheck) {
return {
...item,
status: RiskInsightsItemStatus.Healthy,
};
}
return item;
}
/**
* Create batches from an array.
*/
private createBatches<T>(items: T[], batchSize: number): T[][] {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize));
}
return batches;
}
/**
* Simple hash function for password deduplication.
* Uses a basic string hash for performance.
*/
private hashPassword(password: string): string {
let hash = 0;
for (let i = 0; i < password.length; i++) {
const char = password.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString(36);
}
/**
* Validates that the cipher is a login item with a valid password.
*/
private isValidCipher(cipher: CipherView): boolean {
if (!cipher) {
return false;
}
const { type, login, isDeleted, viewPassword } = cipher;
if (
type !== CipherType.Login ||
!login?.password ||
Utils.isNullOrWhitespace(login.password) ||
isDeleted ||
!viewPassword
) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,12 @@
/**
* Re-export types from libs/common for backwards compatibility
* Types are defined in libs/common so they can be imported by the web vault
*/
export {
RiskInsightsItemStatus,
ProcessingPhase,
ProgressInfo,
RiskInsightsItem,
createRiskInsightsItem,
calculateRiskStatus,
} from "@bitwarden/common/dirt/reports/risk-insights/types";

View File

@@ -2,10 +2,15 @@ export * from "./api/critical-apps-api.service";
export * from "./api/member-cipher-details-api.service";
export * from "./api/risk-insights-api.service";
export * from "./api/security-tasks-api.service";
export * from "./domain/cipher-access-mapping.service";
export * from "./domain/cipher-health-orchestration.service";
export * from "./domain/critical-apps.service";
export * from "./domain/password-health.service";
export * from "./domain/risk-insights-encryption.service";
export * from "./domain/risk-insights-orchestrator.service";
export * from "./domain/risk-insights-report.service";
export * from "./domain/risk-insights-prototype.types";
export * from "./domain/risk-insights-prototype.service";
export * from "./domain/risk-insights-prototype-orchestration.service";
export * from "./view/all-activities.service";
export * from "./view/risk-insights-data.service";

View File

@@ -505,7 +505,11 @@ const safeProviders: SafeProvider[] = [
}),
safeProvider({
provide: AuditServiceAbstraction,
useClass: AuditService,
useFactory: (
cryptoFunctionService: CryptoFunctionServiceAbstraction,
apiService: ApiServiceAbstraction,
hibpApiService: HibpApiService,
) => new AuditService(cryptoFunctionService, apiService, hibpApiService),
deps: [CryptoFunctionServiceAbstraction, ApiServiceAbstraction, HibpApiService],
}),
safeProvider({

View File

@@ -1,2 +1,3 @@
export * from "./models";
export * from "./reports";
export * from "./services";

View File

@@ -0,0 +1 @@
export * from "./risk-insights";

View File

@@ -0,0 +1 @@
export * from "./risk-insights-prototype-orchestration.service.abstraction";

View File

@@ -0,0 +1,61 @@
import { OrganizationId } from "@bitwarden/common/types/guid";
import { ProcessingPhase, ProgressInfo, RiskInsightsItem } from "../types";
/**
* Generic read-only signal interface for framework-agnostic abstraction.
* Implementations can use Angular Signals or other reactive primitives.
*/
export interface ReadonlySignal<T> {
(): T;
}
/**
* Abstraction for the Risk Insights Prototype Orchestration Service.
*
* Coordinates progressive loading in phases:
* - Phase 1: Load ciphers and display immediately
* - Phase 2: Run health checks (weak + reused) if enabled
* - Phase 3: Load member counts progressively
* - Phase 4: Run HIBP checks last (if enabled)
*/
export abstract class RiskInsightsPrototypeOrchestrationServiceAbstraction {
// Configuration flags (read-only signals)
abstract readonly enableWeakPassword: ReadonlySignal<boolean>;
abstract readonly enableHibp: ReadonlySignal<boolean>;
abstract readonly enableReusedPassword: ReadonlySignal<boolean>;
// Processing state (read-only signals)
abstract readonly processingPhase: ReadonlySignal<ProcessingPhase>;
abstract readonly progressMessage: ReadonlySignal<string>;
// Progress tracking (read-only signals)
abstract readonly cipherProgress: ReadonlySignal<ProgressInfo>;
abstract readonly healthProgress: ReadonlySignal<ProgressInfo>;
abstract readonly memberProgress: ReadonlySignal<ProgressInfo>;
abstract readonly hibpProgress: ReadonlySignal<ProgressInfo>;
// Results (read-only signal)
abstract readonly items: ReadonlySignal<RiskInsightsItem[]>;
// Error state (read-only signal)
abstract readonly error: ReadonlySignal<string | null>;
// Expose constants for template access
abstract readonly ProcessingPhase: typeof ProcessingPhase;
// Initialization
abstract initializeForOrganization(organizationId: OrganizationId): void;
// Configuration toggles
abstract toggleEnableWeakPassword(): void;
abstract toggleEnableHibp(): void;
abstract toggleEnableReusedPassword(): void;
abstract setEnableWeakPassword(enabled: boolean): void;
abstract setEnableHibp(enabled: boolean): void;
abstract setEnableReusedPassword(enabled: boolean): void;
// Actions
abstract startProcessing(): void;
abstract resetState(): void;
}

View File

@@ -0,0 +1,2 @@
export * from "./abstractions";
export * from "./types";

View File

@@ -0,0 +1 @@
export * from "./risk-insights-prototype.types";

View File

@@ -0,0 +1,132 @@
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
/**
* Status of a risk insights item (per ADR-0025 - no enums)
*/
export const RiskInsightsItemStatus = Object.freeze({
Healthy: "healthy",
AtRisk: "at-risk",
} as const);
export type RiskInsightsItemStatus =
(typeof RiskInsightsItemStatus)[keyof typeof RiskInsightsItemStatus];
/**
* Processing phase for the risk insights prototype (per ADR-0025 - no enums)
*/
export const ProcessingPhase = Object.freeze({
Idle: "idle",
LoadingCiphers: "loading-ciphers",
RunningHealthChecks: "running-health-checks",
LoadingMembers: "loading-members",
RunningHibp: "running-hibp",
Complete: "complete",
Error: "error",
} as const);
export type ProcessingPhase = (typeof ProcessingPhase)[keyof typeof ProcessingPhase];
/**
* Progress information for tracking operation completion
*/
export interface ProgressInfo {
/** Number of items processed so far */
current: number;
/** Total number of items to process */
total: number;
/** Progress percentage (0-100) */
percent: number;
}
/**
* Represents a single item in the risk insights report
*/
export interface RiskInsightsItem {
/** Unique identifier for the cipher */
cipherId: string;
/** Display name of the cipher */
cipherName: string;
/** Subtitle for the cipher (typically username) */
cipherSubtitle: string;
// Health status columns - null means not checked or pending
/** Whether the cipher has a weak password (null = not checked) */
weakPassword: boolean | null;
/** Whether the cipher has a reused password (null = not checked) */
reusedPassword: boolean | null;
/** Whether the cipher has an exposed password (null = not checked or pending) */
exposedPassword: boolean | null;
/** Number of times the password was exposed in breaches */
exposedCount: number | null;
// Member data
/** Number of members with access to this cipher (null = pending) */
memberCount: number | null;
/** Whether member access data is still being loaded */
memberAccessPending: boolean;
// Computed status
/** Overall risk status (null = still calculating) */
status: RiskInsightsItemStatus | null;
// Reference to full cipher for detail view
/** The underlying cipher view object */
cipher: CipherView;
}
/**
* Creates an initial RiskInsightsItem from a cipher with placeholder values
*/
export function createRiskInsightsItem(cipher: CipherView): RiskInsightsItem {
return {
cipherId: cipher.id,
cipherName: cipher.name || "(no name)",
cipherSubtitle: cipher.login?.username || "",
weakPassword: null,
reusedPassword: null,
exposedPassword: null,
exposedCount: null,
memberCount: null,
memberAccessPending: true,
status: null,
cipher,
};
}
/**
* Calculates the at-risk status based on password health flags
* @param weakPassword Whether the password is weak
* @param reusedPassword Whether the password is reused
* @param exposedPassword Whether the password is exposed
* @returns The risk status, or null if any required check is still pending
*/
export function calculateRiskStatus(
weakPassword: boolean | null,
reusedPassword: boolean | null,
exposedPassword: boolean | null,
enableWeakCheck: boolean,
enableReusedCheck: boolean,
enableHibpCheck: boolean,
): RiskInsightsItemStatus | null {
// If no checks are enabled, status is healthy
if (!enableWeakCheck && !enableReusedCheck && !enableHibpCheck) {
return RiskInsightsItemStatus.Healthy;
}
// Check if any enabled check is still pending
if (enableWeakCheck && weakPassword === null) {
return null;
}
if (enableReusedCheck && reusedPassword === null) {
return null;
}
if (enableHibpCheck && exposedPassword === null) {
return null;
}
// Check for at-risk conditions based on enabled checks
const isAtRisk =
(enableWeakCheck && weakPassword === true) ||
(enableReusedCheck && reusedPassword === true) ||
(enableHibpCheck && exposedPassword === true);
return isAtRisk ? RiskInsightsItemStatus.AtRisk : RiskInsightsItemStatus.Healthy;
}

View File

@@ -21,9 +21,7 @@ export class AuditService implements AuditServiceAbstraction {
private cryptoFunctionService: CryptoFunctionService,
private apiService: ApiService,
private hibpApiService: HibpApiService,
private readonly maxConcurrent: number = 100, // default to 100, can be overridden
) {
this.maxConcurrent = maxConcurrent;
this.passwordLeakedSubject
.pipe(
mergeMap(
@@ -36,7 +34,7 @@ export class AuditService implements AuditServiceAbstraction {
req.reject(err);
}
},
this.maxConcurrent, // Limit concurrent API calls
100, // Limit concurrent API calls
),
)
.subscribe();