1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 08:33:29 +00:00

[PM-26487][PM-20112] Member Access Report - Member Cipher Client Mapping (#18774)

* Added v2 version of member access reports that aggregate data client side instead of using endpoint that times out. Added feature flag.

* Remove feature flag

* Added avatar color to the member access report

* Update icon usage

* Add story book for member access report

* Add icon module to member access report component

* Fix test case

* Update member access report service to match export of v1 version. Update test cases

* Fix billing error in member access report

* Add timeout to fetch organization ciphers

* Handle group naming

* Add cached permission text

* Add memberAccessReportLoadError message

* Fix member cipher mapping to deduplicate data in memory

* Update log

* Update storybook with deterministic data and test type

* Fix avatar color default

* Fix types

* Address timeout cleanup
This commit is contained in:
Leslie Tilton
2026-02-23 09:05:26 -06:00
committed by GitHub
parent 2af9396766
commit 74aec0b80c
7 changed files with 1490 additions and 38 deletions

View File

@@ -10977,6 +10977,9 @@
"memberAccessReportAuthenticationEnabledFalse": {
"message": "Off"
},
"memberAccessReportLoadError": {
"message": "Failed to load the member access report. This may be due to a large organization size or network issue. Please try again or contact support if the problem persists."
},
"kdfIterationRecommends": {
"message": "We recommend 600,000 or more"
},

View File

@@ -9,7 +9,7 @@
></bit-search>
<button type="button" bitButton buttonType="primary" [bitAction]="exportReportAction">
<span>{{ "export" | i18n }}</span>
<i class="bwi bwi-fw bwi-sign-in" aria-hidden="true"></i>
<bit-icon name="bwi-sign-in" class="bwi-fw" aria-hidden="true"></bit-icon>
</button>
}
</app-header>
@@ -22,11 +22,11 @@
@if (isLoading) {
<div class="tw-flex-col tw-flex tw-justify-center tw-items-center tw-gap-5 tw-mt-4">
<i
class="bwi bwi-2x bwi-spinner bwi-spin tw-text-primary-600"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<bit-icon
name="bwi-spinner"
class="bwi-2x bwi-spin tw-text-primary-600"
[ariaLabel]="'loading' | i18n"
></bit-icon>
<h2 bitTypography="h1">{{ "loading" | i18n }}</h2>
</div>
} @else {
@@ -42,7 +42,13 @@
<ng-template bitRowDef let-row>
<td bitCell>
<div class="tw-flex tw-items-center">
<bit-avatar size="small" [text]="row.name" class="tw-mr-3"></bit-avatar>
<bit-avatar
size="small"
[text]="row.name"
[id]="row.userGuid"
[color]="row.avatarColor"
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<button type="button" bitLink (click)="edit(row)">
{{ row.name }}

View File

@@ -0,0 +1,268 @@
import { importProvidersFrom } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import {
applicationConfig,
componentWrapperDecorator,
Meta,
moduleMetadata,
StoryObj,
} from "@storybook/angular";
import { BehaviorSubject, of } from "rxjs";
import {
CollectionAdminService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
import {
BillingAccountProfileStateService,
BillingApiServiceAbstraction,
} from "@bitwarden/common/billing/abstractions";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
VaultTimeoutAction,
VaultTimeoutSettingsService,
} from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { Guid, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService, ScrollLayoutHostDirective, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/app/core/tests";
import { MemberAccessReportComponent } from "./member-access-report.component";
import { MemberAccessReportApiService } from "./services/member-access-report-api.service";
import { MemberAccessReportService } from "./services/member-access-report.service";
import { MemberAccessReportView } from "./view/member-access-report.view";
// ============================================================================
// Mock Data Factory Functions
// ============================================================================
function createMockMember(index: number): MemberAccessReportView {
const names = ["Alice Johnson", "Bob Smith", "Carol Williams", "David Brown", "Eve Martinez"];
const colors = ["#175ddc", "#7c5cdb", "#c93d63", "#d1860a", "#178d5c"];
return {
userGuid: `user-${index}` as Guid,
name: names[index % names.length] || `User ${index}`,
email: `user${index}@example.com`,
avatarColor: colors[index % colors.length],
collectionsCount: ((index * 3) % 10) + 1, // Deterministic: 1-10
groupsCount: ((index * 2) % 5) + 1, // Deterministic: 1-5
itemsCount: ((index * 17) % 200) + 1, // Deterministic: 1-200
usesKeyConnector: index % 2 === 0, // Deterministic: alternating true/false
};
}
const mockMemberData: MemberAccessReportView[] = [...Array(5).keys()].map(createMockMember);
const mockOrganizationId = "org-123" as OrganizationId;
// ============================================================================
// Mock Service Classes
// ============================================================================
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
getApplicationVersion = () => Promise.resolve("2024.1.0");
getClientType = () => ClientType.Web;
isSelfHost = () => false;
}
export default {
title: "DIRT/Reports/Member Access Report",
component: MemberAccessReportComponent,
decorators: [
componentWrapperDecorator(
(story) =>
`<div bitScrollLayoutHost class="tw-flex tw-flex-col tw-h-screen tw-p-6 tw-overflow-auto">${story}</div>`,
),
moduleMetadata({
imports: [ScrollLayoutHostDirective],
providers: [],
}),
applicationConfig({
providers: [
// I18n and Routing
importProvidersFrom(PreloadedEnglishI18nModule),
importProvidersFrom(RouterModule.forRoot([], { useHash: true })),
// Platform Services
{ provide: PlatformUtilsService, useClass: MockPlatformUtilsService },
{ provide: LogService, useValue: { error: () => {}, warning: () => {}, info: () => {} } },
{ provide: MessagingService, useValue: { send: () => {} } },
{
provide: ConfigService,
useValue: { getFeatureFlag$: () => of(false), serverConfig$: of({}) },
},
// Member Access Report Services
{
provide: MemberAccessReportService,
useValue: {
generateMemberAccessReportViewV2: () => Promise.resolve(mockMemberData),
generateUserReportExportItemsV2: () => Promise.resolve([]),
},
},
{ provide: MemberAccessReportApiService, useValue: {} },
// File and Dialog Services
{ provide: FileDownloadService, useValue: { download: () => {} } },
{ provide: DialogService, useValue: { open: () => ({ closed: of(null) }) } },
{ provide: ToastService, useValue: { showToast: () => {} } },
{
provide: UserNamePipe,
useValue: {
transform: (user: { name?: string; email?: string }) => user.name || user.email,
},
},
// Billing Services
{ provide: BillingApiServiceAbstraction, useValue: {} },
{
provide: BillingAccountProfileStateService,
useValue: { hasPremiumFromAnySource$: () => of(false) },
},
{
provide: OrganizationMetadataServiceAbstraction,
useValue: { getOrganizationMetadata$: () => of({ isOnSecretsManagerStandalone: false }) },
},
// Encryption and Key Services
{ provide: EncryptService, useValue: {} },
{ provide: KeyService, useValue: {} },
// Admin Console Services
{ provide: CollectionAdminService, useValue: {} },
{ provide: OrganizationUserApiService, useValue: {} },
{ provide: OrganizationService, useValue: { organizations$: () => of([]) } },
{ provide: PolicyService, useValue: { policyAppliesToUser$: () => of(false) } },
{ provide: ProviderService, useValue: { providers$: () => of([]) } },
// Vault Services
{ provide: CipherService, useValue: {} },
{
provide: VaultTimeoutSettingsService,
useValue: {
availableVaultTimeoutActions$: () =>
new BehaviorSubject([VaultTimeoutAction.Lock]).asObservable(),
},
},
// State and Account Services
{
provide: StateService,
useValue: {
activeAccount$: of("account-123"),
accounts$: of({ "account-123": { profile: { name: "Test User" } } }),
},
},
{
provide: AccountService,
useValue: {
activeAccount$: of({
id: "account-123",
name: "Test User",
email: "test@example.com",
}),
},
},
{ provide: AvatarService, useValue: { avatarColor$: of("#175ddc") } },
{ provide: SyncService, useValue: { getLastSync: () => Promise.resolve(new Date()) } },
// Router
{
provide: ActivatedRoute,
useValue: {
params: of({ organizationId: mockOrganizationId }),
queryParams: of({}),
data: of({ titleId: "memberAccessReport" }), // Provides title for app-header
fragment: of(null),
url: of([]),
paramMap: of({
get: (key: string): string | null =>
key === "organizationId" ? mockOrganizationId : null,
has: (key: string): boolean => key === "organizationId",
keys: ["organizationId"],
}),
queryParamMap: of({
get: (): string | null => null,
has: (): boolean => false,
keys: [],
}),
},
},
],
}),
],
} as Meta<MemberAccessReportComponent>;
type Story = StoryObj<MemberAccessReportComponent>;
export const Default: Story = {};
export const Loading: Story = {
decorators: [
moduleMetadata({
providers: [
{
provide: MemberAccessReportService,
useValue: {
generateMemberAccessReportViewV2: () =>
new Promise(() => {
/* Never resolves to show loading state */
}),
generateUserReportExportItemsV2: () => Promise.resolve([]),
},
},
],
}),
],
};
export const EmptyState: Story = {
decorators: [
moduleMetadata({
providers: [
{
provide: MemberAccessReportService,
useValue: {
generateMemberAccessReportViewV2: () => Promise.resolve([]),
generateUserReportExportItemsV2: () => Promise.resolve([]),
},
},
],
}),
],
};
export const WithManyMembers: Story = {
decorators: [
moduleMetadata({
providers: [
{
provide: MemberAccessReportService,
useValue: {
generateMemberAccessReportViewV2: () => {
const members = [...Array(50).keys()].map(createMockMember);
return Promise.resolve(members);
},
generateUserReportExportItemsV2: () => Promise.resolve([]),
},
},
],
}),
],
};

View File

@@ -6,6 +6,10 @@ import { FormControl } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } from "rxjs";
import {
CollectionAdminService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -14,11 +18,22 @@ import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billin
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, SearchModule, TableDataSource } from "@bitwarden/components";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
DialogService,
SearchModule,
TableDataSource,
IconModule,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { ExportHelper } from "@bitwarden/vault-export-core";
import { CoreOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/core";
import {
CoreOrganizationModule,
GroupApiService,
} from "@bitwarden/web-vault/app/admin-console/organizations/core";
import {
openUserAddEditDialog,
MemberDialogResult,
@@ -39,12 +54,24 @@ import { MemberAccessReportView } from "./view/member-access-report.view";
@Component({
selector: "member-access-report",
templateUrl: "member-access-report.component.html",
imports: [SharedModule, SearchModule, HeaderModule, CoreOrganizationModule],
imports: [SharedModule, SearchModule, HeaderModule, CoreOrganizationModule, IconModule],
providers: [
safeProvider({
provide: MemberAccessReportServiceAbstraction,
useClass: MemberAccessReportService,
deps: [MemberAccessReportApiService, I18nService, EncryptService, KeyService, AccountService],
deps: [
MemberAccessReportApiService,
I18nService,
EncryptService,
KeyService,
AccountService,
// V2 dependencies
CollectionAdminService,
OrganizationUserApiService,
CipherService,
LogService,
GroupApiService,
],
}),
],
})
@@ -63,6 +90,9 @@ export class MemberAccessReportComponent implements OnInit {
protected userNamePipe: UserNamePipe,
protected billingApiService: BillingApiServiceAbstraction,
protected organizationMetadataService: OrganizationMetadataServiceAbstraction,
private logService: LogService,
private toastService: ToastService,
private i18nService: I18nService,
) {
// Connect the search input to the table dataSource filter input
this.searchControl.valueChanges
@@ -73,33 +103,63 @@ export class MemberAccessReportComponent implements OnInit {
async ngOnInit() {
this.isLoading$.next(true);
const params = await firstValueFrom(this.route.params);
this.organizationId = params.organizationId;
try {
const params = await firstValueFrom(this.route.params);
this.organizationId = params.organizationId;
const billingMetadata = await firstValueFrom(
this.organizationMetadataService.getOrganizationMetadata$(this.organizationId),
);
// Handle billing metadata with fallback
try {
const billingMetadata = await firstValueFrom(
this.organizationMetadataService.getOrganizationMetadata$(this.organizationId),
);
this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone;
} catch (billingError: unknown) {
// Log but don't block - billing metadata is not critical for report
this.logService.warning(
"[MemberAccessReportComponent] Failed to load billing metadata, using defaults",
billingError,
);
this.orgIsOnSecretsManagerStandalone = false;
}
this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone;
await this.load();
this.isLoading$.next(false);
await this.load();
} catch (error: unknown) {
this.logService.error(
"[MemberAccessReportComponent] Failed to load member access report",
error,
);
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("memberAccessReportLoadError"),
});
// Set empty data so table doesn't break
this.dataSource.data = [];
} finally {
this.isLoading$.next(false);
}
}
async load() {
this.dataSource.data = await this.reportService.generateMemberAccessReportView(
this.organizationId,
);
try {
const reportData = await this.reportService.generateMemberAccessReportViewV2(
this.organizationId,
);
this.dataSource.data = reportData;
} catch (error) {
this.logService.error("[MemberAccessReportComponent] Report generation failed", error);
throw error;
}
}
exportReportAction = async (): Promise<void> => {
const exportItems = await this.reportService.generateUserReportExportItemsV2(
this.organizationId,
);
this.fileDownloadService.download({
fileName: ExportHelper.getFileName("member-access"),
blobData: exportToCSV(
await this.reportService.generateUserReportExportItems(this.organizationId),
userReportItemHeaders,
),
blobData: exportToCSV(exportItems, userReportItemHeaders),
blobOptions: { type: "text/plain" },
});
};

View File

@@ -1,13 +1,27 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import {
CollectionAdminService,
OrganizationUserApiService,
OrganizationUserUserDetailsResponse,
} from "@bitwarden/admin-console/common";
import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
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 { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import {
GroupApiService,
GroupView,
} from "@bitwarden/web-vault/app/admin-console/organizations/core";
import { MemberAccessReportApiService } from "./member-access-report-api.service";
import {
@@ -16,13 +30,18 @@ import {
} from "./member-access-report.mock";
import { MemberAccessReportService } from "./member-access-report.service";
describe("ImportService", () => {
describe("MemberAccessReportService", () => {
const mockOrganizationId = "mockOrgId" as OrganizationId;
const reportApiService = mock<MemberAccessReportApiService>();
const mockEncryptService = mock<EncryptService>();
const userId = newGuid() as UserId;
const mockAccountService = mockAccountServiceWith(userId);
const mockKeyService = mock<KeyService>();
const mockCollectionAdminService = mock<CollectionAdminService>();
const mockOrganizationUserApiService = mock<OrganizationUserApiService>();
const mockCipherService = mock<CipherService>();
const mockLogService = mock<LogService>();
const mockGroupApiService = mock<GroupApiService>();
let memberAccessReportService: MemberAccessReportService;
const i18nMock = mock<I18nService>({
t(key) {
@@ -37,15 +56,159 @@ describe("ImportService", () => {
reportApiService.getMemberAccessData.mockImplementation(() =>
Promise.resolve(memberAccessReportsMock),
);
// Default: mock groups as empty array (tests can override)
mockGroupApiService.getAll.mockResolvedValue([]);
memberAccessReportService = new MemberAccessReportService(
reportApiService,
i18nMock,
mockEncryptService,
mockKeyService,
mockAccountService,
mockCollectionAdminService,
mockOrganizationUserApiService,
mockCipherService,
mockLogService,
mockGroupApiService,
);
});
// Helper functions to create properly typed test data
const createMockCollection = (
id: string,
name: string,
users: Array<{ id: string; readOnly: boolean; hidePasswords: boolean; manage: boolean }> = [],
groups: Array<{ id: string; readOnly: boolean; hidePasswords: boolean; manage: boolean }> = [],
): Partial<CollectionAdminView> =>
({
id,
name,
users,
groups,
}) as Partial<CollectionAdminView>;
const createMockOrganizationUser = (
id: string,
email: string,
name: string | null | undefined,
options: {
twoFactorEnabled?: boolean;
usesKeyConnector?: boolean;
resetPasswordEnrolled?: boolean;
groups?: string[];
avatarColor?: string;
} = {},
): Partial<OrganizationUserUserDetailsResponse> => ({
id,
email,
name: name ?? undefined, // Convert null to undefined to match expected type
twoFactorEnabled: options.twoFactorEnabled ?? false,
usesKeyConnector: options.usesKeyConnector ?? false,
resetPasswordEnrolled: options.resetPasswordEnrolled ?? false,
groups: options.groups ?? [],
avatarColor: options.avatarColor,
});
const createMockCipher = (id: string, collectionIds: string[]): Partial<CipherView> => ({
id,
collectionIds,
});
const createMockGroup = (id: string, name: string): GroupView => {
const group = new GroupView();
group.id = id;
group.organizationId = mockOrganizationId;
group.name = name;
group.externalId = "";
return group;
};
// Scenario helpers to reduce test duplication
const setupSingleUserWithDirectAccess = (
userId: string,
collectionId: string,
cipherIds: string[],
userOptions: {
email?: string;
name?: string;
twoFactorEnabled?: boolean;
usesKeyConnector?: boolean;
resetPasswordEnrolled?: boolean;
} = {},
) => {
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
of([
createMockCollection(collectionId, "Test Collection", [
{ id: userId, readOnly: false, hidePasswords: false, manage: false },
]),
] as CollectionAdminView[]),
);
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
data: [
createMockOrganizationUser(
userId,
userOptions.email ?? "user@test.com",
userOptions.name ?? "User",
{
twoFactorEnabled: userOptions.twoFactorEnabled ?? false,
usesKeyConnector: userOptions.usesKeyConnector ?? false,
resetPasswordEnrolled: userOptions.resetPasswordEnrolled ?? false,
groups: [],
},
),
],
} as ListResponse<OrganizationUserUserDetailsResponse>);
mockCipherService.getAllFromApiForOrganization.mockResolvedValue(
cipherIds.map((id) => createMockCipher(id, [collectionId])) as CipherView[],
);
};
const setupUserWithGroupAccess = (
userId: string,
groupId: string,
collectionId: string,
cipherIds: string[],
userOptions: {
email?: string;
name?: string;
groupName?: string;
} = {},
) => {
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
of([
createMockCollection(
collectionId,
"Group Collection",
[],
[{ id: groupId, readOnly: false, hidePasswords: false, manage: false }],
),
] as CollectionAdminView[]),
);
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
data: [
createMockOrganizationUser(
userId,
userOptions.email ?? "user@test.com",
userOptions.name ?? "User",
{
groups: [groupId],
},
),
],
} as ListResponse<OrganizationUserUserDetailsResponse>);
// Mock group data with actual group name
mockGroupApiService.getAll.mockResolvedValue([
createMockGroup(groupId, userOptions.groupName ?? "Test Group"),
]);
mockCipherService.getAllFromApiForOrganization.mockResolvedValue(
cipherIds.map((id) => createMockCipher(id, [collectionId])) as CipherView[],
);
};
describe("generateMemberAccessReportView", () => {
it("should generate member access report view", async () => {
const result =
@@ -55,6 +218,7 @@ describe("ImportService", () => {
{
name: "Sarah Johnson",
email: "sjohnson@email.com",
avatarColor: "",
collectionsCount: 3,
groupsCount: 1,
itemsCount: 0,
@@ -64,6 +228,7 @@ describe("ImportService", () => {
{
name: "James Lull",
email: "jlull@email.com",
avatarColor: "",
collectionsCount: 2,
groupsCount: 1,
itemsCount: 0,
@@ -73,6 +238,7 @@ describe("ImportService", () => {
{
name: "Beth Williams",
email: "bwilliams@email.com",
avatarColor: "",
collectionsCount: 2,
groupsCount: 1,
itemsCount: 0,
@@ -82,6 +248,7 @@ describe("ImportService", () => {
{
name: "Ray Williams",
email: "rwilliams@email.com",
avatarColor: "",
collectionsCount: 3,
groupsCount: 3,
itemsCount: 0,
@@ -165,4 +332,505 @@ describe("ImportService", () => {
);
});
});
describe("generateMemberAccessReportViewV2", () => {
it("should generate report using frontend mapping with direct user access", async () => {
const userId1 = "user-1";
const userId2 = "user-2";
const collectionId1 = "collection-1";
const cipherId1 = "cipher-1";
// Mock collections with direct user access
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
of([
createMockCollection(
collectionId1,
"Test Collection",
[
{ id: userId1, readOnly: false, hidePasswords: false, manage: false },
{ id: userId2, readOnly: true, hidePasswords: true, manage: false },
],
[],
),
] as CollectionAdminView[]),
);
// Mock organization users
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
data: [
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
twoFactorEnabled: true,
usesKeyConnector: false,
resetPasswordEnrolled: true,
groups: [],
}),
createMockOrganizationUser(userId2, "user2@test.com", "User Two", {
twoFactorEnabled: false,
usesKeyConnector: true,
resetPasswordEnrolled: false,
groups: [],
}),
],
} as ListResponse<OrganizationUserUserDetailsResponse>);
// Mock ciphers
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
createMockCipher(cipherId1, [collectionId1]),
] as CipherView[]);
const result =
await memberAccessReportService.generateMemberAccessReportViewV2(mockOrganizationId);
expect(result).toHaveLength(2);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
email: "user1@test.com",
name: "User One",
collectionsCount: 1,
groupsCount: 0,
itemsCount: 1,
usesKeyConnector: false,
}),
expect.objectContaining({
email: "user2@test.com",
name: "User Two",
collectionsCount: 1,
groupsCount: 0,
itemsCount: 1,
usesKeyConnector: true,
}),
]),
);
});
it("should handle group-based access correctly", async () => {
const userId1 = "user-1";
const groupId1 = "group-1";
const collectionId1 = "collection-1";
const cipherId1 = "cipher-1";
// Mock collections with group access
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
of([
createMockCollection(
collectionId1,
"Group Collection",
[],
[{ id: groupId1, readOnly: false, hidePasswords: false, manage: false }],
),
] as CollectionAdminView[]),
);
// Mock organization users with group membership
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
data: [
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
twoFactorEnabled: true,
usesKeyConnector: false,
resetPasswordEnrolled: true,
groups: [groupId1],
}),
],
} as ListResponse<OrganizationUserUserDetailsResponse>);
// Mock groups with actual group name
mockGroupApiService.getAll.mockResolvedValue([createMockGroup(groupId1, "Test Group")]);
// Mock ciphers
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
createMockCipher(cipherId1, [collectionId1]),
] as CipherView[]);
const result =
await memberAccessReportService.generateMemberAccessReportViewV2(mockOrganizationId);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
email: "user1@test.com",
name: "User One",
collectionsCount: 1,
groupsCount: 1,
itemsCount: 1,
});
});
it("should aggregate multiple ciphers and collections correctly", async () => {
const userId1 = "user-1";
const collectionId1 = "collection-1";
const collectionId2 = "collection-2";
const cipherId1 = "cipher-1";
const cipherId2 = "cipher-2";
const cipherId3 = "cipher-3";
// Mock collections
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
of([
createMockCollection(collectionId1, "Collection 1", [
{ id: userId1, readOnly: false, hidePasswords: false, manage: false },
]),
createMockCollection(collectionId2, "Collection 2", [
{ id: userId1, readOnly: false, hidePasswords: false, manage: false },
]),
] as CollectionAdminView[]),
);
// Mock organization users
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
data: [
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
twoFactorEnabled: true,
usesKeyConnector: false,
resetPasswordEnrolled: true,
groups: [],
}),
],
} as ListResponse<OrganizationUserUserDetailsResponse>);
// Mock ciphers - user has access via 2 collections
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
createMockCipher(cipherId1, [collectionId1]),
createMockCipher(cipherId2, [collectionId1, collectionId2]),
createMockCipher(cipherId3, [collectionId2]),
] as CipherView[]);
const result =
await memberAccessReportService.generateMemberAccessReportViewV2(mockOrganizationId);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
email: "user1@test.com",
collectionsCount: 2, // Distinct collections
groupsCount: 0,
itemsCount: 3, // Distinct ciphers
});
});
it("should handle users with no access correctly", async () => {
const userId1 = "user-1";
const collectionId1 = "collection-1";
// Mock collection with no user assignments
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
of([createMockCollection(collectionId1, "Empty Collection")] as CollectionAdminView[]),
);
// Mock organization users (user exists but has no access)
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
data: [
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
twoFactorEnabled: true,
usesKeyConnector: false,
resetPasswordEnrolled: true,
groups: [],
}),
],
} as ListResponse<OrganizationUserUserDetailsResponse>);
// Mock ciphers
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
createMockCipher("cipher-1", [collectionId1]),
] as CipherView[]);
const result =
await memberAccessReportService.generateMemberAccessReportViewV2(mockOrganizationId);
// User has no access, so shouldn't appear in report
expect(result).toHaveLength(0);
});
it("should use email as name fallback when name is not available", async () => {
const userId1 = "user-1";
const collectionId1 = "collection-1";
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
of([
createMockCollection(collectionId1, "Test Collection", [
{ id: userId1, readOnly: false, hidePasswords: false, manage: false },
]),
] as CollectionAdminView[]),
);
// User without name
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
data: [
createMockOrganizationUser(userId1, "user1@test.com", null, {
twoFactorEnabled: false,
usesKeyConnector: false,
resetPasswordEnrolled: false,
groups: [],
}),
],
} as ListResponse<OrganizationUserUserDetailsResponse>);
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
createMockCipher("cipher-1", [collectionId1]),
] as CipherView[]);
const result =
await memberAccessReportService.generateMemberAccessReportViewV2(mockOrganizationId);
expect(result[0].name).toBe("user1@test.com");
});
});
describe("generateUserReportExportItemsV2", () => {
it("should generate export items with all metadata fields", async () => {
setupSingleUserWithDirectAccess("user-1", "collection-1", ["cipher-1"], {
email: "user1@test.com",
name: "User One",
twoFactorEnabled: true,
resetPasswordEnrolled: true,
});
const result =
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
email: "user1@test.com",
name: "User One",
twoStepLogin: "memberAccessReportTwoFactorEnabledTrue",
accountRecovery: "memberAccessReportAuthenticationEnabledTrue",
collection: "Test Collection",
totalItems: "1",
});
});
it("should include group information in export when access is via group", async () => {
setupUserWithGroupAccess("user-1", "group-1", "collection-1", ["cipher-1"], {
email: "user1@test.com",
name: "User One",
groupName: "Engineering Team",
});
const result =
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
expect(result).toHaveLength(1);
// Group name should be populated from API
expect(result[0].group).toBe("Engineering Team");
});
it("should group multiple ciphers and count totalItems correctly", async () => {
setupSingleUserWithDirectAccess(
"user-1",
"collection-1",
["cipher-1", "cipher-2", "cipher-3"],
{
email: "user1@test.com",
name: "User One",
},
);
const result =
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
// Should produce 1 row (not 3) with totalItems = 3
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
email: "user1@test.com",
name: "User One",
collection: "Test Collection",
totalItems: "3", // Grouped count
});
});
it("should create separate rows for different access paths (group vs direct)", async () => {
const userId1 = "user-1";
const groupId1 = "group-1";
const collectionId1 = "collection-1";
const cipherId1 = "cipher-1";
const cipherId2 = "cipher-2";
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
of([
createMockCollection(
collectionId1,
"Mixed Access Collection",
[{ id: userId1, readOnly: false, hidePasswords: false, manage: false }],
[{ id: groupId1, readOnly: false, hidePasswords: false, manage: false }],
),
] as CollectionAdminView[]),
);
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
data: [
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
twoFactorEnabled: true,
usesKeyConnector: false,
resetPasswordEnrolled: true,
groups: [groupId1], // User has both direct AND group access
}),
],
} as ListResponse<OrganizationUserUserDetailsResponse>);
// Mock groups
mockGroupApiService.getAll.mockResolvedValue([
createMockGroup(groupId1, "Mixed Access Group"),
]);
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
createMockCipher(cipherId1, [collectionId1]),
createMockCipher(cipherId2, [collectionId1]),
] as CipherView[]);
const result =
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
// Should produce 2 rows: one for direct access, one for group access
// Each with 2 ciphers
expect(result).toHaveLength(2);
expect(result.every((item) => item.totalItems === "2")).toBe(true);
});
it("should handle edge cases with empty/missing data", async () => {
const userId1 = "user-1";
const collectionId1 = "collection-1";
const cipherId1 = "cipher-1";
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
of([
createMockCollection(
collectionId1,
"", // Empty collection name
[{ id: userId1, readOnly: false, hidePasswords: false, manage: false }],
),
] as CollectionAdminView[]),
);
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
data: [
createMockOrganizationUser(
userId1,
"user1@test.com",
"", // Empty name
{
twoFactorEnabled: false,
usesKeyConnector: false,
resetPasswordEnrolled: false,
groups: [],
},
),
],
} as ListResponse<OrganizationUserUserDetailsResponse>);
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
createMockCipher(cipherId1, [collectionId1]),
] as CipherView[]);
const result =
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
name: "user1@test.com", // Falls back to email
collection: "memberAccessReportNoCollection", // Falls back to translation key
group: "memberAccessReportNoGroup", // Falls back to translation key
});
});
it("should populate group names from GroupApiService", async () => {
const userId1 = "user-1";
const groupId1 = "group-1";
const groupId2 = "group-2";
const collectionId1 = "collection-1";
const collectionId2 = "collection-2";
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
of([
createMockCollection(
collectionId1,
"Engineering Collection",
[],
[{ id: groupId1, readOnly: false, hidePasswords: false, manage: false }],
),
createMockCollection(
collectionId2,
"Marketing Collection",
[],
[{ id: groupId2, readOnly: false, hidePasswords: false, manage: false }],
),
] as CollectionAdminView[]),
);
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
data: [
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
twoFactorEnabled: true,
usesKeyConnector: false,
resetPasswordEnrolled: true,
groups: [groupId1, groupId2],
}),
],
} as ListResponse<OrganizationUserUserDetailsResponse>);
// Mock groups with actual names
mockGroupApiService.getAll.mockResolvedValue([
createMockGroup(groupId1, "Engineering Team"),
createMockGroup(groupId2, "Marketing Team"),
]);
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
createMockCipher("cipher-1", [collectionId1]),
createMockCipher("cipher-2", [collectionId2]),
] as CipherView[]);
const result =
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
// Should have 2 rows, one per group
expect(result).toHaveLength(2);
// Verify group names are populated correctly
const groups = result.map((item) => item.group).sort();
expect(groups).toEqual(["Engineering Team", "Marketing Team"]);
// Verify collections match groups
expect(result.find((item) => item.group === "Engineering Team")?.collection).toBe(
"Engineering Collection",
);
expect(result.find((item) => item.group === "Marketing Team")?.collection).toBe(
"Marketing Collection",
);
});
it("should skip ciphers with zero GUID", async () => {
const userId1 = "user-1";
const collectionId1 = "collection-1";
const validCipherId = "cipher-1";
const zeroGuid = "00000000-0000-0000-0000-000000000000";
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
of([
createMockCollection(collectionId1, "Test Collection", [
{ id: userId1, readOnly: false, hidePasswords: false, manage: false },
]),
] as CollectionAdminView[]),
);
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
data: [
createMockOrganizationUser(userId1, "user1@test.com", "User One", {
twoFactorEnabled: false,
usesKeyConnector: false,
resetPasswordEnrolled: false,
groups: [],
}),
],
} as ListResponse<OrganizationUserUserDetailsResponse>);
// Mock ciphers including one with zero GUID
mockCipherService.getAllFromApiForOrganization.mockResolvedValue([
createMockCipher(validCipherId, [collectionId1]),
createMockCipher(zeroGuid, [collectionId1]), // Should be filtered out
] as CipherView[]);
const result =
await memberAccessReportService.generateUserReportExportItemsV2(mockOrganizationId);
expect(result).toHaveLength(1);
expect(result[0].totalItems).toBe("1"); // Only counts valid cipher
});
});
});

