diff --git a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts index 9e33986b87d..da34f3a23f1 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts @@ -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()], + }, ], }, { diff --git a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting.module.ts b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting.module.ts index 46599d7da46..d69f6ee95e2 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting.module.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting.module.ts @@ -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 {} diff --git a/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts b/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts index 6043bfd3193..7829c349029 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts @@ -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; diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.html b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.html new file mode 100644 index 00000000000..85940f3d154 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.html @@ -0,0 +1,6 @@ +
+

{{ "applications" | i18n }}

+

+ {{ "riskInsightsPrototypeApplicationsPlaceholder" | i18n }} +

+
diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.ts b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.ts new file mode 100644 index 00000000000..37a2280c8de --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/applications/risk-insights-prototype-applications.component.ts @@ -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 {} diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.html b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.html new file mode 100644 index 00000000000..da91c174d69 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.html @@ -0,0 +1,222 @@ +
+ +
+
+ + + + + + + + +
+ + + +
+ + + @if (showProgress()) { +
+ +
+
+ {{ progressMessage() || "Processing..." }} + {{ getOverallProgress() | number: "1.0-0" }}% +
+ +
+ + + @if (enableHibp() && processingPhase() === ProcessingPhase.RunningHibp) { +
+
+ + {{ "checkingExposedPasswords" | i18n }}: {{ hibpProgress().current }} / + {{ hibpProgress().total }} + + {{ hibpProgress().percent }}% +
+ +
+ } + + + @if (processingPhase() === ProcessingPhase.Complete) { +
+ + {{ "reportComplete" | i18n }} +
+ } + + + @if (processingPhase() === ProcessingPhase.Error && error()) { +
+ + {{ error() }} +
+ } +
+ } + + + @if (items().length > 0) { +
+ {{ "totalItems" | i18n }}: {{ items().length | number }} +
+ + + + {{ "name" | i18n }} + {{ "weak" | i18n }} + {{ "reused" | i18n }} + {{ "exposed" | i18n }} + {{ "members" | i18n }} + {{ "status" | i18n }} + + + + + +
+ {{ row.cipherName }} +
+ @if (row.cipherSubtitle) { +
+ {{ row.cipherSubtitle }} +
+ } + + + + + @if (row.weakPassword === null) { + @if (enableWeakPassword()) { + + } @else { + - + } + } @else if (row.weakPassword) { + {{ "weak" | i18n }} + } @else { + + } + + + + + @if (row.reusedPassword === null) { + @if (enableReusedPassword()) { + + } @else { + - + } + } @else if (row.reusedPassword) { + {{ "yes" | i18n }} + } @else { + + } + + + + + @if (row.exposedPassword === null) { + @if (enableHibp()) { + + } @else { + - + } + } @else if (row.exposedPassword) { + + {{ row.exposedCount | number }}x + + } @else { + + } + + + + + @if (row.memberAccessPending) { + + } @else { + {{ row.memberCount | number }} + } + + + + + @if (row.status === null) { + + } @else if (row.status === RiskInsightsItemStatus.AtRisk) { + {{ "atRisk" | i18n }} + } @else { + {{ "healthy" | i18n }} + } + +
+
+ } @else if (processingPhase() === ProcessingPhase.Idle) { + +
+ +

{{ "riskInsightsPrototypeItemsPlaceholder" | i18n }}

+
+ } +
diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.ts b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.ts new file mode 100644 index 00000000000..e2de535d757 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/items/risk-insights-prototype-items.component.ts @@ -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(); + + /** 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; + } +} diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.html b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.html new file mode 100644 index 00000000000..f60756ac55f --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.html @@ -0,0 +1,6 @@ +
+

{{ "members" | i18n }}

+

+ {{ "riskInsightsPrototypeMembersPlaceholder" | i18n }} +

+
diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.ts b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.ts new file mode 100644 index 00000000000..80bf6943ee7 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/members/risk-insights-prototype-members.component.ts @@ -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 {} diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.html b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.html new file mode 100644 index 00000000000..03429ee2fac --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.html @@ -0,0 +1,23 @@ + + +
+
+

+ {{ "riskInsightsPrototypeDesc" | i18n }} +

+
+ +
+ + + + + + + + + + + +
+
diff --git a/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.ts b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.ts new file mode 100644 index 00000000000..cda4477631b --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/reporting/risk-insights-prototype/risk-insights-prototype.component.ts @@ -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 { + await this.router.navigate([], { + relativeTo: this.route, + queryParams: { tabIndex: newIndex }, + queryParamsHandling: "merge", + }); + } +} diff --git a/apps/web/src/app/dirt/reports/reports.ts b/apps/web/src/app/dirt/reports/reports.ts index eb24f780021..b7ad8aed5c0 100644 --- a/apps/web/src/app/dirt/reports/reports.ts +++ b/apps/web/src/app/dirt/reports/reports.ts @@ -20,6 +20,8 @@ export enum ReportType { Inactive2fa = "inactive2fa", DataBreach = "dataBreach", MemberAccessReport = "memberAccessReport", + CipherHealthTest = "cipherHealthTest", + RiskInsightsPrototype = "riskInsightsPrototype", } type ReportWithoutVariant = Omit; @@ -67,4 +69,16 @@ export const reports: Record = { 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, + }, }; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index ecad9f8a624..41e92819c30 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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" }, diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/cipher-access-mapping.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/cipher-access-mapping.service.spec.ts new file mode 100644 index 00000000000..745b3e0292a --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/cipher-access-mapping.service.spec.ts @@ -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; + let collectionAdminService: MockProxy; + let organizationUserApiService: MockProxy; + let logService: MockProxy; + + const mockOrgId = "org123" as OrganizationId; + const mockUserId = "user123" as UserId; + + beforeEach(() => { + cipherService = mock(); + collectionAdminService = mock(); + organizationUserApiService = mock(); + logService = mock(); + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/cipher-access-mapping.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/cipher-access-mapping.service.ts new file mode 100644 index 00000000000..ad437917d9b --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/cipher-access-mapping.service.ts @@ -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; +} + +/** + * 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; + + /** Entity counts (partial until complete) */ + counts: Partial; + + /** Error message if state is Error */ + error?: string; +} + +/** + * Cached organization users data to avoid duplicate API calls + */ +interface CachedOrganizationUsers { + organizationId: OrganizationId; + groupMemberMap: Map; + userEmailMap: Map; + 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 { + 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(); + 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(); + + // 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(); + 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 { + 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 { + 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(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 { + 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 { + 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 { + 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 { + const timings: Partial = {}; + const counts: Partial = { 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(); + 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((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> { + const groupMemberMap = new Map(); + + // 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> { + const userEmailMap = new Map(); + + 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; + userEmailMap: Map; + }> { + // 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(); + const userEmailMap = new Map(); + + 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, + groupMemberMap: Map, + userEmailMap: Map, + ): CipherWithMemberAccess[] { + const results: CipherWithMemberAccess[] = []; + + for (const cipher of ciphers) { + const memberAccessMap = new Map(); + 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; + } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts index 2d94bf828b8..11fc7a28b64 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/password-health.service.ts @@ -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 { + 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. diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype-orchestration.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype-orchestration.service.ts new file mode 100644 index 00000000000..b26c228751c --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype-orchestration.service.ts @@ -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(); + private allCiphers: CipherView[] = []; + private passwordUseMap: Map = 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.Idle); + private readonly _progressMessage = signal(""); + + // Progress tracking + private readonly _cipherProgress = signal({ current: 0, total: 0, percent: 0 }); + private readonly _healthProgress = signal({ current: 0, total: 0, percent: 0 }); + private readonly _memberProgress = signal({ current: 0, total: 0, percent: 0 }); + private readonly _hibpProgress = signal({ current: 0, total: 0, percent: 0 }); + + // Results + private readonly _items = signal([]); + + // Error state + private readonly _error = signal(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 { + 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 { + 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 { + 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); + } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.service.ts new file mode 100644 index 00000000000..d177fe8e56b --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.service.ts @@ -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 { + 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 { + return new Observable((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 { + const passwordUseMap = new Map(); + + 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, + ciphers: CipherView[], + ): Set { + const reusedCipherIds = new Set(); + 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(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; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.types.ts new file mode 100644 index 00000000000..d3f1ab366fa --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-prototype.types.ts @@ -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"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts index 1e14c09d089..4fddb5a2011 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/index.ts @@ -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"; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 5eaac4033eb..f04cce88244 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -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({ diff --git a/libs/common/src/dirt/index.ts b/libs/common/src/dirt/index.ts index 3c3b6b6476a..942f7675253 100644 --- a/libs/common/src/dirt/index.ts +++ b/libs/common/src/dirt/index.ts @@ -1,2 +1,3 @@ export * from "./models"; +export * from "./reports"; export * from "./services"; diff --git a/libs/common/src/dirt/reports/index.ts b/libs/common/src/dirt/reports/index.ts new file mode 100644 index 00000000000..5ce0afc17eb --- /dev/null +++ b/libs/common/src/dirt/reports/index.ts @@ -0,0 +1 @@ +export * from "./risk-insights"; diff --git a/libs/common/src/dirt/reports/risk-insights/abstractions/index.ts b/libs/common/src/dirt/reports/risk-insights/abstractions/index.ts new file mode 100644 index 00000000000..ffae2ae5f5a --- /dev/null +++ b/libs/common/src/dirt/reports/risk-insights/abstractions/index.ts @@ -0,0 +1 @@ +export * from "./risk-insights-prototype-orchestration.service.abstraction"; diff --git a/libs/common/src/dirt/reports/risk-insights/abstractions/risk-insights-prototype-orchestration.service.abstraction.ts b/libs/common/src/dirt/reports/risk-insights/abstractions/risk-insights-prototype-orchestration.service.abstraction.ts new file mode 100644 index 00000000000..ef002edc538 --- /dev/null +++ b/libs/common/src/dirt/reports/risk-insights/abstractions/risk-insights-prototype-orchestration.service.abstraction.ts @@ -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; +} + +/** + * 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; + abstract readonly enableHibp: ReadonlySignal; + abstract readonly enableReusedPassword: ReadonlySignal; + + // Processing state (read-only signals) + abstract readonly processingPhase: ReadonlySignal; + abstract readonly progressMessage: ReadonlySignal; + + // Progress tracking (read-only signals) + abstract readonly cipherProgress: ReadonlySignal; + abstract readonly healthProgress: ReadonlySignal; + abstract readonly memberProgress: ReadonlySignal; + abstract readonly hibpProgress: ReadonlySignal; + + // Results (read-only signal) + abstract readonly items: ReadonlySignal; + + // Error state (read-only signal) + abstract readonly error: ReadonlySignal; + + // 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; +} diff --git a/libs/common/src/dirt/reports/risk-insights/index.ts b/libs/common/src/dirt/reports/risk-insights/index.ts new file mode 100644 index 00000000000..136680d9746 --- /dev/null +++ b/libs/common/src/dirt/reports/risk-insights/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions"; +export * from "./types"; diff --git a/libs/common/src/dirt/reports/risk-insights/types/index.ts b/libs/common/src/dirt/reports/risk-insights/types/index.ts new file mode 100644 index 00000000000..8803eba3ca1 --- /dev/null +++ b/libs/common/src/dirt/reports/risk-insights/types/index.ts @@ -0,0 +1 @@ +export * from "./risk-insights-prototype.types"; diff --git a/libs/common/src/dirt/reports/risk-insights/types/risk-insights-prototype.types.ts b/libs/common/src/dirt/reports/risk-insights/types/risk-insights-prototype.types.ts new file mode 100644 index 00000000000..5813239a438 --- /dev/null +++ b/libs/common/src/dirt/reports/risk-insights/types/risk-insights-prototype.types.ts @@ -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; +} diff --git a/libs/common/src/services/audit.service.ts b/libs/common/src/services/audit.service.ts index 7762c2cbd93..68c73d66890 100644 --- a/libs/common/src/services/audit.service.ts +++ b/libs/common/src/services/audit.service.ts @@ -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();