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