View File

@@ -1,16 +1,27 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom, map, take } from "rxjs";
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
import {
CollectionAdminService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
import {
CollectionAccessSelectionView,
CollectionAdminView,
} from "@bitwarden/common/admin-console/models/collections";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Guid, OrganizationId } from "@bitwarden/common/types/guid";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Guid, 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 { KeyService } from "@bitwarden/key-management";
import { GroupApiService } from "@bitwarden/web-vault/app/admin-console/organizations/core";
import {
getPermissionList,
convertToPermission,
@@ -22,6 +33,38 @@ import { MemberAccessReportView } from "../view/member-access-report.view";
import { MemberAccessReportApiService } from "./member-access-report-api.service";
/**
* V2 data structures for frontend member-to-cipher mapping
*/
interface MemberAccessDataV2 {
collectionMap: Map<string, CollectionAdminView>;
organizationUserDataMap: Map<string, OrganizationUserData>;
groupMemberMap: Map<string, { groupName: string; memberIds: string[] }>;
}
interface OrganizationUserData {
userId: string;
name: string;
email: string;
avatarColor: string;
twoFactorEnabled: boolean;
usesKeyConnector: boolean;
resetPasswordEnrolled: boolean;
}
interface MemberCipherAccess {
userId: string;
cipherId: string;
collectionId: string;
collectionName: string;
groupId?: string;
groupName?: string;
accessType: "direct" | "group";
readOnly: boolean;
hidePasswords: boolean;
manage: boolean;
}
@Injectable({ providedIn: "root" })
export class MemberAccessReportService {
constructor(
@@ -30,10 +73,19 @@ export class MemberAccessReportService {
private encryptService: EncryptService,
private keyService: KeyService,
private accountService: AccountService,
// V2 dependencies for frontend member-to-cipher mapping
private collectionAdminService: CollectionAdminService,
private organizationUserApiService: OrganizationUserApiService,
private cipherService: CipherService,
private logService: LogService,
private groupApiService: GroupApiService,
) {}
/**
* Transforms user data into a MemberAccessReportView.
*
* @deprecated Times out for large orgs
* Use generateMemberAccessReportViewV2 instead. Will be removed after V2 rollout is complete.
*
* @param {UserData} userData - The user data to aggregate.
* @param {ReportCollection[]} collections - An array of collections, each with an ID and a total number of items.
* @returns {MemberAccessReportView} The aggregated report view.
@@ -71,6 +123,7 @@ export class MemberAccessReportService {
userGuid: userGuid,
name: userDataArray[0].userName,
email: userDataArray[0].email,
avatarColor: "", // V1 API doesn't provide avatarColor
collectionsCount: collectionCount,
groupsCount: groupCount,
itemsCount: itemsCount,
@@ -83,6 +136,10 @@ export class MemberAccessReportService {
return memberAccessReportViewCollection;
}
/**
* @deprecated V1 implementation - causes timeout for large orgs (5K+ members).
* Use generateUserReportExportItemsV2 instead. Will be removed after V2 rollout is complete.
*/
async generateUserReportExportItems(
organizationId: OrganizationId,
): Promise<MemberAccessExportItem[]> {
@@ -124,7 +181,7 @@ export class MemberAccessReportService {
? collectionName
: this.i18nService.t("memberAccessReportNoCollection"),
collectionPermission: report.collectionId
? this.getPermissionText(report)
? this.getPermissionTextFromAccess(report)
: this.i18nService.t("memberAccessReportNoCollectionPermission"),
totalItems: report.cipherIds
.filter((_) => _ != "00000000-0000-0000-0000-000000000000")
@@ -134,21 +191,410 @@ export class MemberAccessReportService {
return exportItems.flat();
}
private getPermissionText(accessDetails: MemberAccessResponse): string {
/**
* Shared logic for getting permission text from access details
* @deprecated Use getPermissionTextCached with pre-built lookup map for better performance
* @private
*/
private getPermissionTextFromAccess(access: {
groupId?: string;
collectionId: string;
readOnly: boolean;
hidePasswords: boolean;
manage: boolean;
}): string {
const permissionList = getPermissionList();
const collectionSelectionView = new CollectionAccessSelectionView({
id: accessDetails.groupId ?? accessDetails.collectionId,
readOnly: accessDetails.readOnly,
hidePasswords: accessDetails.hidePasswords,
manage: accessDetails.manage,
id: access.groupId ?? access.collectionId,
readOnly: access.readOnly,
hidePasswords: access.hidePasswords,
manage: access.manage,
});
return this.i18nService.t(
permissionList.find((p) => p.perm === convertToPermission(collectionSelectionView))?.labelId,
);
}
/**
* Get permission text using cached lookup map (performance optimized)
* @param access - Access details
* @param permissionLookup - Pre-built map of permission to label ID
* @private
*/
private getPermissionTextCached(
access: {
groupId?: string;
collectionId: string;
readOnly: boolean;
hidePasswords: boolean;
manage: boolean;
},
permissionLookup: Map<string, string>,
): string {
const collectionSelectionView = new CollectionAccessSelectionView({
id: access.groupId ?? access.collectionId,
readOnly: access.readOnly,
hidePasswords: access.hidePasswords,
manage: access.manage,
});
const perm = convertToPermission(collectionSelectionView);
const labelId = permissionLookup.get(perm);
return this.i18nService.t(labelId ?? "");
}
private getDistinctCount<T>(items: T[]): number {
const uniqueItems = new Set(items);
return uniqueItems.size;
}
// ==================== V2 METHODS - Frontend Member Mapping ====================
// These methods implement the Access Intelligence V2 pattern to avoid backend timeout issues.
// V2 performs member-to-cipher mapping on the frontend using collection relationships,
// eliminating the need for the problematic backend member-access endpoint for large orgs.
/**
* Loads organization data (collections, users, groups) for V2 member mapping
* @param organizationId - The organization ID
* @param currentUserId - The current user's ID
* @returns Promise containing collection map, user metadata map, and group member map
*/
private async _loadOrganizationDataV2(
organizationId: OrganizationId,
currentUserId: UserId,
): Promise<MemberAccessDataV2> {
this.logService.debug("[MemberAccessReportService V2] Loading organization data");
// Fetch collections, users, and groups in parallel
const [collections, orgUsersResponse, groups] = await Promise.all([
firstValueFrom(
this.collectionAdminService
.collectionAdminViews$(organizationId, currentUserId)
.pipe(take(1)),
),
this.organizationUserApiService.getAllUsers(organizationId, { includeGroups: true }),
this.groupApiService.getAll(organizationId),
]);
// Build collection map
const collectionMap = new Map<string, CollectionAdminView>();
collections.forEach((c) => collectionMap.set(c.id, c));
// Build group name lookup map
const groupNameMap = new Map<string, string>();
groups.forEach((g) => groupNameMap.set(g.id, g.name));
// Build user metadata and group member maps
const organizationUserDataMap = new Map<string, OrganizationUserData>();
const groupMemberMap = new Map<string, { groupName: string; memberIds: string[] }>();
for (const orgUser of orgUsersResponse.data) {
// Build user metadata map
if (orgUser.id) {
organizationUserDataMap.set(orgUser.id, {
userId: orgUser.id,
name: orgUser.name || orgUser.email,
email: orgUser.email,
avatarColor: orgUser.avatarColor,
twoFactorEnabled: orgUser.twoFactorEnabled || false,
usesKeyConnector: orgUser.usesKeyConnector || false,
resetPasswordEnrolled: orgUser.resetPasswordEnrolled || false,
});
}
// Build group member map
if (orgUser.groups && orgUser.groups.length > 0) {
for (const groupId of orgUser.groups) {
let groupData = groupMemberMap.get(groupId);
if (!groupData) {
groupData = {
groupName: groupNameMap.get(groupId) || "",
memberIds: [],
};
groupMemberMap.set(groupId, groupData);
}
groupData.memberIds.push(orgUser.id);
}
}
}
this.logService.debug(
`[MemberAccessReportService V2] Loaded ${collections.length} collections, ${organizationUserDataMap.size} users, ${groupMemberMap.size} groups`,
);
return { collectionMap, organizationUserDataMap, groupMemberMap };
}
/**
* Maps ciphers to members using frontend collection mapping (V2)
*
* Groups by (user, collection, group) access path and tracks cipher IDs in Sets
* to avoid creating redundant objects for large organizations.
*
* @param ciphers - Array of cipher views
* @param orgData - Organization data containing collections, users, and groups
* @returns Map of access paths with cipher ID sets
*/
private _mapCiphersToMembersV2(
ciphers: CipherView[],
orgData: MemberAccessDataV2,
): Map<string, { access: MemberCipherAccess; cipherIds: Set<string> }> {
const accessMap = new Map<string, { access: MemberCipherAccess; cipherIds: Set<string> }>();
for (const cipher of ciphers) {
// Skip ciphers without collections or with placeholder/invalid IDs (matches V1 behavior)
if (
!cipher.collectionIds ||
cipher.collectionIds.length === 0 ||
!cipher.id ||
cipher.id === "00000000-0000-0000-0000-000000000000"
) {
continue;
}
for (const collectionId of cipher.collectionIds) {
const collection = orgData.collectionMap.get(collectionId);
if (!collection) {
continue;
}
// Process direct user access
for (const userAccess of collection.users) {
const key = `${userAccess.id}|${collection.id}|direct`;
let entry = accessMap.get(key);
if (!entry) {
// First cipher for this access path - create new entry
entry = {
access: {
userId: userAccess.id,
cipherId: cipher.id, // Representative cipher (for backward compatibility)
collectionId: collection.id,
collectionName: collection.name,
accessType: "direct",
readOnly: userAccess.readOnly,
hidePasswords: userAccess.hidePasswords,
manage: userAccess.manage,
},
cipherIds: new Set([cipher.id]),
};
accessMap.set(key, entry);
} else {
// Add cipher to existing access path
entry.cipherIds.add(cipher.id);
}
}
// Process group access
for (const groupAccess of collection.groups) {
const groupData = orgData.groupMemberMap.get(groupAccess.id);
if (!groupData) {
continue;
}
for (const userId of groupData.memberIds) {
const key = `${userId}|${collection.id}|${groupAccess.id}`;
let entry = accessMap.get(key);
if (!entry) {
// First cipher for this access path - create new entry
entry = {
access: {
userId,
cipherId: cipher.id, // Representative cipher (for backward compatibility)
collectionId: collection.id,
collectionName: collection.name,
groupId: groupAccess.id,
groupName: groupData.groupName,
accessType: "group",
readOnly: groupAccess.readOnly,
hidePasswords: groupAccess.hidePasswords,
manage: groupAccess.manage,
},
cipherIds: new Set([cipher.id]),
};
accessMap.set(key, entry);
} else {
// Add cipher to existing access path
entry.cipherIds.add(cipher.id);
}
}
}
}
}
this.logService.debug(
`[MemberAccessReportService V2] Mapped ${ciphers.length} ciphers to ${accessMap.size} access paths`,
);
return accessMap;
}
/**
* Fetch ciphers with 5-minute timeout protection
* @private
*/
private async _fetchCiphersWithTimeout(organizationId: OrganizationId): Promise<CipherView[]> {
const TIMEOUT_MS = 300000; // 5 minutes
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new Error(
"Cipher fetch timed out after 5 minutes. Organization may be too large for this report. Please contact support.",
),
);
}, TIMEOUT_MS);
});
const fetchPromise = this.cipherService.getAllFromApiForOrganization(organizationId);
return Promise.race([fetchPromise, timeoutPromise]).finally(() => {
clearTimeout(timeoutId);
});
}
/**
* Generate member access report using V2 frontend mapping
*
* @param organizationId - The organization ID
* @returns Promise of MemberAccessReportView array
*/
async generateMemberAccessReportViewV2(
organizationId: OrganizationId,
): Promise<MemberAccessReportView[]> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.logService.debug("[MemberAccessReportService V2] Starting report generation");
// Load organization data
const orgData = await this._loadOrganizationDataV2(organizationId, userId);
// Log organization complexity
this.logService.info(
`[MemberAccessReport V2] Organization size: ${orgData.organizationUserDataMap.size} users, ${orgData.collectionMap.size} collections`,
);
// Get all org ciphers with timeout protection
const ciphers = await this._fetchCiphersWithTimeout(organizationId);
this.logService.info(`[MemberAccessReport V2] Fetched ${ciphers.length} ciphers`);
// Map ciphers to members
const accessMap = this._mapCiphersToMembersV2(ciphers, orgData);
// Aggregate by user
const userAccessMap = new Map<
string,
{
collections: Set<string>;
groups: Set<string>;
items: Set<string>;
}
>();
for (const { access, cipherIds } of accessMap.values()) {
let userData = userAccessMap.get(access.userId);
if (!userData) {
userData = {
collections: new Set(),
groups: new Set(),
items: new Set(),
};
userAccessMap.set(access.userId, userData);
}
userData.collections.add(access.collectionId);
if (access.groupId) {
userData.groups.add(access.groupId);
}
// Add all ciphers from this access path
for (const cipherId of cipherIds) {
userData.items.add(cipherId);
}
}
// Build report views
const reportViews: MemberAccessReportView[] = [];
for (const [userId, data] of userAccessMap.entries()) {
const metadata = orgData.organizationUserDataMap.get(userId);
if (!metadata) {
continue;
}
reportViews.push({
userGuid: userId as Guid,
name: metadata.name,
email: metadata.email,
avatarColor: metadata.avatarColor,
collectionsCount: data.collections.size,
groupsCount: data.groups.size,
itemsCount: data.items.size,
usesKeyConnector: metadata.usesKeyConnector,
});
}
this.logService.debug(
`[MemberAccessReportService V2] Generated report for ${reportViews.length} users`,
);
return reportViews;
}
/**
* Generate export items using V2 frontend mapping
*
* @param organizationId The organization ID
* @returns Promise of MemberAccessExportItem array
*/
async generateUserReportExportItemsV2(
organizationId: OrganizationId,
): Promise<MemberAccessExportItem[]> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const orgData = await this._loadOrganizationDataV2(organizationId, userId);
const ciphers = await this._fetchCiphersWithTimeout(organizationId);
const accessMap = this._mapCiphersToMembersV2(ciphers, orgData);
// Pre-fetch i18n strings to avoid repeated lookups
const twoFactorEnabledTrue = this.i18nService.t("memberAccessReportTwoFactorEnabledTrue");
const twoFactorEnabledFalse = this.i18nService.t("memberAccessReportTwoFactorEnabledFalse");
const accountRecoveryEnabledTrue = this.i18nService.t(
"memberAccessReportAuthenticationEnabledTrue",
);
const accountRecoveryEnabledFalse = this.i18nService.t(
"memberAccessReportAuthenticationEnabledFalse",
);
const noGroup = this.i18nService.t("memberAccessReportNoGroup");
const noCollection = this.i18nService.t("memberAccessReportNoCollection");
const noCollectionPermission = this.i18nService.t("memberAccessReportNoCollectionPermission");
// Build permission lookup map once instead of calling getPermissionList() for each item
const permissionList = getPermissionList();
const permissionLookup = new Map<string, string>();
permissionList.forEach((p) => {
permissionLookup.set(p.perm, p.labelId);
});
const exportItems: MemberAccessExportItem[] = [];
for (const { access, cipherIds } of accessMap.values()) {
const metadata = orgData.organizationUserDataMap.get(access.userId);
exportItems.push({
email: metadata?.email ?? "",
name: metadata?.name ?? "",
twoStepLogin: metadata?.twoFactorEnabled ? twoFactorEnabledTrue : twoFactorEnabledFalse,
accountRecovery: metadata?.resetPasswordEnrolled
? accountRecoveryEnabledTrue
: accountRecoveryEnabledFalse,
group: access.groupName || noGroup,
collection: access.collectionName || noCollection,
collectionPermission: access.collectionId
? this.getPermissionTextCached(access, permissionLookup)
: noCollectionPermission,
totalItems: cipherIds.size.toString(),
});
}
return exportItems;
}
}

View File

@@ -3,6 +3,7 @@ import { Guid } from "@bitwarden/common/types/guid";
export type MemberAccessReportView = {
name: string;
email: string;
avatarColor: string;
collectionsCount: number;
groupsCount: number;
itemsCount: number;