mirror of
https://github.com/bitwarden/browser
synced 2026-02-20 19:34:03 +00:00
initial prototype commit
This commit is contained in:
@@ -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()],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="tw-p-4">
|
||||
<h2 class="tw-text-xl tw-font-semibold tw-mb-4">{{ "applications" | i18n }}</h2>
|
||||
<p class="tw-text-muted">
|
||||
{{ "riskInsightsPrototypeApplicationsPlaceholder" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,222 @@
|
||||
<div class="tw-flex tw-flex-col tw-gap-4">
|
||||
<!-- Controls Section -->
|
||||
<div class="tw-p-4 tw-bg-background-alt tw-rounded-lg">
|
||||
<div class="tw-flex tw-flex-wrap tw-items-center tw-gap-6 tw-mb-4">
|
||||
<!-- Weak Password Checkbox -->
|
||||
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
[checked]="enableWeakPassword()"
|
||||
(change)="toggleWeakPassword()"
|
||||
[disabled]="isProcessing()"
|
||||
/>
|
||||
<span class="tw-text-main">{{ "enableWeakPasswordCheck" | i18n }}</span>
|
||||
</label>
|
||||
|
||||
<!-- HIBP Checkbox -->
|
||||
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
[checked]="enableHibp()"
|
||||
(change)="toggleHibp()"
|
||||
[disabled]="isProcessing()"
|
||||
/>
|
||||
<span class="tw-text-main">{{ "enableHibpCheck" | i18n }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Reused Password Checkbox -->
|
||||
<label class="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
[checked]="enableReusedPassword()"
|
||||
(change)="toggleReusedPassword()"
|
||||
[disabled]="isProcessing()"
|
||||
/>
|
||||
<span class="tw-text-main">{{ "enableReusedPasswordCheck" | i18n }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Run Report Button -->
|
||||
<button bitButton buttonType="primary" (click)="runReport()" [disabled]="isProcessing()">
|
||||
@if (isProcessing()) {
|
||||
<i class="bwi bwi-spinner bwi-spin tw-mr-2" aria-hidden="true"></i>
|
||||
{{ "processing" | i18n }}
|
||||
} @else {
|
||||
<i class="bwi bwi-play tw-mr-2" aria-hidden="true"></i>
|
||||
{{ "runReport" | i18n }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress Section -->
|
||||
@if (showProgress()) {
|
||||
<div class="tw-p-4 tw-bg-background-alt tw-rounded-lg">
|
||||
<!-- Main Progress -->
|
||||
<div class="tw-mb-4">
|
||||
<div class="tw-flex tw-justify-between tw-text-sm tw-mb-1">
|
||||
<span class="tw-text-muted">{{ progressMessage() || "Processing..." }}</span>
|
||||
<span class="tw-text-muted">{{ getOverallProgress() | number: "1.0-0" }}%</span>
|
||||
</div>
|
||||
<bit-progress [barWidth]="getOverallProgress()" bgColor="primary"></bit-progress>
|
||||
</div>
|
||||
|
||||
<!-- HIBP Progress (only when HIBP is enabled and running) -->
|
||||
@if (enableHibp() && processingPhase() === ProcessingPhase.RunningHibp) {
|
||||
<div>
|
||||
<div class="tw-flex tw-justify-between tw-text-sm tw-mb-1">
|
||||
<span class="tw-text-muted">
|
||||
{{ "checkingExposedPasswords" | i18n }}: {{ hibpProgress().current }} /
|
||||
{{ hibpProgress().total }}
|
||||
</span>
|
||||
<span class="tw-text-muted">{{ hibpProgress().percent }}%</span>
|
||||
</div>
|
||||
<bit-progress [barWidth]="hibpProgress().percent" bgColor="warning"></bit-progress>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Completion Status -->
|
||||
@if (processingPhase() === ProcessingPhase.Complete) {
|
||||
<div class="tw-flex tw-items-center tw-gap-2 tw-text-success-600">
|
||||
<i class="bwi bwi-check-circle" aria-hidden="true"></i>
|
||||
<span>{{ "reportComplete" | i18n }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error Status -->
|
||||
@if (processingPhase() === ProcessingPhase.Error && error()) {
|
||||
<div class="tw-flex tw-items-center tw-gap-2 tw-text-danger-600">
|
||||
<i class="bwi bwi-error" aria-hidden="true"></i>
|
||||
<span>{{ error() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Results Table -->
|
||||
@if (items().length > 0) {
|
||||
<div class="tw-text-sm tw-text-muted tw-mb-2">
|
||||
{{ "totalItems" | i18n }}: {{ items().length | number }}
|
||||
</div>
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="ROW_SIZE" [trackBy]="trackByItemId">
|
||||
<ng-container header>
|
||||
<th bitCell class="tw-min-w-[200px]">{{ "name" | i18n }}</th>
|
||||
<th bitCell class="tw-text-center tw-w-24">{{ "weak" | i18n }}</th>
|
||||
<th bitCell class="tw-text-center tw-w-24">{{ "reused" | i18n }}</th>
|
||||
<th bitCell class="tw-text-center tw-w-24">{{ "exposed" | i18n }}</th>
|
||||
<th bitCell class="tw-text-center tw-w-24">{{ "members" | i18n }}</th>
|
||||
<th bitCell class="tw-text-center tw-w-28">{{ "status" | i18n }}</th>
|
||||
</ng-container>
|
||||
|
||||
<ng-template bitRowDef let-row>
|
||||
<!-- Cipher Name -->
|
||||
<td bitCell>
|
||||
<div class="tw-font-medium tw-truncate tw-max-w-[200px]" [title]="row.cipherName">
|
||||
{{ row.cipherName }}
|
||||
</div>
|
||||
@if (row.cipherSubtitle) {
|
||||
<div class="tw-text-xs tw-text-muted tw-truncate tw-max-w-[200px]">
|
||||
{{ row.cipherSubtitle }}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
|
||||
<!-- Weak Password Status -->
|
||||
<td bitCell class="tw-text-center">
|
||||
@if (row.weakPassword === null) {
|
||||
@if (enableWeakPassword()) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
aria-hidden="true"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
></i>
|
||||
} @else {
|
||||
<span class="tw-text-muted">-</span>
|
||||
}
|
||||
} @else if (row.weakPassword) {
|
||||
<span bitBadge variant="warning">{{ "weak" | i18n }}</span>
|
||||
} @else {
|
||||
<i class="bwi bwi-check tw-text-success-600" aria-hidden="true"></i>
|
||||
}
|
||||
</td>
|
||||
|
||||
<!-- Reused Password Status -->
|
||||
<td bitCell class="tw-text-center">
|
||||
@if (row.reusedPassword === null) {
|
||||
@if (enableReusedPassword()) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
aria-hidden="true"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
></i>
|
||||
} @else {
|
||||
<span class="tw-text-muted">-</span>
|
||||
}
|
||||
} @else if (row.reusedPassword) {
|
||||
<span bitBadge variant="warning">{{ "yes" | i18n }}</span>
|
||||
} @else {
|
||||
<i class="bwi bwi-check tw-text-success-600" aria-hidden="true"></i>
|
||||
}
|
||||
</td>
|
||||
|
||||
<!-- Exposed Password Status -->
|
||||
<td bitCell class="tw-text-center">
|
||||
@if (row.exposedPassword === null) {
|
||||
@if (enableHibp()) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
aria-hidden="true"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
></i>
|
||||
} @else {
|
||||
<span class="tw-text-muted">-</span>
|
||||
}
|
||||
} @else if (row.exposedPassword) {
|
||||
<span bitBadge variant="danger" [title]="row.exposedCount + ' times'">
|
||||
{{ row.exposedCount | number }}x
|
||||
</span>
|
||||
} @else {
|
||||
<i class="bwi bwi-check tw-text-success-600" aria-hidden="true"></i>
|
||||
}
|
||||
</td>
|
||||
|
||||
<!-- Member Count -->
|
||||
<td bitCell class="tw-text-center">
|
||||
@if (row.memberAccessPending) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
aria-hidden="true"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
></i>
|
||||
} @else {
|
||||
<span>{{ row.memberCount | number }}</span>
|
||||
}
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td bitCell class="tw-text-center">
|
||||
@if (row.status === null) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
aria-hidden="true"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
></i>
|
||||
} @else if (row.status === RiskInsightsItemStatus.AtRisk) {
|
||||
<span bitBadge variant="danger">{{ "atRisk" | i18n }}</span>
|
||||
} @else {
|
||||
<span bitBadge variant="success">{{ "healthy" | i18n }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
} @else if (processingPhase() === ProcessingPhase.Idle) {
|
||||
<!-- Initial State -->
|
||||
<div class="tw-text-center tw-py-8 tw-text-muted">
|
||||
<i class="bwi bwi-report tw-text-4xl tw-mb-4" aria-hidden="true"></i>
|
||||
<p>{{ "riskInsightsPrototypeItemsPlaceholder" | i18n }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,215 @@
|
||||
/* eslint-disable no-restricted-imports -- Prototype feature using licensed services */
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
CipherAccessMappingService,
|
||||
PasswordHealthService,
|
||||
RiskInsightsPrototypeOrchestrationService,
|
||||
RiskInsightsPrototypeService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/services";
|
||||
import {
|
||||
ProcessingPhase,
|
||||
RiskInsightsItem,
|
||||
RiskInsightsItemStatus,
|
||||
} from "@bitwarden/common/dirt/reports/risk-insights";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
CheckboxModule,
|
||||
ProgressModule,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
/* eslint-enable no-restricted-imports */
|
||||
|
||||
/**
|
||||
* Items tab component for the Risk Insights Prototype.
|
||||
*
|
||||
* Displays a table of cipher items with health status and member counts.
|
||||
* Features:
|
||||
* - Progressive loading with status indicators
|
||||
* - Virtual scrolling table for large datasets
|
||||
* - Configurable health checks (weak, reused, exposed passwords)
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-risk-insights-prototype-items",
|
||||
templateUrl: "./risk-insights-prototype-items.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
TableModule,
|
||||
ProgressModule,
|
||||
CheckboxModule,
|
||||
ButtonModule,
|
||||
BadgeModule,
|
||||
],
|
||||
providers: [
|
||||
RiskInsightsPrototypeOrchestrationService,
|
||||
RiskInsightsPrototypeService,
|
||||
CipherAccessMappingService,
|
||||
PasswordHealthService,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class RiskInsightsPrototypeItemsComponent implements OnInit {
|
||||
// ============================================================================
|
||||
// Injected Dependencies
|
||||
// ============================================================================
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly orchestrator = inject(RiskInsightsPrototypeOrchestrationService);
|
||||
|
||||
// ============================================================================
|
||||
// Expose Orchestrator Signals to Template
|
||||
// ============================================================================
|
||||
|
||||
// Configuration flags
|
||||
readonly enableWeakPassword = this.orchestrator.enableWeakPassword;
|
||||
readonly enableHibp = this.orchestrator.enableHibp;
|
||||
readonly enableReusedPassword = this.orchestrator.enableReusedPassword;
|
||||
|
||||
// Processing state
|
||||
readonly processingPhase = this.orchestrator.processingPhase;
|
||||
readonly progressMessage = this.orchestrator.progressMessage;
|
||||
|
||||
// Progress tracking
|
||||
readonly cipherProgress = this.orchestrator.cipherProgress;
|
||||
readonly healthProgress = this.orchestrator.healthProgress;
|
||||
readonly memberProgress = this.orchestrator.memberProgress;
|
||||
readonly hibpProgress = this.orchestrator.hibpProgress;
|
||||
|
||||
// Results
|
||||
readonly items = this.orchestrator.items;
|
||||
|
||||
// Error state
|
||||
readonly error = this.orchestrator.error;
|
||||
|
||||
// Expose constants for template access
|
||||
readonly ProcessingPhase = ProcessingPhase;
|
||||
readonly RiskInsightsItemStatus = RiskInsightsItemStatus;
|
||||
|
||||
// ============================================================================
|
||||
// Component State
|
||||
// ============================================================================
|
||||
|
||||
/** Table data source for virtual scrolling */
|
||||
protected readonly dataSource = new TableDataSource<RiskInsightsItem>();
|
||||
|
||||
/** Row size for virtual scrolling (in pixels) */
|
||||
protected readonly ROW_SIZE = 52;
|
||||
|
||||
/** Whether the component has been initialized */
|
||||
protected readonly initialized = signal(false);
|
||||
|
||||
// ============================================================================
|
||||
// Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
constructor() {
|
||||
// Effect to sync items signal to table data source
|
||||
effect(() => {
|
||||
const items = this.items();
|
||||
this.dataSource.data = items;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Get organization ID from route and initialize orchestrator
|
||||
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||
const organizationId = params["organizationId"] as OrganizationId;
|
||||
if (organizationId) {
|
||||
this.orchestrator.initializeForOrganization(organizationId);
|
||||
this.initialized.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UI Actions
|
||||
// ============================================================================
|
||||
|
||||
/** Start processing - run the report */
|
||||
protected runReport(): void {
|
||||
this.orchestrator.startProcessing();
|
||||
}
|
||||
|
||||
/** Toggle weak password check */
|
||||
protected toggleWeakPassword(): void {
|
||||
this.orchestrator.toggleEnableWeakPassword();
|
||||
}
|
||||
|
||||
/** Toggle HIBP check */
|
||||
protected toggleHibp(): void {
|
||||
this.orchestrator.toggleEnableHibp();
|
||||
}
|
||||
|
||||
/** Toggle reused password check */
|
||||
protected toggleReusedPassword(): void {
|
||||
this.orchestrator.toggleEnableReusedPassword();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Computed Properties
|
||||
// ============================================================================
|
||||
|
||||
/** Check if processing is currently running */
|
||||
protected isProcessing(): boolean {
|
||||
const phase = this.processingPhase();
|
||||
return (
|
||||
phase !== ProcessingPhase.Idle &&
|
||||
phase !== ProcessingPhase.Complete &&
|
||||
phase !== ProcessingPhase.Error
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if progress section should be shown */
|
||||
protected showProgress(): boolean {
|
||||
return this.isProcessing() || this.processingPhase() === ProcessingPhase.Complete;
|
||||
}
|
||||
|
||||
/** Calculate overall progress percentage */
|
||||
protected getOverallProgress(): number {
|
||||
const phase = this.processingPhase();
|
||||
|
||||
switch (phase) {
|
||||
case ProcessingPhase.Idle:
|
||||
return 0;
|
||||
case ProcessingPhase.LoadingCiphers:
|
||||
return this.cipherProgress().percent * 0.2; // 0-20%
|
||||
case ProcessingPhase.RunningHealthChecks:
|
||||
return 20 + this.healthProgress().percent * 0.2; // 20-40%
|
||||
case ProcessingPhase.LoadingMembers:
|
||||
return 40 + this.memberProgress().percent * 0.4; // 40-80%
|
||||
case ProcessingPhase.RunningHibp:
|
||||
return 80 + this.hibpProgress().percent * 0.2; // 80-100%
|
||||
case ProcessingPhase.Complete:
|
||||
return 100;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TrackBy Functions
|
||||
// ============================================================================
|
||||
|
||||
/** TrackBy function for items */
|
||||
protected trackByItemId(_index: number, item: RiskInsightsItem): string {
|
||||
return item.cipherId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="tw-p-4">
|
||||
<h2 class="tw-text-xl tw-font-semibold tw-mb-4">{{ "members" | i18n }}</h2>
|
||||
<p class="tw-text-muted">
|
||||
{{ "riskInsightsPrototypeMembersPlaceholder" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,23 @@
|
||||
<app-header></app-header>
|
||||
|
||||
<div class="tw-min-h-screen tw-flex tw-flex-col">
|
||||
<div class="tw-mb-4">
|
||||
<p class="tw-text-main tw-max-w-4xl">
|
||||
{{ "riskInsightsPrototypeDesc" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex-1 tw-flex tw-flex-col">
|
||||
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
|
||||
<bit-tab [label]="'items' | i18n">
|
||||
<app-risk-insights-prototype-items></app-risk-insights-prototype-items>
|
||||
</bit-tab>
|
||||
<bit-tab [label]="'applications' | i18n">
|
||||
<app-risk-insights-prototype-applications></app-risk-insights-prototype-applications>
|
||||
</bit-tab>
|
||||
<bit-tab [label]="'members' | i18n">
|
||||
<app-risk-insights-prototype-members></app-risk-insights-prototype-members>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ChangeDetectionStrategy, DestroyRef, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { TabsModule } from "@bitwarden/components";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
|
||||
import { RiskInsightsPrototypeApplicationsComponent } from "./applications/risk-insights-prototype-applications.component";
|
||||
import { RiskInsightsPrototypeItemsComponent } from "./items/risk-insights-prototype-items.component";
|
||||
import { RiskInsightsPrototypeMembersComponent } from "./members/risk-insights-prototype-members.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-risk-insights-prototype",
|
||||
templateUrl: "./risk-insights-prototype.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
TabsModule,
|
||||
HeaderModule,
|
||||
RiskInsightsPrototypeItemsComponent,
|
||||
RiskInsightsPrototypeApplicationsComponent,
|
||||
RiskInsightsPrototypeMembersComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class RiskInsightsPrototypeComponent {
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
tabIndex = 0;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
) {
|
||||
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => {
|
||||
this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
async onTabChange(newIndex: number): Promise<void> {
|
||||
await this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { tabIndex: newIndex },
|
||||
queryParamsHandling: "merge",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export enum ReportType {
|
||||
Inactive2fa = "inactive2fa",
|
||||
DataBreach = "dataBreach",
|
||||
MemberAccessReport = "memberAccessReport",
|
||||
CipherHealthTest = "cipherHealthTest",
|
||||
RiskInsightsPrototype = "riskInsightsPrototype",
|
||||
}
|
||||
|
||||
type ReportWithoutVariant = Omit<ReportEntry, "variant">;
|
||||
@@ -67,4 +69,16 @@ export const reports: Record<ReportType, ReportWithoutVariant> = {
|
||||
route: "member-access-report",
|
||||
icon: UserLockIcon,
|
||||
},
|
||||
[ReportType.CipherHealthTest]: {
|
||||
title: "cipherHealthTest",
|
||||
description: "cipherHealthTestDesc",
|
||||
route: "cipher-health-test",
|
||||
icon: UnlockedIcon,
|
||||
},
|
||||
[ReportType.RiskInsightsPrototype]: {
|
||||
title: "riskInsightsPrototype",
|
||||
description: "riskInsightsPrototypeDesc",
|
||||
route: "risk-insights-prototype",
|
||||
icon: UnlockedIcon,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import {
|
||||
CollectionAdminService,
|
||||
CollectionAdminView,
|
||||
CollectionAccessSelectionView,
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserUserDetailsResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { CipherAccessMappingService } from "./cipher-access-mapping.service";
|
||||
|
||||
describe("CipherAccessMappingService", () => {
|
||||
let service: CipherAccessMappingService;
|
||||
let cipherService: MockProxy<CipherService>;
|
||||
let collectionAdminService: MockProxy<CollectionAdminService>;
|
||||
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
const mockOrgId = "org123" as OrganizationId;
|
||||
const mockUserId = "user123" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
cipherService = mock<CipherService>();
|
||||
collectionAdminService = mock<CollectionAdminService>();
|
||||
organizationUserApiService = mock<OrganizationUserApiService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
service = new CipherAccessMappingService(
|
||||
cipherService,
|
||||
collectionAdminService,
|
||||
organizationUserApiService,
|
||||
logService,
|
||||
);
|
||||
});
|
||||
|
||||
// Helper function to create a collection access selection view
|
||||
function createCollectionAccessSelectionView(
|
||||
id: string,
|
||||
readOnly = false,
|
||||
hidePasswords = false,
|
||||
manage = false,
|
||||
): CollectionAccessSelectionView {
|
||||
return {
|
||||
id,
|
||||
readOnly,
|
||||
hidePasswords,
|
||||
manage,
|
||||
} as CollectionAccessSelectionView;
|
||||
}
|
||||
|
||||
// Helper function to create a mock org user response
|
||||
function createMockOrgUserResponse(
|
||||
userId: string,
|
||||
email: string,
|
||||
groups: string[] = [],
|
||||
): OrganizationUserUserDetailsResponse {
|
||||
return {
|
||||
userId,
|
||||
email,
|
||||
groups,
|
||||
} as OrganizationUserUserDetailsResponse;
|
||||
}
|
||||
|
||||
describe("getAllCiphersWithMemberAccess", () => {
|
||||
it("should map ciphers with direct user access", async () => {
|
||||
// Setup mock cipher
|
||||
const mockCipher = new CipherView();
|
||||
mockCipher.id = "cipher1";
|
||||
mockCipher.name = "Test Cipher";
|
||||
mockCipher.type = CipherType.Login;
|
||||
mockCipher.collectionIds = ["collection1"];
|
||||
|
||||
// Setup mock collection
|
||||
const mockCollection = {
|
||||
id: "collection1",
|
||||
name: "Collection 1",
|
||||
users: [createCollectionAccessSelectionView("user1", false, false, true)],
|
||||
groups: [],
|
||||
} as unknown as CollectionAdminView;
|
||||
|
||||
// Setup mock org user response
|
||||
const mockOrgUser = createMockOrgUserResponse("user1", "user1@example.com", []);
|
||||
const mockListResponse = {
|
||||
data: [mockOrgUser],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>;
|
||||
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
|
||||
collectionAdminService.collectionAdminViews$ = jest
|
||||
.fn()
|
||||
.mockReturnValue(of([mockCollection])) as any;
|
||||
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
|
||||
|
||||
const result = await service.getAllCiphersWithMemberAccess(mockOrgId, mockUserId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].cipher.id).toBe("cipher1");
|
||||
expect(result[0].totalMemberCount).toBe(1);
|
||||
expect(result[0].members[0].userId).toBe("user1");
|
||||
expect(result[0].members[0].email).toBe("user1@example.com");
|
||||
expect(result[0].members[0].accessPaths[0].type).toBe("direct");
|
||||
expect(result[0].members[0].effectivePermissions.canEdit).toBe(true);
|
||||
expect(result[0].members[0].effectivePermissions.canManage).toBe(true);
|
||||
});
|
||||
|
||||
it("should map ciphers with group-based access", async () => {
|
||||
// Setup mock cipher
|
||||
const mockCipher = new CipherView();
|
||||
mockCipher.id = "cipher1";
|
||||
mockCipher.name = "Test Cipher";
|
||||
mockCipher.type = CipherType.Login;
|
||||
mockCipher.collectionIds = ["collection1"];
|
||||
|
||||
// Setup mock collection with group access
|
||||
const mockCollection = {
|
||||
id: "collection1",
|
||||
name: "Collection 1",
|
||||
users: [],
|
||||
groups: [createCollectionAccessSelectionView("group1", true, false, false)],
|
||||
} as unknown as CollectionAdminView;
|
||||
|
||||
// Setup mock org user in group
|
||||
const mockOrgUser = createMockOrgUserResponse("user2", "user2@example.com", ["group1"]);
|
||||
const mockListResponse = {
|
||||
data: [mockOrgUser],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>;
|
||||
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
|
||||
collectionAdminService.collectionAdminViews$ = jest
|
||||
.fn()
|
||||
.mockReturnValue(of([mockCollection])) as any;
|
||||
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
|
||||
|
||||
const result = await service.getAllCiphersWithMemberAccess(mockOrgId, mockUserId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].totalMemberCount).toBe(1);
|
||||
expect(result[0].members[0].userId).toBe("user2");
|
||||
expect(result[0].members[0].accessPaths[0].type).toBe("group");
|
||||
expect(result[0].members[0].accessPaths[0].groupId).toBe("group1");
|
||||
expect(result[0].members[0].effectivePermissions.canEdit).toBe(false);
|
||||
expect(result[0].members[0].effectivePermissions.canViewPasswords).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle unassigned ciphers", async () => {
|
||||
// Setup mock cipher with no collections
|
||||
const mockCipher = new CipherView();
|
||||
mockCipher.id = "cipher1";
|
||||
mockCipher.name = "Unassigned Cipher";
|
||||
mockCipher.type = CipherType.Login;
|
||||
mockCipher.collectionIds = [];
|
||||
|
||||
const mockListResponse = {
|
||||
data: [],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>;
|
||||
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
|
||||
collectionAdminService.collectionAdminViews$ = jest.fn().mockReturnValue(of([])) as any;
|
||||
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
|
||||
|
||||
const result = await service.getAllCiphersWithMemberAccess(mockOrgId, mockUserId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].unassigned).toBe(true);
|
||||
expect(result[0].totalMemberCount).toBe(0);
|
||||
expect(result[0].members).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should combine multiple access paths for same user", async () => {
|
||||
// Setup mock cipher assigned to two collections
|
||||
const mockCipher = new CipherView();
|
||||
mockCipher.id = "cipher1";
|
||||
mockCipher.name = "Test Cipher";
|
||||
mockCipher.type = CipherType.Login;
|
||||
mockCipher.collectionIds = ["collection1", "collection2"];
|
||||
|
||||
// Setup two collections with same user
|
||||
const mockCollection1 = {
|
||||
id: "collection1",
|
||||
name: "Collection 1",
|
||||
users: [createCollectionAccessSelectionView("user1", true, true, false)],
|
||||
groups: [],
|
||||
} as unknown as CollectionAdminView;
|
||||
|
||||
const mockCollection2 = {
|
||||
id: "collection2",
|
||||
name: "Collection 2",
|
||||
users: [createCollectionAccessSelectionView("user1", false, false, true)],
|
||||
groups: [],
|
||||
} as unknown as CollectionAdminView;
|
||||
|
||||
const mockOrgUser = createMockOrgUserResponse("user1", "user1@example.com", []);
|
||||
const mockListResponse = {
|
||||
data: [mockOrgUser],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>;
|
||||
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
|
||||
collectionAdminService.collectionAdminViews$ = jest
|
||||
.fn()
|
||||
.mockReturnValue(of([mockCollection1, mockCollection2])) as any;
|
||||
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
|
||||
|
||||
const result = await service.getAllCiphersWithMemberAccess(mockOrgId, mockUserId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].totalMemberCount).toBe(1);
|
||||
expect(result[0].members[0].accessPaths).toHaveLength(2);
|
||||
// Most permissive should win
|
||||
expect(result[0].members[0].effectivePermissions.canEdit).toBe(true);
|
||||
expect(result[0].members[0].effectivePermissions.canViewPasswords).toBe(true);
|
||||
expect(result[0].members[0].effectivePermissions.canManage).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSimplifiedCipherAccessMap", () => {
|
||||
it("should return simplified mapping of cipher IDs to user IDs", async () => {
|
||||
const mockCipher = new CipherView();
|
||||
mockCipher.id = "cipher1";
|
||||
mockCipher.name = "Test Cipher";
|
||||
mockCipher.collectionIds = ["collection1"];
|
||||
|
||||
const mockCollection = {
|
||||
id: "collection1",
|
||||
users: [createCollectionAccessSelectionView("user1")],
|
||||
groups: [],
|
||||
} as unknown as CollectionAdminView;
|
||||
|
||||
const mockOrgUser = createMockOrgUserResponse("user1", "user1@example.com", []);
|
||||
const mockListResponse = {
|
||||
data: [mockOrgUser],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>;
|
||||
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
|
||||
collectionAdminService.collectionAdminViews$ = jest
|
||||
.fn()
|
||||
.mockReturnValue(of([mockCollection])) as any;
|
||||
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
|
||||
|
||||
const result = await service.getSimplifiedCipherAccessMap(mockOrgId, mockUserId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].cipherId).toBe("cipher1");
|
||||
expect(result[0].cipherName).toBe("Test Cipher");
|
||||
expect(result[0].userIds.size).toBe(1);
|
||||
expect(result[0].userIds.has("user1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMembersForCipher", () => {
|
||||
it("should return members with access to specific cipher", async () => {
|
||||
const mockCipher = new CipherView();
|
||||
mockCipher.id = "cipher1";
|
||||
mockCipher.name = "Test Cipher";
|
||||
mockCipher.collectionIds = ["collection1"];
|
||||
|
||||
const mockCollection = {
|
||||
id: "collection1",
|
||||
users: [createCollectionAccessSelectionView("user1")],
|
||||
groups: [],
|
||||
} as unknown as CollectionAdminView;
|
||||
|
||||
const mockOrgUser = createMockOrgUserResponse("user1", "user1@example.com", []);
|
||||
const mockListResponse = {
|
||||
data: [mockOrgUser],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>;
|
||||
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue([mockCipher]);
|
||||
collectionAdminService.collectionAdminViews$ = jest
|
||||
.fn()
|
||||
.mockReturnValue(of([mockCollection])) as any;
|
||||
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
|
||||
|
||||
const result = await service.findMembersForCipher(mockOrgId, mockUserId, "cipher1");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].userId).toBe("user1");
|
||||
});
|
||||
|
||||
it("should return null for non-existent cipher", async () => {
|
||||
const mockListResponse = {
|
||||
data: [],
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>;
|
||||
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue([]);
|
||||
collectionAdminService.collectionAdminViews$ = jest.fn().mockReturnValue(of([])) as any;
|
||||
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
|
||||
|
||||
const result = await service.findMembersForCipher(mockOrgId, mockUserId, "nonexistent");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateCipherMemberCountReport", () => {
|
||||
it("should generate report sorted by member count", async () => {
|
||||
const cipher1 = new CipherView();
|
||||
cipher1.id = "cipher1";
|
||||
cipher1.name = "Cipher 1";
|
||||
cipher1.collectionIds = ["collection1"];
|
||||
|
||||
const cipher2 = new CipherView();
|
||||
cipher2.id = "cipher2";
|
||||
cipher2.name = "Cipher 2";
|
||||
cipher2.collectionIds = ["collection2"];
|
||||
|
||||
const collection1 = {
|
||||
id: "collection1",
|
||||
users: [createCollectionAccessSelectionView("user1")],
|
||||
groups: [],
|
||||
} as unknown as CollectionAdminView;
|
||||
|
||||
const collection2 = {
|
||||
id: "collection2",
|
||||
users: [
|
||||
createCollectionAccessSelectionView("user2"),
|
||||
createCollectionAccessSelectionView("user3"),
|
||||
],
|
||||
groups: [],
|
||||
} as unknown as CollectionAdminView;
|
||||
|
||||
const mockOrgUsers = [
|
||||
createMockOrgUserResponse("user1", "user1@example.com", []),
|
||||
createMockOrgUserResponse("user2", "user2@example.com", []),
|
||||
createMockOrgUserResponse("user3", "user3@example.com", []),
|
||||
];
|
||||
const mockListResponse = {
|
||||
data: mockOrgUsers,
|
||||
} as ListResponse<OrganizationUserUserDetailsResponse>;
|
||||
|
||||
cipherService.getAllFromApiForOrganization.mockResolvedValue([cipher1, cipher2]);
|
||||
collectionAdminService.collectionAdminViews$ = jest
|
||||
.fn()
|
||||
.mockReturnValue(of([collection1, collection2])) as any;
|
||||
organizationUserApiService.getAllUsers.mockResolvedValue(mockListResponse);
|
||||
|
||||
const result = await service.generateCipherMemberCountReport(mockOrgId, mockUserId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Should be sorted descending by member count
|
||||
expect(result[0].cipherId).toBe("cipher2");
|
||||
expect(result[0].memberCount).toBe(2);
|
||||
expect(result[1].cipherId).toBe("cipher1");
|
||||
expect(result[1].memberCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,991 @@
|
||||
import { Injectable, inject } from "@angular/core";
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
CollectionAdminService,
|
||||
CollectionAdminView,
|
||||
OrganizationUserApiService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
/**
|
||||
* Represents a member's access to a cipher with permission details
|
||||
*/
|
||||
export interface CipherMemberAccess {
|
||||
userId: string;
|
||||
email: string | null;
|
||||
accessPaths: CipherAccessPath[];
|
||||
effectivePermissions: EffectiveCipherPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes how a member gained access to a cipher
|
||||
*/
|
||||
export interface CipherAccessPath {
|
||||
type: "direct" | "group";
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
permissions: {
|
||||
readOnly: boolean;
|
||||
hidePasswords: boolean;
|
||||
manage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective permissions after combining all access paths
|
||||
*/
|
||||
export interface EffectiveCipherPermissions {
|
||||
canEdit: boolean; // Has at least one non-readOnly path
|
||||
canViewPasswords: boolean; // Has at least one non-hidePasswords path
|
||||
canManage: boolean; // Has at least one manage path
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete cipher with all members who have access
|
||||
*/
|
||||
export interface CipherWithMemberAccess {
|
||||
cipher: CipherView;
|
||||
members: CipherMemberAccess[];
|
||||
totalMemberCount: number;
|
||||
unassigned: boolean; // True if cipher has no collections
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified mapping of cipher ID to user IDs
|
||||
*/
|
||||
export interface SimplifiedCipherAccessMap {
|
||||
cipherId: string;
|
||||
cipherName: string;
|
||||
userIds: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for mapping organization ciphers to members who have access
|
||||
*
|
||||
* This service provides functionality to:
|
||||
* 1. Fetch all ciphers in an organization (using getAllFromApiForOrganization)
|
||||
* 2. Determine which members have access to each cipher
|
||||
* 3. Track access paths (direct collection assignment vs group-based)
|
||||
* 4. Calculate effective permissions
|
||||
*
|
||||
* Use Cases:
|
||||
* - Testing cipher access logic
|
||||
* - Auditing member access to sensitive items
|
||||
* - Generating access reports
|
||||
*/
|
||||
/**
|
||||
* Timing information for diagnostics
|
||||
*/
|
||||
export interface CipherAccessMappingTimings {
|
||||
fetchCollectionsMs: number;
|
||||
fetchUsersMs: number;
|
||||
buildGroupMemberMapMs: number;
|
||||
buildUserEmailMapMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count information for diagnostics
|
||||
*/
|
||||
export interface CipherAccessMappingCounts {
|
||||
cipherCount: number;
|
||||
collectionCount: number;
|
||||
groupCount: number;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result with timing information
|
||||
*/
|
||||
export interface CipherAccessMappingTimedResult {
|
||||
data: CipherWithMemberAccess[];
|
||||
timings: CipherAccessMappingTimings;
|
||||
counts: CipherAccessMappingCounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for progressive member access loading (const object pattern per ADR-0025)
|
||||
*/
|
||||
export const MemberAccessLoadState = Object.freeze({
|
||||
NotStarted: "not-started",
|
||||
LoadingPrerequisites: "loading-prerequisites",
|
||||
ProcessingBatches: "processing-batches",
|
||||
Complete: "complete",
|
||||
Error: "error",
|
||||
} as const);
|
||||
export type MemberAccessLoadState =
|
||||
(typeof MemberAccessLoadState)[keyof typeof MemberAccessLoadState];
|
||||
|
||||
/**
|
||||
* Progressive result emitted as batches complete during streaming
|
||||
*/
|
||||
export interface CipherAccessMappingProgressiveResult {
|
||||
/** Current state of member access loading */
|
||||
state: MemberAccessLoadState;
|
||||
|
||||
/** Ciphers processed so far (grows with each batch) */
|
||||
processedCiphers: CipherWithMemberAccess[];
|
||||
|
||||
/** Total number of ciphers to process */
|
||||
totalCipherCount: number;
|
||||
|
||||
/** Number of ciphers processed so far */
|
||||
processedCount: number;
|
||||
|
||||
/** Percentage complete (0-100) */
|
||||
progressPercent: number;
|
||||
|
||||
/** Timing diagnostics (partial until complete) */
|
||||
timings: Partial<CipherAccessMappingTimings>;
|
||||
|
||||
/** Entity counts (partial until complete) */
|
||||
counts: Partial<CipherAccessMappingCounts>;
|
||||
|
||||
/** Error message if state is Error */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached organization users data to avoid duplicate API calls
|
||||
*/
|
||||
interface CachedOrganizationUsers {
|
||||
organizationId: OrganizationId;
|
||||
groupMemberMap: Map<string, { groupName: string; memberIds: string[] }>;
|
||||
userEmailMap: Map<string, string>;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CipherAccessMappingService {
|
||||
private readonly cipherService = inject(CipherService);
|
||||
private readonly collectionAdminService = inject(CollectionAdminService);
|
||||
private readonly organizationUserApiService = inject(OrganizationUserApiService);
|
||||
private readonly logService = inject(LogService);
|
||||
|
||||
/** Cache for organization users to avoid duplicate API calls */
|
||||
private _usersCache: CachedOrganizationUsers | null = null;
|
||||
private readonly CACHE_TTL_MS = 60000; // 1 minute cache
|
||||
|
||||
/**
|
||||
* Gets all ciphers with member access AND timing diagnostics
|
||||
*/
|
||||
getAllCiphersWithMemberAccessTimed$(
|
||||
organizationId: OrganizationId,
|
||||
currentUserId: UserId,
|
||||
): Observable<CipherAccessMappingTimedResult> {
|
||||
const timings: CipherAccessMappingTimings = {
|
||||
fetchCollectionsMs: 0,
|
||||
fetchUsersMs: 0,
|
||||
buildGroupMemberMapMs: 0,
|
||||
buildUserEmailMapMs: 0,
|
||||
};
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Fetching all ciphers for organization ${organizationId}`,
|
||||
);
|
||||
|
||||
// STEP 1: Fetch all ciphers in the organization (admin view)
|
||||
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId)).pipe(
|
||||
tap((ciphers) =>
|
||||
this.logService.info(`[CipherAccessMappingService] Found ${ciphers.length} ciphers`),
|
||||
),
|
||||
);
|
||||
|
||||
// STEP 2: Fetch all collections with access details (users and groups)
|
||||
this.logService.info("[CipherAccessMappingService] Fetching collections with access details");
|
||||
const collectionsStart = performance.now();
|
||||
const collections$ = this.collectionAdminService
|
||||
.collectionAdminViews$(organizationId, currentUserId)
|
||||
.pipe(
|
||||
tap((collections) => {
|
||||
timings.fetchCollectionsMs = performance.now() - collectionsStart;
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Found ${collections.length} collections`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// STEP 3 & 4: Build group member map and user email map
|
||||
// These are async operations, so we convert to observables
|
||||
const groupStart = performance.now();
|
||||
const groupMemberMap$ = from(this.buildGroupMemberMap(organizationId)).pipe(
|
||||
tap(() => {
|
||||
timings.buildGroupMemberMapMs = performance.now() - groupStart;
|
||||
}),
|
||||
);
|
||||
|
||||
const emailStart = performance.now();
|
||||
const userEmailMap$ = from(this.buildUserEmailMap(organizationId)).pipe(
|
||||
tap(() => {
|
||||
timings.buildUserEmailMapMs = performance.now() - emailStart;
|
||||
timings.fetchUsersMs = timings.buildGroupMemberMapMs + timings.buildUserEmailMapMs;
|
||||
}),
|
||||
);
|
||||
|
||||
// Combine all data sources
|
||||
return combineLatest([allCiphers$, collections$, groupMemberMap$, userEmailMap$]).pipe(
|
||||
map(([allCiphers, collections, groupMemberMap, userEmailMap]) => {
|
||||
// Build collection map for quick lookup
|
||||
const collectionMap = new Map<string, CollectionAdminView>();
|
||||
collections.forEach((collection) => {
|
||||
collectionMap.set(collection.id, collection);
|
||||
});
|
||||
|
||||
// STEP 5: For each cipher, determine member access
|
||||
this.logService.info("[CipherAccessMappingService] Mapping member access to ciphers");
|
||||
const ciphersWithAccess: CipherWithMemberAccess[] = [];
|
||||
|
||||
for (const cipher of allCiphers) {
|
||||
const memberAccessMap = new Map<string, CipherMemberAccess>();
|
||||
|
||||
// Check if cipher is unassigned (no collections)
|
||||
const isUnassigned = !cipher.collectionIds || cipher.collectionIds.length === 0;
|
||||
|
||||
if (!isUnassigned) {
|
||||
// Cipher is assigned to collections
|
||||
for (const collectionId of cipher.collectionIds) {
|
||||
const collection = collectionMap.get(collectionId);
|
||||
if (!collection) {
|
||||
this.logService.warning(
|
||||
`[CipherAccessMappingService] Collection ${collectionId} not found for cipher ${cipher.id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A) Process direct user assignments to this collection
|
||||
for (const userAccess of collection.users) {
|
||||
const userId = userAccess.id;
|
||||
|
||||
if (!memberAccessMap.has(userId)) {
|
||||
memberAccessMap.set(userId, {
|
||||
userId,
|
||||
email: userEmailMap.get(userId) ?? null,
|
||||
accessPaths: [],
|
||||
effectivePermissions: {
|
||||
canEdit: false,
|
||||
canViewPasswords: false,
|
||||
canManage: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const memberAccess = memberAccessMap.get(userId)!;
|
||||
memberAccess.accessPaths.push({
|
||||
type: "direct",
|
||||
collectionId: collection.id,
|
||||
collectionName: collection.name || "Unknown",
|
||||
permissions: {
|
||||
readOnly: userAccess.readOnly,
|
||||
hidePasswords: userAccess.hidePasswords,
|
||||
manage: userAccess.manage,
|
||||
},
|
||||
});
|
||||
|
||||
// Update effective permissions
|
||||
this.updateEffectivePermissions(memberAccess, userAccess);
|
||||
}
|
||||
|
||||
// B) Process group assignments to this collection
|
||||
for (const groupAccess of collection.groups) {
|
||||
const groupId = groupAccess.id;
|
||||
const groupMemberData = groupMemberMap.get(groupId);
|
||||
|
||||
if (!groupMemberData || groupMemberData.memberIds.length === 0) {
|
||||
this.logService.warning(
|
||||
`[CipherAccessMappingService] No members found for group ${groupId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add access for each member in the group
|
||||
for (const userId of groupMemberData.memberIds) {
|
||||
if (!memberAccessMap.has(userId)) {
|
||||
memberAccessMap.set(userId, {
|
||||
userId,
|
||||
email: userEmailMap.get(userId) ?? null,
|
||||
accessPaths: [],
|
||||
effectivePermissions: {
|
||||
canEdit: false,
|
||||
canViewPasswords: false,
|
||||
canManage: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const memberAccess = memberAccessMap.get(userId)!;
|
||||
memberAccess.accessPaths.push({
|
||||
type: "group",
|
||||
collectionId: collection.id,
|
||||
collectionName: collection.name || "Unknown",
|
||||
groupId: groupId,
|
||||
groupName: groupMemberData.groupName,
|
||||
permissions: {
|
||||
readOnly: groupAccess.readOnly,
|
||||
hidePasswords: groupAccess.hidePasswords,
|
||||
manage: groupAccess.manage,
|
||||
},
|
||||
});
|
||||
|
||||
// Update effective permissions
|
||||
this.updateEffectivePermissions(memberAccess, groupAccess);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the map to an array
|
||||
const members = Array.from(memberAccessMap.values());
|
||||
|
||||
ciphersWithAccess.push({
|
||||
cipher,
|
||||
totalMemberCount: members.length,
|
||||
members,
|
||||
unassigned: isUnassigned,
|
||||
});
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Completed mapping for ${ciphersWithAccess.length} ciphers`,
|
||||
);
|
||||
|
||||
// Calculate counts for diagnostics
|
||||
const uniqueMembers = new Set<string>();
|
||||
ciphersWithAccess.forEach((cipher) => {
|
||||
cipher.members.forEach((member) => uniqueMembers.add(member.userId));
|
||||
});
|
||||
|
||||
const counts: CipherAccessMappingCounts = {
|
||||
cipherCount: allCiphers.length,
|
||||
collectionCount: collections.length,
|
||||
groupCount: groupMemberMap.size,
|
||||
memberCount: uniqueMembers.size,
|
||||
};
|
||||
|
||||
return {
|
||||
data: ciphersWithAccess,
|
||||
timings,
|
||||
counts,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all ciphers in an organization and maps which members have access
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @param currentUserId - The current user's ID (for collection fetching)
|
||||
* @returns Observable of array of ciphers with their member access details
|
||||
*/
|
||||
getAllCiphersWithMemberAccess$(
|
||||
organizationId: OrganizationId,
|
||||
currentUserId: UserId,
|
||||
): Observable<CipherWithMemberAccess[]> {
|
||||
return this.getAllCiphersWithMemberAccessTimed$(organizationId, currentUserId).pipe(
|
||||
map((result) => result.data),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified version that just returns cipher ID -> user IDs mapping
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @param currentUserId - The current user's ID
|
||||
* @returns Observable of map of cipher IDs to sets of user IDs with access
|
||||
*/
|
||||
getSimplifiedCipherAccessMap$(
|
||||
organizationId: OrganizationId,
|
||||
currentUserId: UserId,
|
||||
): Observable<SimplifiedCipherAccessMap[]> {
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Building simplified cipher access map for ${organizationId}`,
|
||||
);
|
||||
|
||||
return this.getAllCiphersWithMemberAccess$(organizationId, currentUserId).pipe(
|
||||
map((ciphersWithAccess) => {
|
||||
const result: SimplifiedCipherAccessMap[] = ciphersWithAccess.map((c) => {
|
||||
const userIds = new Set<string>(c.members.map((m) => m.userId));
|
||||
return {
|
||||
cipherId: c.cipher.id,
|
||||
cipherName: c.cipher.name,
|
||||
userIds,
|
||||
};
|
||||
});
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Completed simplified mapping for ${result.length} ciphers`,
|
||||
);
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all ciphers a specific user has access to
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @param currentUserId - The current user's ID (for collection fetching)
|
||||
* @param targetUserId - The user to find ciphers for
|
||||
* @returns Observable of ciphers the target user can access
|
||||
*/
|
||||
findCiphersForUser$(
|
||||
organizationId: OrganizationId,
|
||||
currentUserId: UserId,
|
||||
targetUserId: string,
|
||||
): Observable<CipherWithMemberAccess[]> {
|
||||
return this.getAllCiphersWithMemberAccess$(organizationId, currentUserId).pipe(
|
||||
map((allCiphersWithAccess) => {
|
||||
const userCiphers = allCiphersWithAccess.filter((c) =>
|
||||
c.members.some((m) => m.userId === targetUserId),
|
||||
);
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] User ${targetUserId} has access to ${userCiphers.length} ciphers`,
|
||||
);
|
||||
|
||||
return userCiphers;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all members who have access to a specific cipher
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @param currentUserId - The current user's ID (for collection fetching)
|
||||
* @param cipherId - The cipher to find members for
|
||||
* @returns Observable of members with access to the cipher, or null if cipher not found
|
||||
*/
|
||||
findMembersForCipher$(
|
||||
organizationId: OrganizationId,
|
||||
currentUserId: UserId,
|
||||
cipherId: string,
|
||||
): Observable<CipherMemberAccess[] | null> {
|
||||
return this.getAllCiphersWithMemberAccess$(organizationId, currentUserId).pipe(
|
||||
map((allCiphersWithAccess) => {
|
||||
const targetCipher = allCiphersWithAccess.find((c) => c.cipher.id === cipherId);
|
||||
|
||||
if (!targetCipher) {
|
||||
this.logService.warning(
|
||||
`[CipherAccessMappingService] Cipher ${cipherId} not found in organization`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Found ${targetCipher.totalMemberCount} members with access to cipher ${cipherId}`,
|
||||
);
|
||||
|
||||
return targetCipher.members;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a report of ciphers with their distinct member count
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @param currentUserId - The current user's ID
|
||||
* @returns Observable of array of cipher summaries sorted by member count descending
|
||||
*/
|
||||
generateCipherMemberCountReport$(
|
||||
organizationId: OrganizationId,
|
||||
currentUserId: UserId,
|
||||
): Observable<
|
||||
{ cipherId: string; cipherName: string; memberCount: number; unassigned: boolean }[]
|
||||
> {
|
||||
return this.getAllCiphersWithMemberAccess$(organizationId, currentUserId).pipe(
|
||||
map((ciphersWithAccess) => {
|
||||
const report = ciphersWithAccess.map((c) => ({
|
||||
cipherId: c.cipher.id,
|
||||
cipherName: c.cipher.name,
|
||||
memberCount: c.totalMemberCount,
|
||||
unassigned: c.unassigned,
|
||||
}));
|
||||
|
||||
// Sort by member count descending
|
||||
report.sort((a, b) => b.memberCount - a.memberCount);
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Generated report for ${report.length} ciphers`,
|
||||
);
|
||||
|
||||
return report;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports cipher access data to JSON format
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @param currentUserId - The current user's ID
|
||||
* @returns Observable of JSON string of cipher access data
|
||||
*/
|
||||
exportToJSON$(organizationId: OrganizationId, currentUserId: UserId): Observable<string> {
|
||||
return this.getAllCiphersWithMemberAccess$(organizationId, currentUserId).pipe(
|
||||
map((ciphersWithAccess) => {
|
||||
// Transform to a serializable format
|
||||
const exportData = ciphersWithAccess.map((c) => ({
|
||||
cipherId: c.cipher.id,
|
||||
cipherName: c.cipher.name,
|
||||
cipherType: c.cipher.type,
|
||||
organizationId: c.cipher.organizationId,
|
||||
collectionIds: c.cipher.collectionIds,
|
||||
unassigned: c.unassigned,
|
||||
totalMemberCount: c.totalMemberCount,
|
||||
members: c.members.map((m) => ({
|
||||
userId: m.userId,
|
||||
email: m.email,
|
||||
effectivePermissions: m.effectivePermissions,
|
||||
accessPaths: m.accessPaths,
|
||||
})),
|
||||
}));
|
||||
|
||||
const jsonOutput = JSON.stringify(exportData, null, 2);
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Exported ${exportData.length} ciphers to JSON`,
|
||||
);
|
||||
|
||||
return jsonOutput;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets ciphers with member access using progressive streaming.
|
||||
* Emits partial results as batches complete, enabling incremental UI updates.
|
||||
*
|
||||
* Key differences from getAllCiphersWithMemberAccessTimed$:
|
||||
* 1. Accepts already-fetched ciphers (decouples cipher fetch from member mapping)
|
||||
* 2. Uses single API call for users (via fetchOrganizationUsersOnce)
|
||||
* 3. Processes in batches with setTimeout(0) to yield to event loop
|
||||
* 4. Emits after each batch for progressive UI updates
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @param currentUserId - The current user's ID (for collection fetching)
|
||||
* @param ciphers - Pre-fetched ciphers to process
|
||||
* @param batchSize - Number of ciphers to process per batch (default 500)
|
||||
* @returns Observable that emits progressive results after each batch
|
||||
*/
|
||||
getAllCiphersWithMemberAccessProgressive$(
|
||||
organizationId: OrganizationId,
|
||||
currentUserId: UserId,
|
||||
ciphers: CipherView[],
|
||||
batchSize: number = 500,
|
||||
): Observable<CipherAccessMappingProgressiveResult> {
|
||||
const timings: Partial<CipherAccessMappingTimings> = {};
|
||||
const counts: Partial<CipherAccessMappingCounts> = { cipherCount: ciphers.length };
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Starting progressive member access mapping for ${ciphers.length} ciphers`,
|
||||
);
|
||||
|
||||
// Fetch prerequisites in parallel: collections and users (single API call)
|
||||
const collectionsStart = performance.now();
|
||||
const collections$ = this.collectionAdminService
|
||||
.collectionAdminViews$(organizationId, currentUserId)
|
||||
.pipe(
|
||||
take(1), // Complete after first emission (hot observable)
|
||||
tap((collections) => {
|
||||
timings.fetchCollectionsMs = performance.now() - collectionsStart;
|
||||
counts.collectionCount = collections.length;
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Fetched ${collections.length} collections in ${timings.fetchCollectionsMs?.toFixed(0)}ms`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const usersStart = performance.now();
|
||||
const users$ = from(this.fetchOrganizationUsersOnce(organizationId)).pipe(
|
||||
tap(({ groupMemberMap, userEmailMap }) => {
|
||||
timings.fetchUsersMs = performance.now() - usersStart;
|
||||
timings.buildGroupMemberMapMs = timings.fetchUsersMs;
|
||||
timings.buildUserEmailMapMs = 0; // Included in single call
|
||||
counts.groupCount = groupMemberMap.size;
|
||||
counts.memberCount = userEmailMap.size;
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Fetched users in ${timings.fetchUsersMs?.toFixed(0)}ms`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// Combine prerequisites, then process batches
|
||||
return combineLatest([collections$, users$]).pipe(
|
||||
switchMap(([collections, { groupMemberMap, userEmailMap }]) => {
|
||||
// Build collection lookup map
|
||||
const collectionMap = new Map<string, CollectionAdminView>();
|
||||
collections.forEach((c) => collectionMap.set(c.id, c));
|
||||
|
||||
// Create batches
|
||||
const batches: CipherView[][] = [];
|
||||
for (let i = 0; i < ciphers.length; i += batchSize) {
|
||||
batches.push(ciphers.slice(i, i + batchSize));
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Processing ${batches.length} batches of ~${batchSize} ciphers each`,
|
||||
);
|
||||
|
||||
// Accumulate results across batches
|
||||
const processedCiphers: CipherWithMemberAccess[] = [];
|
||||
|
||||
// Process batches sequentially with event loop yields
|
||||
return from(batches).pipe(
|
||||
concatMap((batch, batchIndex) => {
|
||||
return new Observable<CipherAccessMappingProgressiveResult>((observer) => {
|
||||
// Use setTimeout(0) to yield to event loop between batches
|
||||
setTimeout(() => {
|
||||
const batchResults = this.processCipherBatch(
|
||||
batch,
|
||||
collectionMap,
|
||||
groupMemberMap,
|
||||
userEmailMap,
|
||||
);
|
||||
|
||||
processedCiphers.push(...batchResults);
|
||||
|
||||
const processedCount = processedCiphers.length;
|
||||
const progressPercent = Math.round((processedCount / ciphers.length) * 100);
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Batch ${batchIndex + 1}/${batches.length} complete: ${processedCount}/${ciphers.length} (${progressPercent}%)`,
|
||||
);
|
||||
|
||||
const isLastBatch = batchIndex === batches.length - 1;
|
||||
|
||||
observer.next({
|
||||
state: isLastBatch
|
||||
? MemberAccessLoadState.Complete
|
||||
: MemberAccessLoadState.ProcessingBatches,
|
||||
processedCiphers: [...processedCiphers], // Copy for immutability
|
||||
totalCipherCount: ciphers.length,
|
||||
processedCount,
|
||||
progressPercent,
|
||||
timings,
|
||||
counts,
|
||||
});
|
||||
|
||||
observer.complete();
|
||||
}, 0);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("[CipherAccessMappingService] Progressive mapping error", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error occurred during member access mapping";
|
||||
return of({
|
||||
state: MemberAccessLoadState.Error,
|
||||
processedCiphers: [],
|
||||
totalCipherCount: ciphers.length,
|
||||
processedCount: 0,
|
||||
progressPercent: 0,
|
||||
timings,
|
||||
counts,
|
||||
error: errorMessage,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PRIVATE HELPER METHODS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Builds a map of groupId -> member user IDs and group name
|
||||
*
|
||||
* Note: The GroupDetailsView doesn't include members directly.
|
||||
* We need to fetch organization users and check their group memberships.
|
||||
*/
|
||||
private async buildGroupMemberMap(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<Map<string, { groupName: string; memberIds: string[] }>> {
|
||||
const groupMemberMap = new Map<string, { groupName: string; memberIds: string[] }>();
|
||||
|
||||
// Fetch all organization users with groups
|
||||
const orgUsersResponse = await this.organizationUserApiService.getAllUsers(organizationId, {
|
||||
includeGroups: true,
|
||||
});
|
||||
|
||||
// Build reverse mapping: for each user, add them to their groups
|
||||
for (const orgUser of orgUsersResponse.data) {
|
||||
if (!orgUser.groups || orgUser.groups.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const groupId of orgUser.groups) {
|
||||
let groupData = groupMemberMap.get(groupId);
|
||||
if (!groupData) {
|
||||
// Initialize group data (name will be updated if we have it)
|
||||
groupData = { groupName: "Unknown Group", memberIds: [] };
|
||||
groupMemberMap.set(groupId, groupData);
|
||||
}
|
||||
// Use orgUser.id (organization user ID) to match collection assignments and email map
|
||||
groupData.memberIds.push(orgUser.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Built group member map for ${groupMemberMap.size} groups`,
|
||||
);
|
||||
|
||||
return groupMemberMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of userId -> email for quick lookup
|
||||
*/
|
||||
private async buildUserEmailMap(organizationId: OrganizationId): Promise<Map<string, string>> {
|
||||
const userEmailMap = new Map<string, string>();
|
||||
|
||||
const orgUsersResponse = await this.organizationUserApiService.getAllUsers(organizationId);
|
||||
|
||||
for (const orgUser of orgUsersResponse.data) {
|
||||
// Use orgUser.id as the key (organization user ID), not orgUser.userId which can be null
|
||||
// This is the ID that will be used in collection assignments and group member IDs
|
||||
if (orgUser.id && orgUser.email) {
|
||||
userEmailMap.set(orgUser.id, orgUser.email);
|
||||
}
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Built user email map for ${userEmailMap.size} users`,
|
||||
);
|
||||
|
||||
return userEmailMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches organization users ONCE and builds both maps from the same API response.
|
||||
* Eliminates the duplicate API calls that existed in the original implementation.
|
||||
* Includes caching to avoid repeated calls within a short time window.
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @returns Both groupMemberMap and userEmailMap built from a single API call
|
||||
*/
|
||||
private async fetchOrganizationUsersOnce(organizationId: OrganizationId): Promise<{
|
||||
groupMemberMap: Map<string, { groupName: string; memberIds: string[] }>;
|
||||
userEmailMap: Map<string, string>;
|
||||
}> {
|
||||
// Check cache first
|
||||
if (
|
||||
this._usersCache &&
|
||||
this._usersCache.organizationId === organizationId &&
|
||||
Date.now() - this._usersCache.fetchedAt < this.CACHE_TTL_MS
|
||||
) {
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Using cached user data for organization ${organizationId}`,
|
||||
);
|
||||
return {
|
||||
groupMemberMap: this._usersCache.groupMemberMap,
|
||||
userEmailMap: this._usersCache.userEmailMap,
|
||||
};
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Fetching organization users (single API call) for ${organizationId}`,
|
||||
);
|
||||
|
||||
// Single API call with includeGroups: true - gets all user data needed
|
||||
const orgUsersResponse = await this.organizationUserApiService.getAllUsers(organizationId, {
|
||||
includeGroups: true,
|
||||
});
|
||||
|
||||
// Build both maps from the same response
|
||||
const groupMemberMap = new Map<string, { groupName: string; memberIds: string[] }>();
|
||||
const userEmailMap = new Map<string, string>();
|
||||
|
||||
for (const orgUser of orgUsersResponse.data) {
|
||||
// Build email map
|
||||
if (orgUser.id && orgUser.email) {
|
||||
userEmailMap.set(orgUser.id, orgUser.email);
|
||||
}
|
||||
|
||||
// Build group member map (reverse mapping: user -> their groups)
|
||||
if (orgUser.groups && orgUser.groups.length > 0) {
|
||||
for (const groupId of orgUser.groups) {
|
||||
let groupData = groupMemberMap.get(groupId);
|
||||
if (!groupData) {
|
||||
groupData = { groupName: "Unknown Group", memberIds: [] };
|
||||
groupMemberMap.set(groupId, groupData);
|
||||
}
|
||||
groupData.memberIds.push(orgUser.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
this._usersCache = {
|
||||
organizationId,
|
||||
groupMemberMap,
|
||||
userEmailMap,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
|
||||
this.logService.info(
|
||||
`[CipherAccessMappingService] Built maps from single API call: ${userEmailMap.size} users, ${groupMemberMap.size} groups`,
|
||||
);
|
||||
|
||||
return { groupMemberMap, userEmailMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a batch of ciphers to calculate member access.
|
||||
* Extracted as a reusable helper for both progressive and non-progressive methods.
|
||||
*
|
||||
* @param ciphers - The batch of ciphers to process
|
||||
* @param collectionMap - Map of collection ID to CollectionAdminView
|
||||
* @param groupMemberMap - Map of group ID to group data with member IDs
|
||||
* @param userEmailMap - Map of user ID to email
|
||||
* @returns Array of ciphers with member access calculated
|
||||
*/
|
||||
private processCipherBatch(
|
||||
ciphers: CipherView[],
|
||||
collectionMap: Map<string, CollectionAdminView>,
|
||||
groupMemberMap: Map<string, { groupName: string; memberIds: string[] }>,
|
||||
userEmailMap: Map<string, string>,
|
||||
): CipherWithMemberAccess[] {
|
||||
const results: CipherWithMemberAccess[] = [];
|
||||
|
||||
for (const cipher of ciphers) {
|
||||
const memberAccessMap = new Map<string, CipherMemberAccess>();
|
||||
const isUnassigned = !cipher.collectionIds || cipher.collectionIds.length === 0;
|
||||
|
||||
if (!isUnassigned) {
|
||||
for (const collectionId of cipher.collectionIds) {
|
||||
const collection = collectionMap.get(collectionId);
|
||||
if (!collection) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process direct user assignments
|
||||
for (const userAccess of collection.users) {
|
||||
const userId = userAccess.id;
|
||||
|
||||
if (!memberAccessMap.has(userId)) {
|
||||
memberAccessMap.set(userId, {
|
||||
userId,
|
||||
email: userEmailMap.get(userId) ?? null,
|
||||
accessPaths: [],
|
||||
effectivePermissions: {
|
||||
canEdit: false,
|
||||
canViewPasswords: false,
|
||||
canManage: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const memberAccess = memberAccessMap.get(userId)!;
|
||||
memberAccess.accessPaths.push({
|
||||
type: "direct",
|
||||
collectionId: collection.id,
|
||||
collectionName: collection.name || "Unknown",
|
||||
permissions: {
|
||||
readOnly: userAccess.readOnly,
|
||||
hidePasswords: userAccess.hidePasswords,
|
||||
manage: userAccess.manage,
|
||||
},
|
||||
});
|
||||
|
||||
this.updateEffectivePermissions(memberAccess, userAccess);
|
||||
}
|
||||
|
||||
// Process group assignments
|
||||
for (const groupAccess of collection.groups) {
|
||||
const groupId = groupAccess.id;
|
||||
const groupMemberData = groupMemberMap.get(groupId);
|
||||
|
||||
if (!groupMemberData || groupMemberData.memberIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const userId of groupMemberData.memberIds) {
|
||||
if (!memberAccessMap.has(userId)) {
|
||||
memberAccessMap.set(userId, {
|
||||
userId,
|
||||
email: userEmailMap.get(userId) ?? null,
|
||||
accessPaths: [],
|
||||
effectivePermissions: {
|
||||
canEdit: false,
|
||||
canViewPasswords: false,
|
||||
canManage: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const memberAccess = memberAccessMap.get(userId)!;
|
||||
memberAccess.accessPaths.push({
|
||||
type: "group",
|
||||
collectionId: collection.id,
|
||||
collectionName: collection.name || "Unknown",
|
||||
groupId: groupId,
|
||||
groupName: groupMemberData.groupName,
|
||||
permissions: {
|
||||
readOnly: groupAccess.readOnly,
|
||||
hidePasswords: groupAccess.hidePasswords,
|
||||
manage: groupAccess.manage,
|
||||
},
|
||||
});
|
||||
|
||||
this.updateEffectivePermissions(memberAccess, groupAccess);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
cipher,
|
||||
members: Array.from(memberAccessMap.values()),
|
||||
totalMemberCount: memberAccessMap.size,
|
||||
unassigned: isUnassigned,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the effective permissions based on a new access path
|
||||
* Uses "most permissive" logic - if ANY path grants a permission, it's granted
|
||||
*/
|
||||
private updateEffectivePermissions(
|
||||
memberAccess: CipherMemberAccess,
|
||||
accessPermissions: { readOnly: boolean; hidePasswords: boolean; manage: boolean },
|
||||
): void {
|
||||
// Can edit if at least one path is NOT read-only
|
||||
if (!accessPermissions.readOnly) {
|
||||
memberAccess.effectivePermissions.canEdit = true;
|
||||
}
|
||||
|
||||
// Can view passwords if at least one path does NOT hide passwords
|
||||
if (!accessPermissions.hidePasswords) {
|
||||
memberAccess.effectivePermissions.canViewPasswords = true;
|
||||
}
|
||||
|
||||
// Can manage if at least one path grants manage permission
|
||||
if (accessPermissions.manage) {
|
||||
memberAccess.effectivePermissions.canManage = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import { filter, from, map, mergeMap, Observable, toArray } from "rxjs";
|
||||
import { Injectable } from "@angular/core";
|
||||
import {
|
||||
bufferCount,
|
||||
filter,
|
||||
from,
|
||||
map,
|
||||
mergeMap,
|
||||
Observable,
|
||||
scan,
|
||||
startWith,
|
||||
toArray,
|
||||
} from "rxjs";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -12,6 +23,38 @@ import {
|
||||
WeakPasswordScore,
|
||||
} from "../../models/password-health";
|
||||
|
||||
/**
|
||||
* State of HIBP (Have I Been Pwned) password checking (per ADR-0025 - no enums)
|
||||
*/
|
||||
export const HibpCheckState = Object.freeze({
|
||||
NotStarted: "not-started",
|
||||
Checking: "checking",
|
||||
Complete: "complete",
|
||||
Error: "error",
|
||||
} as const);
|
||||
export type HibpCheckState = (typeof HibpCheckState)[keyof typeof HibpCheckState];
|
||||
|
||||
/**
|
||||
* Progress result for progressive HIBP password checking
|
||||
*/
|
||||
export interface HibpProgressResult {
|
||||
/** Current state of the HIBP check */
|
||||
state: HibpCheckState;
|
||||
/** Number of passwords checked so far */
|
||||
checkedCount: number;
|
||||
/** Total number of passwords to check */
|
||||
totalCount: number;
|
||||
/** Progress percentage (0-100) */
|
||||
progressPercent: number;
|
||||
/** Exposed passwords found so far */
|
||||
exposedPasswords: ExposedPasswordDetail[];
|
||||
/** Elapsed time in milliseconds */
|
||||
elapsedMs: number;
|
||||
/** Error message if state is Error */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PasswordHealthService {
|
||||
constructor(
|
||||
private auditService: AuditService,
|
||||
@@ -42,6 +85,93 @@ export class PasswordHealthService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Progressive version of auditPasswordLeaks$ that emits progress updates
|
||||
* as passwords are checked against HIBP.
|
||||
*
|
||||
* @param ciphers The list of ciphers to check.
|
||||
* @param batchEmitSize How often to emit progress updates (default: 500 passwords).
|
||||
* @returns An observable that emits HibpProgressResult with progress updates.
|
||||
*/
|
||||
auditPasswordLeaksProgressive$(
|
||||
ciphers: CipherView[],
|
||||
batchEmitSize: number = 500,
|
||||
): Observable<HibpProgressResult> {
|
||||
const validCiphers = ciphers.filter((c) => this.isValidCipher(c));
|
||||
const totalCount = validCiphers.length;
|
||||
const startTime = performance.now();
|
||||
|
||||
if (totalCount === 0) {
|
||||
// No valid ciphers to check - emit immediate completion
|
||||
return from([
|
||||
{
|
||||
state: HibpCheckState.Complete,
|
||||
checkedCount: 0,
|
||||
totalCount: 0,
|
||||
progressPercent: 100,
|
||||
exposedPasswords: [],
|
||||
elapsedMs: 0,
|
||||
} as HibpProgressResult,
|
||||
]);
|
||||
}
|
||||
|
||||
// Accumulator for scan operator
|
||||
interface ProgressAccumulator {
|
||||
checkedCount: number;
|
||||
exposedPasswords: ExposedPasswordDetail[];
|
||||
}
|
||||
|
||||
return from(validCiphers).pipe(
|
||||
// Use mergeMap with concurrency matching audit service (100 concurrent)
|
||||
mergeMap(
|
||||
(cipher) =>
|
||||
from(this.auditService.passwordLeaked(cipher.login.password!)).pipe(
|
||||
map((exposedCount) => ({ cipherId: cipher.id, exposedCount })),
|
||||
),
|
||||
100,
|
||||
),
|
||||
// Buffer results and emit every batchEmitSize checks
|
||||
bufferCount(batchEmitSize),
|
||||
// Use scan to accumulate results across batches
|
||||
scan(
|
||||
(acc: ProgressAccumulator, batch) => {
|
||||
const newExposed = batch
|
||||
.filter((result) => result.exposedCount > 0)
|
||||
.map((result) => ({
|
||||
cipherId: result.cipherId,
|
||||
exposedXTimes: result.exposedCount,
|
||||
}));
|
||||
|
||||
return {
|
||||
checkedCount: acc.checkedCount + batch.length,
|
||||
exposedPasswords: [...acc.exposedPasswords, ...newExposed],
|
||||
};
|
||||
},
|
||||
{ checkedCount: 0, exposedPasswords: [] } as ProgressAccumulator,
|
||||
),
|
||||
// Map accumulated state to progress result
|
||||
map(
|
||||
(acc): HibpProgressResult => ({
|
||||
state: acc.checkedCount >= totalCount ? HibpCheckState.Complete : HibpCheckState.Checking,
|
||||
checkedCount: acc.checkedCount,
|
||||
totalCount,
|
||||
progressPercent: Math.round((acc.checkedCount / totalCount) * 100),
|
||||
exposedPasswords: acc.exposedPasswords,
|
||||
elapsedMs: performance.now() - startTime,
|
||||
}),
|
||||
),
|
||||
// Start with initial progress state
|
||||
startWith({
|
||||
state: HibpCheckState.Checking,
|
||||
checkedCount: 0,
|
||||
totalCount,
|
||||
progressPercent: 0,
|
||||
exposedPasswords: [],
|
||||
elapsedMs: 0,
|
||||
} as HibpProgressResult),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts username parts from the cipher's username.
|
||||
* This is used to help determine password strength.
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
import { Injectable, signal, inject, DestroyRef } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { from, Observable, of } from "rxjs";
|
||||
import { catchError, switchMap, tap, last, map } from "rxjs/operators";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { CipherAccessMappingService, MemberAccessLoadState } from "./cipher-access-mapping.service";
|
||||
import { PasswordHealthService } from "./password-health.service";
|
||||
import { RiskInsightsPrototypeService } from "./risk-insights-prototype.service";
|
||||
import {
|
||||
ProcessingPhase,
|
||||
ProgressInfo,
|
||||
RiskInsightsItem,
|
||||
RiskInsightsItemStatus,
|
||||
calculateRiskStatus,
|
||||
} from "./risk-insights-prototype.types";
|
||||
|
||||
/**
|
||||
* Orchestration service for the Risk Insights Prototype.
|
||||
*
|
||||
* Coordinates progressive loading in phases:
|
||||
* - Phase 1: Load ciphers and display immediately
|
||||
* - Phase 2: Run health checks (weak + reused) if enabled
|
||||
* - Phase 3: Load member counts progressively
|
||||
* - Phase 4: Run HIBP checks last (if enabled), updating items progressively
|
||||
*
|
||||
* Uses Angular Signals internally (per ADR-0027), exposed as read-only signals.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RiskInsightsPrototypeOrchestrationService {
|
||||
// ============================================================================
|
||||
// Injected Dependencies
|
||||
// ============================================================================
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly cipherService = inject(CipherService);
|
||||
private readonly cipherAccessMappingService = inject(CipherAccessMappingService);
|
||||
private readonly passwordHealthService = inject(PasswordHealthService);
|
||||
private readonly riskInsightsService = inject(RiskInsightsPrototypeService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
// ============================================================================
|
||||
// Private State
|
||||
// ============================================================================
|
||||
private organizationId: OrganizationId | null = null;
|
||||
private currentUserId: UserId | null = null;
|
||||
private cipherIndexMap = new Map<string, number>();
|
||||
private allCiphers: CipherView[] = [];
|
||||
private passwordUseMap: Map<string, string[]> = new Map();
|
||||
|
||||
// ============================================================================
|
||||
// Internal Signals (private, writable)
|
||||
// ============================================================================
|
||||
|
||||
// Configuration flags (default all to false per requirements)
|
||||
private readonly _enableWeakPassword = signal(false);
|
||||
private readonly _enableHibp = signal(false);
|
||||
private readonly _enableReusedPassword = signal(false);
|
||||
|
||||
// Processing state
|
||||
private readonly _processingPhase = signal<ProcessingPhase>(ProcessingPhase.Idle);
|
||||
private readonly _progressMessage = signal("");
|
||||
|
||||
// Progress tracking
|
||||
private readonly _cipherProgress = signal<ProgressInfo>({ current: 0, total: 0, percent: 0 });
|
||||
private readonly _healthProgress = signal<ProgressInfo>({ current: 0, total: 0, percent: 0 });
|
||||
private readonly _memberProgress = signal<ProgressInfo>({ current: 0, total: 0, percent: 0 });
|
||||
private readonly _hibpProgress = signal<ProgressInfo>({ current: 0, total: 0, percent: 0 });
|
||||
|
||||
// Results
|
||||
private readonly _items = signal<RiskInsightsItem[]>([]);
|
||||
|
||||
// Error state
|
||||
private readonly _error = signal<string | null>(null);
|
||||
|
||||
// ============================================================================
|
||||
// Public Read-only Signals (for template binding)
|
||||
// ============================================================================
|
||||
|
||||
// Configuration flags
|
||||
readonly enableWeakPassword = this._enableWeakPassword.asReadonly();
|
||||
readonly enableHibp = this._enableHibp.asReadonly();
|
||||
readonly enableReusedPassword = this._enableReusedPassword.asReadonly();
|
||||
|
||||
// Processing state
|
||||
readonly processingPhase = this._processingPhase.asReadonly();
|
||||
readonly progressMessage = this._progressMessage.asReadonly();
|
||||
|
||||
// Progress tracking
|
||||
readonly cipherProgress = this._cipherProgress.asReadonly();
|
||||
readonly healthProgress = this._healthProgress.asReadonly();
|
||||
readonly memberProgress = this._memberProgress.asReadonly();
|
||||
readonly hibpProgress = this._hibpProgress.asReadonly();
|
||||
|
||||
// Results
|
||||
readonly items = this._items.asReadonly();
|
||||
|
||||
// Error state
|
||||
readonly error = this._error.asReadonly();
|
||||
|
||||
// Expose constants for template access
|
||||
readonly ProcessingPhase = ProcessingPhase;
|
||||
|
||||
// ============================================================================
|
||||
// Public Methods - Initialization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Initialize the service for a specific organization.
|
||||
*/
|
||||
initializeForOrganization(organizationId: OrganizationId): void {
|
||||
this.organizationId = organizationId;
|
||||
|
||||
this.accountService.activeAccount$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((account) => {
|
||||
if (account) {
|
||||
this.currentUserId = account.id as UserId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public Methods - Configuration
|
||||
// ============================================================================
|
||||
|
||||
toggleEnableWeakPassword(): void {
|
||||
this._enableWeakPassword.update((current) => !current);
|
||||
}
|
||||
|
||||
toggleEnableHibp(): void {
|
||||
this._enableHibp.update((current) => !current);
|
||||
}
|
||||
|
||||
toggleEnableReusedPassword(): void {
|
||||
this._enableReusedPassword.update((current) => !current);
|
||||
}
|
||||
|
||||
setEnableWeakPassword(enabled: boolean): void {
|
||||
this._enableWeakPassword.set(enabled);
|
||||
}
|
||||
|
||||
setEnableHibp(enabled: boolean): void {
|
||||
this._enableHibp.set(enabled);
|
||||
}
|
||||
|
||||
setEnableReusedPassword(enabled: boolean): void {
|
||||
this._enableReusedPassword.set(enabled);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public Methods - Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Starts progressive loading:
|
||||
* Phase 1: Load ciphers, display immediately
|
||||
* Phase 2: Run health checks (weak + reused) if enabled
|
||||
* Phase 3: Load member counts progressively
|
||||
* Phase 4: Run HIBP checks last (if enabled)
|
||||
*/
|
||||
startProcessing(): void {
|
||||
if (!this.organizationId || !this.currentUserId) {
|
||||
this._processingPhase.set(ProcessingPhase.Error);
|
||||
this._error.set("Organization ID or User ID not available");
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetState();
|
||||
this._processingPhase.set(ProcessingPhase.LoadingCiphers);
|
||||
this._progressMessage.set("Loading ciphers...");
|
||||
|
||||
// PHASE 1: Load ciphers
|
||||
from(this.cipherService.getAllFromApiForOrganization(this.organizationId))
|
||||
.pipe(
|
||||
tap((ciphers) => {
|
||||
this.allCiphers = ciphers;
|
||||
|
||||
// Transform to items and display immediately
|
||||
const items = this.riskInsightsService.transformCiphersToItems(ciphers);
|
||||
this._items.set(items);
|
||||
|
||||
// Build cipher index map for O(1) updates
|
||||
this.cipherIndexMap.clear();
|
||||
items.forEach((item, index) => {
|
||||
this.cipherIndexMap.set(item.cipherId, index);
|
||||
});
|
||||
|
||||
this._cipherProgress.set({
|
||||
current: items.length,
|
||||
total: items.length,
|
||||
percent: 100,
|
||||
});
|
||||
|
||||
// Build password use map for reuse detection
|
||||
this.passwordUseMap = this.riskInsightsService.buildPasswordUseMap(ciphers);
|
||||
}),
|
||||
// PHASE 2: Run health checks if enabled
|
||||
switchMap(() => this.runHealthChecksIfEnabled$()),
|
||||
// PHASE 3: Load member counts
|
||||
switchMap(() => this.runMemberCountsLoading$()),
|
||||
// PHASE 4: Run HIBP checks if enabled (runs last)
|
||||
tap(() => {
|
||||
if (this._enableHibp()) {
|
||||
this._processingPhase.set(ProcessingPhase.RunningHibp);
|
||||
this._progressMessage.set("Checking for exposed passwords...");
|
||||
this.runHibpChecks$()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
complete: () => {
|
||||
this._processingPhase.set(ProcessingPhase.Complete);
|
||||
this._progressMessage.set("");
|
||||
},
|
||||
error: (_err: unknown) => {
|
||||
// HIBP check error - silently ignore for prototype
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this._processingPhase.set(ProcessingPhase.Complete);
|
||||
this._progressMessage.set("");
|
||||
this.finalizeItemStatuses();
|
||||
}
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
catchError((err: unknown) => {
|
||||
this._processingPhase.set(ProcessingPhase.Error);
|
||||
const errorMessage = err instanceof Error ? err.message : "An error occurred";
|
||||
this._error.set(errorMessage);
|
||||
return of(undefined);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all state to initial values.
|
||||
*/
|
||||
resetState(): void {
|
||||
this._items.set([]);
|
||||
this._processingPhase.set(ProcessingPhase.Idle);
|
||||
this._progressMessage.set("");
|
||||
this._cipherProgress.set({ current: 0, total: 0, percent: 0 });
|
||||
this._healthProgress.set({ current: 0, total: 0, percent: 0 });
|
||||
this._memberProgress.set({ current: 0, total: 0, percent: 0 });
|
||||
this._hibpProgress.set({ current: 0, total: 0, percent: 0 });
|
||||
this._error.set(null);
|
||||
this.cipherIndexMap.clear();
|
||||
this.allCiphers = [];
|
||||
this.passwordUseMap.clear();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private Methods - Health Checks
|
||||
// ============================================================================
|
||||
|
||||
private runHealthChecksIfEnabled$(): Observable<void> {
|
||||
const enableWeak = this._enableWeakPassword();
|
||||
const enableReused = this._enableReusedPassword();
|
||||
|
||||
if (!enableWeak && !enableReused) {
|
||||
// No health checks enabled, skip this phase
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
this._processingPhase.set(ProcessingPhase.RunningHealthChecks);
|
||||
this._progressMessage.set("Analyzing password health...");
|
||||
|
||||
const totalCiphers = this.allCiphers.length;
|
||||
let processedCount = 0;
|
||||
|
||||
return this.riskInsightsService.checkWeakPasswordsBatched$(this.allCiphers, 100).pipe(
|
||||
tap((weakResults) => {
|
||||
// Update items with weak password results
|
||||
const currentItems = [...this._items()];
|
||||
const reusedCipherIds = this.riskInsightsService.findReusedPasswordCipherIds(
|
||||
currentItems.map((i) => i.cipherId),
|
||||
this.passwordUseMap,
|
||||
this.allCiphers,
|
||||
);
|
||||
|
||||
for (const result of weakResults) {
|
||||
const index = this.cipherIndexMap.get(result.cipherId);
|
||||
if (index === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let item = currentItems[index];
|
||||
|
||||
// Update weak password status if enabled
|
||||
if (enableWeak) {
|
||||
item = this.riskInsightsService.updateItemWithWeakPassword(
|
||||
item,
|
||||
result.weakPasswordDetail,
|
||||
enableWeak,
|
||||
enableReused,
|
||||
this._enableHibp(),
|
||||
);
|
||||
}
|
||||
|
||||
// Update reused password status if enabled
|
||||
if (enableReused) {
|
||||
const isReused = reusedCipherIds.has(result.cipherId);
|
||||
item = this.riskInsightsService.updateItemWithReusedPassword(
|
||||
item,
|
||||
isReused,
|
||||
enableWeak,
|
||||
enableReused,
|
||||
this._enableHibp(),
|
||||
);
|
||||
}
|
||||
|
||||
currentItems[index] = item;
|
||||
processedCount++;
|
||||
}
|
||||
|
||||
this._items.set(currentItems);
|
||||
this._healthProgress.set({
|
||||
current: processedCount,
|
||||
total: totalCiphers,
|
||||
percent: Math.round((processedCount / totalCiphers) * 100),
|
||||
});
|
||||
}),
|
||||
map((): void => undefined),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private Methods - Member Counts
|
||||
// ============================================================================
|
||||
|
||||
private runMemberCountsLoading$(): Observable<void> {
|
||||
if (!this.organizationId || !this.currentUserId) {
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
this._processingPhase.set(ProcessingPhase.LoadingMembers);
|
||||
this._progressMessage.set("Loading member access data...");
|
||||
|
||||
const BATCH_SIZE = 200;
|
||||
let lastUpdateTime = 0;
|
||||
const UPDATE_THROTTLE_MS = 100;
|
||||
|
||||
return this.cipherAccessMappingService
|
||||
.getAllCiphersWithMemberAccessProgressive$(
|
||||
this.organizationId,
|
||||
this.currentUserId,
|
||||
this.allCiphers,
|
||||
BATCH_SIZE,
|
||||
)
|
||||
.pipe(
|
||||
tap((progressResult) => {
|
||||
const now = performance.now();
|
||||
const isComplete = progressResult.state === MemberAccessLoadState.Complete;
|
||||
const shouldUpdate = isComplete || now - lastUpdateTime >= UPDATE_THROTTLE_MS;
|
||||
|
||||
if (shouldUpdate) {
|
||||
lastUpdateTime = now;
|
||||
this._memberProgress.set({
|
||||
current: progressResult.processedCount,
|
||||
total: progressResult.totalCipherCount,
|
||||
percent: progressResult.progressPercent,
|
||||
});
|
||||
|
||||
this._progressMessage.set(
|
||||
`Loading member access: ${progressResult.processedCount}/${progressResult.totalCipherCount}`,
|
||||
);
|
||||
|
||||
// Update items with member counts
|
||||
this.updateItemsWithMemberCounts(progressResult.processedCiphers);
|
||||
}
|
||||
}),
|
||||
last(),
|
||||
map((): void => undefined),
|
||||
catchError((_err: unknown) => {
|
||||
// Member access error - silently continue for prototype
|
||||
return of(undefined);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private updateItemsWithMemberCounts(
|
||||
processedCiphers: Array<{ cipher: CipherView; totalMemberCount: number }>,
|
||||
): void {
|
||||
const currentItems = [...this._items()];
|
||||
let hasChanges = false;
|
||||
|
||||
for (const processed of processedCiphers) {
|
||||
const index = this.cipherIndexMap.get(processed.cipher.id);
|
||||
if (index === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = currentItems[index];
|
||||
if (item.memberCount === processed.totalMemberCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentItems[index] = this.riskInsightsService.updateItemWithMemberCount(
|
||||
item,
|
||||
processed.totalMemberCount,
|
||||
);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
this._items.set(currentItems);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private Methods - HIBP Checks
|
||||
// ============================================================================
|
||||
|
||||
private runHibpChecks$(): Observable<void> {
|
||||
const validCiphers = this.allCiphers.filter(
|
||||
(c) => c.login?.password && !c.isDeleted && c.viewPassword,
|
||||
);
|
||||
|
||||
if (validCiphers.length === 0) {
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
return this.passwordHealthService.auditPasswordLeaksProgressive$(validCiphers, 500).pipe(
|
||||
tap((result) => {
|
||||
this._hibpProgress.set({
|
||||
current: result.checkedCount,
|
||||
total: result.totalCount,
|
||||
percent: result.progressPercent,
|
||||
});
|
||||
|
||||
this._progressMessage.set(
|
||||
`Checking exposed passwords: ${result.checkedCount}/${result.totalCount}`,
|
||||
);
|
||||
|
||||
// Update items with exposed password data
|
||||
this.updateItemsWithExposedPasswords(result.exposedPasswords);
|
||||
}),
|
||||
last(),
|
||||
map((): void => undefined),
|
||||
);
|
||||
}
|
||||
|
||||
private updateItemsWithExposedPasswords(
|
||||
exposedPasswords: Array<{ cipherId: string; exposedXTimes: number }>,
|
||||
): void {
|
||||
const currentItems = [...this._items()];
|
||||
const exposedMap = new Map(exposedPasswords.map((ep) => [ep.cipherId, ep.exposedXTimes]));
|
||||
let hasChanges = false;
|
||||
|
||||
// Update exposed items
|
||||
for (const [cipherId, exposedCount] of exposedMap) {
|
||||
const index = this.cipherIndexMap.get(cipherId);
|
||||
if (index === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = currentItems[index];
|
||||
currentItems[index] = this.riskInsightsService.updateItemWithExposedPassword(
|
||||
item,
|
||||
exposedCount,
|
||||
this._enableWeakPassword(),
|
||||
this._enableReusedPassword(),
|
||||
this._enableHibp(),
|
||||
);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Mark items not in exposed list as not exposed
|
||||
for (let i = 0; i < currentItems.length; i++) {
|
||||
const item = currentItems[i];
|
||||
if (item.exposedPassword === null && !exposedMap.has(item.cipherId)) {
|
||||
currentItems[i] = this.riskInsightsService.updateItemWithExposedPassword(
|
||||
item,
|
||||
0,
|
||||
this._enableWeakPassword(),
|
||||
this._enableReusedPassword(),
|
||||
this._enableHibp(),
|
||||
);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
this._items.set(currentItems);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private Methods - Finalization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Finalize item statuses when no HIBP check is enabled.
|
||||
* Sets all items to healthy if no checks are enabled, or calculates final status.
|
||||
*/
|
||||
private finalizeItemStatuses(): void {
|
||||
const enableWeak = this._enableWeakPassword();
|
||||
const enableReused = this._enableReusedPassword();
|
||||
const enableHibp = this._enableHibp();
|
||||
|
||||
const currentItems = [...this._items()];
|
||||
let hasChanges = false;
|
||||
|
||||
for (let i = 0; i < currentItems.length; i++) {
|
||||
const item = currentItems[i];
|
||||
|
||||
// If no checks enabled, mark as healthy
|
||||
if (!enableWeak && !enableReused && !enableHibp) {
|
||||
if (item.status !== RiskInsightsItemStatus.Healthy) {
|
||||
currentItems[i] = {
|
||||
...item,
|
||||
status: RiskInsightsItemStatus.Healthy,
|
||||
};
|
||||
hasChanges = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate final status based on enabled checks
|
||||
const newStatus = calculateRiskStatus(
|
||||
item.weakPassword,
|
||||
item.reusedPassword,
|
||||
enableHibp ? item.exposedPassword : false, // If HIBP not enabled, don't count as factor
|
||||
enableWeak,
|
||||
enableReused,
|
||||
enableHibp,
|
||||
);
|
||||
|
||||
if (item.status !== newStatus) {
|
||||
currentItems[i] = {
|
||||
...item,
|
||||
status: newStatus,
|
||||
};
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
this._items.set(currentItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { Injectable, inject } from "@angular/core";
|
||||
import { from, Observable } from "rxjs";
|
||||
import { concatMap, map, toArray } from "rxjs/operators";
|
||||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import type { WeakPasswordDetail } from "../../models/password-health";
|
||||
|
||||
import { PasswordHealthService } from "./password-health.service";
|
||||
import {
|
||||
createRiskInsightsItem,
|
||||
RiskInsightsItem,
|
||||
RiskInsightsItemStatus,
|
||||
calculateRiskStatus,
|
||||
} from "./risk-insights-prototype.types";
|
||||
|
||||
/**
|
||||
* Result of weak password check for a single cipher
|
||||
*/
|
||||
export interface WeakPasswordCheckResult {
|
||||
cipherId: string;
|
||||
weakPasswordDetail: WeakPasswordDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for transforming and processing cipher data for the Risk Insights Prototype.
|
||||
*
|
||||
* Handles:
|
||||
* - Transforming CipherView to RiskInsightsItem
|
||||
* - Running weak password checks in batches
|
||||
* - Building password use maps for reuse detection
|
||||
* - Calculating risk status
|
||||
*/
|
||||
@Injectable()
|
||||
export class RiskInsightsPrototypeService {
|
||||
private readonly passwordHealthService = inject(PasswordHealthService);
|
||||
|
||||
/**
|
||||
* Transform ciphers to RiskInsightsItems with placeholder values.
|
||||
* The items will be progressively enriched with health and member data.
|
||||
*
|
||||
* @param ciphers The ciphers to transform
|
||||
* @returns Array of RiskInsightsItems with placeholder values
|
||||
*/
|
||||
transformCiphersToItems(ciphers: CipherView[]): RiskInsightsItem[] {
|
||||
return ciphers
|
||||
.filter((cipher) => this.isValidCipher(cipher))
|
||||
.map((cipher) => createRiskInsightsItem(cipher));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check weak passwords for a batch of ciphers using requestAnimationFrame
|
||||
* to avoid blocking the UI.
|
||||
*
|
||||
* @param ciphers The ciphers to check
|
||||
* @param batchSize Number of ciphers to process per frame (default: 100)
|
||||
* @returns Observable that emits weak password results for each batch
|
||||
*/
|
||||
checkWeakPasswordsBatched$(
|
||||
ciphers: CipherView[],
|
||||
batchSize: number = 100,
|
||||
): Observable<WeakPasswordCheckResult[]> {
|
||||
const validCiphers = ciphers.filter((c) => this.isValidCipher(c));
|
||||
const batches = this.createBatches(validCiphers, batchSize);
|
||||
|
||||
return from(batches).pipe(
|
||||
concatMap((batch, batchIndex) =>
|
||||
this.processWeakPasswordBatch(batch, batchIndex, batches.length),
|
||||
),
|
||||
toArray(),
|
||||
map((batchResults) => batchResults.flat()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single batch of ciphers for weak passwords using requestAnimationFrame.
|
||||
*/
|
||||
private processWeakPasswordBatch(
|
||||
batch: CipherView[],
|
||||
_batchIndex: number,
|
||||
_totalBatches: number,
|
||||
): Observable<WeakPasswordCheckResult[]> {
|
||||
return new Observable<WeakPasswordCheckResult[]>((subscriber) => {
|
||||
requestAnimationFrame(() => {
|
||||
const results: WeakPasswordCheckResult[] = batch.map((cipher) => ({
|
||||
cipherId: cipher.id,
|
||||
weakPasswordDetail: this.passwordHealthService.findWeakPasswordDetails(cipher),
|
||||
}));
|
||||
subscriber.next(results);
|
||||
subscriber.complete();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of password hashes to cipher IDs for detecting reused passwords.
|
||||
* Only includes ciphers with valid passwords.
|
||||
*
|
||||
* @param ciphers The ciphers to analyze
|
||||
* @returns Map where key is password hash, value is array of cipher IDs using that password
|
||||
*/
|
||||
buildPasswordUseMap(ciphers: CipherView[]): Map<string, string[]> {
|
||||
const passwordUseMap = new Map<string, string[]>();
|
||||
|
||||
for (const cipher of ciphers) {
|
||||
if (!this.isValidCipher(cipher)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const password = cipher.login?.password;
|
||||
if (!password) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use a simple hash of the password as the key
|
||||
// This avoids storing actual passwords in memory
|
||||
const passwordKey = this.hashPassword(password);
|
||||
|
||||
const existing = passwordUseMap.get(passwordKey);
|
||||
if (existing) {
|
||||
existing.push(cipher.id);
|
||||
} else {
|
||||
passwordUseMap.set(passwordKey, [cipher.id]);
|
||||
}
|
||||
}
|
||||
|
||||
return passwordUseMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which ciphers have reused passwords based on a password use map.
|
||||
*
|
||||
* @param cipherIds The cipher IDs to check
|
||||
* @param passwordUseMap Map of password hashes to cipher IDs
|
||||
* @param ciphers The original ciphers (for password lookup)
|
||||
* @returns Set of cipher IDs that have reused passwords
|
||||
*/
|
||||
findReusedPasswordCipherIds(
|
||||
cipherIds: string[],
|
||||
passwordUseMap: Map<string, string[]>,
|
||||
ciphers: CipherView[],
|
||||
): Set<string> {
|
||||
const reusedCipherIds = new Set<string>();
|
||||
const cipherMap = new Map(ciphers.map((c) => [c.id, c]));
|
||||
|
||||
for (const cipherId of cipherIds) {
|
||||
const cipher = cipherMap.get(cipherId);
|
||||
if (!cipher || !this.isValidCipher(cipher)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const password = cipher.login?.password;
|
||||
if (!password) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const passwordKey = this.hashPassword(password);
|
||||
const usedBy = passwordUseMap.get(passwordKey);
|
||||
|
||||
// Password is reused if more than one cipher uses it
|
||||
if (usedBy && usedBy.length > 1) {
|
||||
reusedCipherIds.add(cipherId);
|
||||
}
|
||||
}
|
||||
|
||||
return reusedCipherIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an item with weak password status.
|
||||
*/
|
||||
updateItemWithWeakPassword(
|
||||
item: RiskInsightsItem,
|
||||
weakPasswordDetail: WeakPasswordDetail,
|
||||
enableWeakCheck: boolean,
|
||||
enableReusedCheck: boolean,
|
||||
enableHibpCheck: boolean,
|
||||
): RiskInsightsItem {
|
||||
const weakPassword = weakPasswordDetail !== null;
|
||||
const newStatus = calculateRiskStatus(
|
||||
weakPassword,
|
||||
item.reusedPassword,
|
||||
item.exposedPassword,
|
||||
enableWeakCheck,
|
||||
enableReusedCheck,
|
||||
enableHibpCheck,
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
weakPassword,
|
||||
status: newStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an item with reused password status.
|
||||
*/
|
||||
updateItemWithReusedPassword(
|
||||
item: RiskInsightsItem,
|
||||
isReused: boolean,
|
||||
enableWeakCheck: boolean,
|
||||
enableReusedCheck: boolean,
|
||||
enableHibpCheck: boolean,
|
||||
): RiskInsightsItem {
|
||||
const newStatus = calculateRiskStatus(
|
||||
item.weakPassword,
|
||||
isReused,
|
||||
item.exposedPassword,
|
||||
enableWeakCheck,
|
||||
enableReusedCheck,
|
||||
enableHibpCheck,
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
reusedPassword: isReused,
|
||||
status: newStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an item with exposed password status.
|
||||
*/
|
||||
updateItemWithExposedPassword(
|
||||
item: RiskInsightsItem,
|
||||
exposedCount: number,
|
||||
enableWeakCheck: boolean,
|
||||
enableReusedCheck: boolean,
|
||||
enableHibpCheck: boolean,
|
||||
): RiskInsightsItem {
|
||||
const exposedPassword = exposedCount > 0;
|
||||
const newStatus = calculateRiskStatus(
|
||||
item.weakPassword,
|
||||
item.reusedPassword,
|
||||
exposedPassword,
|
||||
enableWeakCheck,
|
||||
enableReusedCheck,
|
||||
enableHibpCheck,
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
exposedPassword,
|
||||
exposedCount,
|
||||
status: newStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an item with member count.
|
||||
*/
|
||||
updateItemWithMemberCount(item: RiskInsightsItem, memberCount: number): RiskInsightsItem {
|
||||
return {
|
||||
...item,
|
||||
memberCount,
|
||||
memberAccessPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an item's status when all checks are complete.
|
||||
* This is called when no health checks are enabled but we still need a status.
|
||||
*/
|
||||
finalizeItemStatus(
|
||||
item: RiskInsightsItem,
|
||||
enableWeakCheck: boolean,
|
||||
enableReusedCheck: boolean,
|
||||
enableHibpCheck: boolean,
|
||||
): RiskInsightsItem {
|
||||
// If no checks enabled, mark as healthy
|
||||
if (!enableWeakCheck && !enableReusedCheck && !enableHibpCheck) {
|
||||
return {
|
||||
...item,
|
||||
status: RiskInsightsItemStatus.Healthy,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create batches from an array.
|
||||
*/
|
||||
private createBatches<T>(items: T[], batchSize: number): T[][] {
|
||||
const batches: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
batches.push(items.slice(i, i + batchSize));
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple hash function for password deduplication.
|
||||
* Uses a basic string hash for performance.
|
||||
*/
|
||||
private hashPassword(password: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < password.length; i++) {
|
||||
const char = password.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the cipher is a login item with a valid password.
|
||||
*/
|
||||
private isValidCipher(cipher: CipherView): boolean {
|
||||
if (!cipher) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { type, login, isDeleted, viewPassword } = cipher;
|
||||
|
||||
if (
|
||||
type !== CipherType.Login ||
|
||||
!login?.password ||
|
||||
Utils.isNullOrWhitespace(login.password) ||
|
||||
isDeleted ||
|
||||
!viewPassword
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./models";
|
||||
export * from "./reports";
|
||||
export * from "./services";
|
||||
|
||||
1
libs/common/src/dirt/reports/index.ts
Normal file
1
libs/common/src/dirt/reports/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./risk-insights";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./risk-insights-prototype-orchestration.service.abstraction";
|
||||
@@ -0,0 +1,61 @@
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { ProcessingPhase, ProgressInfo, RiskInsightsItem } from "../types";
|
||||
|
||||
/**
|
||||
* Generic read-only signal interface for framework-agnostic abstraction.
|
||||
* Implementations can use Angular Signals or other reactive primitives.
|
||||
*/
|
||||
export interface ReadonlySignal<T> {
|
||||
(): T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstraction for the Risk Insights Prototype Orchestration Service.
|
||||
*
|
||||
* Coordinates progressive loading in phases:
|
||||
* - Phase 1: Load ciphers and display immediately
|
||||
* - Phase 2: Run health checks (weak + reused) if enabled
|
||||
* - Phase 3: Load member counts progressively
|
||||
* - Phase 4: Run HIBP checks last (if enabled)
|
||||
*/
|
||||
export abstract class RiskInsightsPrototypeOrchestrationServiceAbstraction {
|
||||
// Configuration flags (read-only signals)
|
||||
abstract readonly enableWeakPassword: ReadonlySignal<boolean>;
|
||||
abstract readonly enableHibp: ReadonlySignal<boolean>;
|
||||
abstract readonly enableReusedPassword: ReadonlySignal<boolean>;
|
||||
|
||||
// Processing state (read-only signals)
|
||||
abstract readonly processingPhase: ReadonlySignal<ProcessingPhase>;
|
||||
abstract readonly progressMessage: ReadonlySignal<string>;
|
||||
|
||||
// Progress tracking (read-only signals)
|
||||
abstract readonly cipherProgress: ReadonlySignal<ProgressInfo>;
|
||||
abstract readonly healthProgress: ReadonlySignal<ProgressInfo>;
|
||||
abstract readonly memberProgress: ReadonlySignal<ProgressInfo>;
|
||||
abstract readonly hibpProgress: ReadonlySignal<ProgressInfo>;
|
||||
|
||||
// Results (read-only signal)
|
||||
abstract readonly items: ReadonlySignal<RiskInsightsItem[]>;
|
||||
|
||||
// Error state (read-only signal)
|
||||
abstract readonly error: ReadonlySignal<string | null>;
|
||||
|
||||
// Expose constants for template access
|
||||
abstract readonly ProcessingPhase: typeof ProcessingPhase;
|
||||
|
||||
// Initialization
|
||||
abstract initializeForOrganization(organizationId: OrganizationId): void;
|
||||
|
||||
// Configuration toggles
|
||||
abstract toggleEnableWeakPassword(): void;
|
||||
abstract toggleEnableHibp(): void;
|
||||
abstract toggleEnableReusedPassword(): void;
|
||||
abstract setEnableWeakPassword(enabled: boolean): void;
|
||||
abstract setEnableHibp(enabled: boolean): void;
|
||||
abstract setEnableReusedPassword(enabled: boolean): void;
|
||||
|
||||
// Actions
|
||||
abstract startProcessing(): void;
|
||||
abstract resetState(): void;
|
||||
}
|
||||
2
libs/common/src/dirt/reports/risk-insights/index.ts
Normal file
2
libs/common/src/dirt/reports/risk-insights/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./abstractions";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./risk-insights-prototype.types";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user