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

Members access testing

This commit is contained in:
Tom
2026-01-28 11:21:09 -05:00
parent ab75ea8659
commit ce6d03e23d
14 changed files with 1821 additions and 7 deletions

View File

@@ -83,6 +83,13 @@ export class ReportsHomeComponent implements OnInit {
? ReportVariant.Enabled
: ReportVariant.RequiresEnterprise,
},
{
...reports[ReportType.MemberAccessReportPrototype],
variant:
productType == ProductTierType.Enterprise
? ReportVariant.Enabled
: ReportVariant.RequiresEnterprise,
},
{
...reports[ReportType.RiskInsightsPrototype],
variant: reportRequiresUpgrade,

View File

@@ -20,6 +20,7 @@ export enum ReportType {
Inactive2fa = "inactive2fa",
DataBreach = "dataBreach",
MemberAccessReport = "memberAccessReport",
MemberAccessReportPrototype = "memberAccessReportPrototype",
RiskInsightsPrototype = "riskInsightsPrototype",
}
@@ -68,6 +69,12 @@ export const reports: Record<ReportType, ReportWithoutVariant> = {
route: "member-access-report",
icon: UserLockIcon,
},
[ReportType.MemberAccessReportPrototype]: {
title: "memberAccessReportPrototype",
description: "memberAccessReportPrototypeDesc",
route: "member-access-report-prototype",
icon: UserLockIcon,
},
[ReportType.RiskInsightsPrototype]: {
title: "riskInsightsPrototype",
description: "riskInsightsPrototypeDesc",

View File

@@ -10715,6 +10715,12 @@
"memberAccessReportDesc": {
"message": "Ensure members have access to the right credentials and their accounts are secure. Use this report to obtain a CSV of member access and account configurations."
},
"memberAccessReportPrototype": {
"message": "Member access (Prototype)"
},
"memberAccessReportPrototypeDesc": {
"message": "Prototype: Faster member access report using client-side data assembly instead of the server-side API."
},
"memberAccessReportPageDesc": {
"message": "Audit organization member access across groups, collections, and collection items. The CSV export provides a detailed breakdown per member, including information on collection permissions and account configurations."
},

View File

@@ -1,4 +1,5 @@
export * from "./api-models.types";
export * from "./member-access-report.types";
export * from "./password-health";
export * from "./report-data-service.types";
export * from "./report-encryption.types";

View File

@@ -0,0 +1,124 @@
/**
* Effective permission level for a member's access to ciphers (const object pattern per ADR-0025)
* Priority order: Manage > Edit > ViewOnly > HidePasswords
*/
export const EffectivePermissionLevel = Object.freeze({
Manage: "manage",
Edit: "edit",
ViewOnly: "view-only",
HidePasswords: "hide-passwords",
} as const);
export type EffectivePermissionLevel =
(typeof EffectivePermissionLevel)[keyof typeof EffectivePermissionLevel];
/**
* Loading states for the member access report (const object pattern per ADR-0025)
*/
export const MemberAccessReportState = Object.freeze({
Idle: "idle",
LoadingCiphers: "loading-ciphers",
ProcessingMembers: "processing-members",
Complete: "complete",
Error: "error",
} as const);
export type MemberAccessReportState =
(typeof MemberAccessReportState)[keyof typeof MemberAccessReportState];
/**
* Summary view model for a single member's access across all ciphers.
* This is the member-centric view pivoted from cipher-centric data.
*/
export interface MemberAccessSummary {
/** The user's organization member ID */
userId: string;
/** The user's email address */
email: string;
/** The user's display name (may be null if not set) */
name: string | null;
/** Total number of ciphers this member has access to */
cipherCount: number;
/** Number of unique collections this member has access to */
collectionCount: number;
/** Number of unique groups this member belongs to that grant cipher access */
groupCount: number;
/** The highest permission level across all access paths */
highestPermission: EffectivePermissionLevel;
}
/**
* Progressive result emitted during streaming member access report generation.
* Enables incremental UI updates as batches of cipher data are processed.
*/
export interface MemberAccessReportProgressiveResult {
/** Current state of report generation */
state: MemberAccessReportState;
/** Member summaries computed so far (grows with each batch) */
members: MemberAccessSummary[];
/** Number of ciphers processed so far */
processedCipherCount: number;
/** Total number of ciphers to process */
totalCipherCount: number;
/** Percentage complete (0-100) */
progressPercent: number;
/** Error message if state is Error */
error?: string;
}
/**
* Detail of a member's access to a specific collection via a specific access path.
* Groups ciphers by collection+accessType+groupId for detailed breakdown.
*/
export interface MemberCollectionAccessDetail {
/** The collection ID */
collectionId: string;
/** The collection display name */
collectionName: string;
/** Number of ciphers accessible via this path */
cipherCount: number;
/** Permission level for this access path */
permission: EffectivePermissionLevel;
/** How access was granted: "direct" or "group" */
accessType: "direct" | "group";
/** Group name if accessType is "group", null otherwise */
groupName: string | null;
/** Group ID if accessType is "group", null otherwise */
groupId: string | null;
}
/**
* Detailed view of a single member's access across all collections.
* Used for displaying drill-down information in the detail dialog.
*/
export interface MemberAccessDetailView {
/** The user's organization member ID */
userId: string;
/** The user's email address */
email: string;
/** The user's display name (may be null if not set) */
name: string | null;
/** Total number of unique ciphers this member can access */
totalCipherCount: number;
/** Breakdown of access by collection and access type */
collectionDetails: MemberCollectionAccessDetail[];
}

View File

@@ -16,6 +16,7 @@ import {
CollectionAdminService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -176,6 +177,7 @@ export class CipherAccessMappingService {
private readonly cipherService = inject(CipherService);
private readonly collectionAdminService = inject(CollectionAdminService);
private readonly organizationUserApiService = inject(OrganizationUserApiService);
private readonly apiService = inject(ApiService);
private readonly logService = inject(LogService);
/** Cache for organization users to avoid duplicate API calls */
@@ -738,7 +740,7 @@ export class CipherAccessMappingService {
let groupData = groupMemberMap.get(groupId);
if (!groupData) {
// Initialize group data (name will be updated if we have it)
groupData = { groupName: "Unknown Group", memberIds: [] };
groupData = { groupName: "", memberIds: [] };
groupMemberMap.set(groupId, groupData);
}
// Use orgUser.id (organization user ID) to match collection assignments and email map
@@ -804,13 +806,22 @@ export class CipherAccessMappingService {
}
this.logService.info(
`[CipherAccessMappingService] Fetching organization users (single API call) for ${organizationId}`,
`[CipherAccessMappingService] Fetching organization users and groups for ${organizationId}`,
);
// Single API call with includeGroups: true - gets all user data needed
const orgUsersResponse = await this.organizationUserApiService.getAllUsers(organizationId, {
includeGroups: true,
});
// Fetch users and groups in parallel
const [orgUsersResponse, groupsResponse] = await Promise.all([
this.organizationUserApiService.getAllUsers(organizationId, {
includeGroups: true,
}),
this.fetchGroupNames(organizationId),
]);
// Build group name lookup map
const groupNameMap = new Map<string, string>();
for (const group of groupsResponse) {
groupNameMap.set(group.id, group.name);
}
// Build both maps from the same response
const groupMemberMap = new Map<string, { groupName: string; memberIds: string[] }>();
@@ -827,7 +838,9 @@ export class CipherAccessMappingService {
for (const groupId of orgUser.groups) {
let groupData = groupMemberMap.get(groupId);
if (!groupData) {
groupData = { groupName: "Unknown Group", memberIds: [] };
// Look up the actual group name
const groupName = groupNameMap.get(groupId) ?? "";
groupData = { groupName, memberIds: [] };
groupMemberMap.set(groupId, groupData);
}
groupData.memberIds.push(orgUser.id);
@@ -988,4 +1001,40 @@ export class CipherAccessMappingService {
memberAccess.effectivePermissions.canManage = true;
}
}
/**
* Fetches all group names for an organization.
*
* @param organizationId - The organization ID
* @returns Array of groups with id and name
*/
private async fetchGroupNames(
organizationId: OrganizationId,
): Promise<{ id: string; name: string }[]> {
try {
const response = await this.apiService.send(
"GET",
`/organizations/${organizationId}/groups`,
null,
true,
true,
);
// The response is a ListResponse with data array containing group objects
if (response?.data && Array.isArray(response.data)) {
return response.data.map((g: { id: string; name: string }) => ({
id: g.id,
name: g.name,
}));
}
return [];
} catch (error) {
this.logService.warning(
`[CipherAccessMappingService] Failed to fetch group names for org ${organizationId}`,
error,
);
return [];
}
}
}

View File

@@ -0,0 +1,529 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.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 {
EffectivePermissionLevel,
MemberAccessReportState,
MemberAccessSummary,
} from "../../models/member-access-report.types";
import {
CipherAccessMappingService,
CipherMemberAccess,
CipherWithMemberAccess,
MemberAccessLoadState,
CipherAccessMappingProgressiveResult,
} from "./cipher-access-mapping.service";
import { MemberAccessReportService } from "./member-access-report.service";
const TestOrgId = "test-org-id" as OrganizationId;
const TestUserId = "test-user-id" as UserId;
describe("MemberAccessReportService", () => {
let service: MemberAccessReportService;
let cipherService: MockProxy<CipherService>;
let cipherAccessMappingService: MockProxy<CipherAccessMappingService>;
let logService: MockProxy<LogService>;
beforeEach(() => {
cipherService = mock<CipherService>();
cipherAccessMappingService = mock<CipherAccessMappingService>();
logService = mock<LogService>();
// Create service with constructor injection
service = new MemberAccessReportService(cipherService, cipherAccessMappingService, logService);
});
describe("pivotCipherDataToMemberSummaries", () => {
it("should return empty array for empty input", () => {
const result = service.pivotCipherDataToMemberSummaries([]);
expect(result).toEqual([]);
});
it("should handle single member with direct collection access", () => {
const ciphersWithMembers: CipherWithMemberAccess[] = [
createCipherWithMembers("cipher-1", [
createMemberAccess("user-1", "user1@test.com", [
{
type: "direct",
collectionId: "col-1",
collectionName: "Collection 1",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
];
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
expect(result).toHaveLength(1);
expect(result[0]).toEqual<MemberAccessSummary>({
userId: "user-1",
email: "user1@test.com",
name: null,
cipherCount: 1,
collectionCount: 1,
groupCount: 0,
highestPermission: EffectivePermissionLevel.Edit,
});
});
it("should handle member with group-based access", () => {
const ciphersWithMembers: CipherWithMemberAccess[] = [
createCipherWithMembers("cipher-1", [
createMemberAccess("user-1", "user1@test.com", [
{
type: "group",
collectionId: "col-1",
collectionName: "Collection 1",
groupId: "group-1",
groupName: "Group 1",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
];
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
expect(result).toHaveLength(1);
expect(result[0]).toEqual<MemberAccessSummary>({
userId: "user-1",
email: "user1@test.com",
name: null,
cipherCount: 1,
collectionCount: 1,
groupCount: 1,
highestPermission: EffectivePermissionLevel.Edit,
});
});
it("should handle member with multiple access paths and aggregate permissions", () => {
// Same member accessing multiple ciphers through different paths
const ciphersWithMembers: CipherWithMemberAccess[] = [
createCipherWithMembers("cipher-1", [
createMemberAccess(
"user-1",
"user1@test.com",
[
{
type: "direct",
collectionId: "col-1",
collectionName: "Collection 1",
permissions: { readOnly: true, hidePasswords: false, manage: false },
},
],
{ canEdit: false, canViewPasswords: true, canManage: false },
),
]),
createCipherWithMembers("cipher-2", [
createMemberAccess(
"user-1",
"user1@test.com",
[
{
type: "group",
collectionId: "col-2",
collectionName: "Collection 2",
groupId: "group-1",
groupName: "Group 1",
permissions: { readOnly: false, hidePasswords: false, manage: true },
},
],
{ canEdit: true, canViewPasswords: true, canManage: true },
),
]),
createCipherWithMembers("cipher-3", [
createMemberAccess(
"user-1",
"user1@test.com",
[
{
type: "group",
collectionId: "col-3",
collectionName: "Collection 3",
groupId: "group-2",
groupName: "Group 2",
permissions: { readOnly: true, hidePasswords: true, manage: false },
},
],
{ canEdit: false, canViewPasswords: false, canManage: false },
),
]),
];
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
expect(result).toHaveLength(1);
expect(result[0]).toEqual<MemberAccessSummary>({
userId: "user-1",
email: "user1@test.com",
name: null,
cipherCount: 3,
collectionCount: 3,
groupCount: 2,
// Should be Manage because one path has manage permission
highestPermission: EffectivePermissionLevel.Manage,
});
});
it("should handle multiple members with different permission levels", () => {
const ciphersWithMembers: CipherWithMemberAccess[] = [
createCipherWithMembers("cipher-1", [
createMemberAccess(
"user-1",
"user1@test.com",
[
{
type: "direct",
collectionId: "col-1",
collectionName: "Collection 1",
permissions: { readOnly: false, hidePasswords: false, manage: true },
},
],
{ canEdit: true, canViewPasswords: true, canManage: true },
),
createMemberAccess(
"user-2",
"user2@test.com",
[
{
type: "direct",
collectionId: "col-1",
collectionName: "Collection 1",
permissions: { readOnly: true, hidePasswords: false, manage: false },
},
],
{ canEdit: false, canViewPasswords: true, canManage: false },
),
createMemberAccess(
"user-3",
"user3@test.com",
[
{
type: "direct",
collectionId: "col-1",
collectionName: "Collection 1",
permissions: { readOnly: true, hidePasswords: true, manage: false },
},
],
{ canEdit: false, canViewPasswords: false, canManage: false },
),
]),
];
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
expect(result).toHaveLength(3);
// Find each member in results
const user1 = result.find((m) => m.userId === "user-1");
const user2 = result.find((m) => m.userId === "user-2");
const user3 = result.find((m) => m.userId === "user-3");
expect(user1?.highestPermission).toBe(EffectivePermissionLevel.Manage);
expect(user2?.highestPermission).toBe(EffectivePermissionLevel.ViewOnly);
expect(user3?.highestPermission).toBe(EffectivePermissionLevel.HidePasswords);
});
it("should deduplicate collections and groups across multiple ciphers", () => {
// Same member accessing multiple ciphers through the same collection/group
const ciphersWithMembers: CipherWithMemberAccess[] = [
createCipherWithMembers("cipher-1", [
createMemberAccess("user-1", "user1@test.com", [
{
type: "group",
collectionId: "col-1",
collectionName: "Collection 1",
groupId: "group-1",
groupName: "Group 1",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
createCipherWithMembers("cipher-2", [
createMemberAccess("user-1", "user1@test.com", [
{
type: "group",
collectionId: "col-1", // Same collection
collectionName: "Collection 1",
groupId: "group-1", // Same group
groupName: "Group 1",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
createCipherWithMembers("cipher-3", [
createMemberAccess("user-1", "user1@test.com", [
{
type: "direct",
collectionId: "col-1", // Same collection, direct access
collectionName: "Collection 1",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
];
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
expect(result).toHaveLength(1);
expect(result[0].cipherCount).toBe(3); // 3 unique ciphers
expect(result[0].collectionCount).toBe(1); // Only 1 unique collection
expect(result[0].groupCount).toBe(1); // Only 1 unique group
});
it("should sort members by cipher count descending, then email ascending", () => {
const ciphersWithMembers: CipherWithMemberAccess[] = [
createCipherWithMembers("cipher-1", [
createMemberAccess("user-a", "alice@test.com", [
{
type: "direct",
collectionId: "col-1",
collectionName: "Collection 1",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
createMemberAccess("user-b", "bob@test.com", [
{
type: "direct",
collectionId: "col-1",
collectionName: "Collection 1",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
createCipherWithMembers("cipher-2", [
createMemberAccess("user-b", "bob@test.com", [
{
type: "direct",
collectionId: "col-2",
collectionName: "Collection 2",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
createCipherWithMembers("cipher-3", [
createMemberAccess("user-b", "bob@test.com", [
{
type: "direct",
collectionId: "col-3",
collectionName: "Collection 3",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
createMemberAccess("user-c", "charlie@test.com", [
{
type: "direct",
collectionId: "col-3",
collectionName: "Collection 3",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
];
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
expect(result).toHaveLength(3);
// Bob has 3 ciphers, Alice and Charlie have 1 each
expect(result[0].email).toBe("bob@test.com");
expect(result[0].cipherCount).toBe(3);
// Alice comes before Charlie alphabetically (both have 1 cipher)
expect(result[1].email).toBe("alice@test.com");
expect(result[1].cipherCount).toBe(1);
expect(result[2].email).toBe("charlie@test.com");
expect(result[2].cipherCount).toBe(1);
});
it("should handle member with null email", () => {
const ciphersWithMembers: CipherWithMemberAccess[] = [
createCipherWithMembers("cipher-1", [
createMemberAccess("user-1", null, [
{
type: "direct",
collectionId: "col-1",
collectionName: "Collection 1",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
];
const result = service.pivotCipherDataToMemberSummaries(ciphersWithMembers);
expect(result).toHaveLength(1);
expect(result[0].email).toBe("(unknown)");
});
});
describe("getMemberAccessSummariesProgressive$", () => {
it("should return empty result for organization with no ciphers", (done) => {
cipherService.getAllFromApiForOrganization.mockResolvedValue([]);
const results: MemberAccessReportState[] = [];
service.getMemberAccessSummariesProgressive$(TestOrgId, TestUserId).subscribe({
next: (result) => {
results.push(result.state);
if (result.state === MemberAccessReportState.Complete) {
expect(result.members).toEqual([]);
expect(result.processedCipherCount).toBe(0);
expect(result.totalCipherCount).toBe(0);
expect(result.progressPercent).toBe(100);
done();
}
},
error: done.fail,
});
});
it("should emit progressive results as batches complete", (done) => {
const mockCiphers = [createMockCipherView("cipher-1"), createMockCipherView("cipher-2")];
cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers);
// Mock progressive results with two batches
const progressiveResults$ = new BehaviorSubject<CipherAccessMappingProgressiveResult>({
state: MemberAccessLoadState.ProcessingBatches,
processedCiphers: [
createCipherWithMembers("cipher-1", [
createMemberAccess("user-1", "user1@test.com", [
{
type: "direct",
collectionId: "col-1",
collectionName: "Collection 1",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
],
totalCipherCount: 2,
processedCount: 1,
progressPercent: 50,
timings: {},
counts: {},
});
cipherAccessMappingService.getAllCiphersWithMemberAccessProgressive$.mockReturnValue(
progressiveResults$,
);
const results: MemberAccessReportState[] = [];
service.getMemberAccessSummariesProgressive$(TestOrgId, TestUserId).subscribe({
next: (result) => {
results.push(result.state);
if (result.state === MemberAccessReportState.ProcessingMembers) {
// Emit the final batch
progressiveResults$.next({
state: MemberAccessLoadState.Complete,
processedCiphers: [
createCipherWithMembers("cipher-1", [
createMemberAccess("user-1", "user1@test.com", [
{
type: "direct",
collectionId: "col-1",
collectionName: "Collection 1",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
createCipherWithMembers("cipher-2", [
createMemberAccess("user-1", "user1@test.com", [
{
type: "direct",
collectionId: "col-2",
collectionName: "Collection 2",
permissions: { readOnly: false, hidePasswords: false, manage: false },
},
]),
]),
],
totalCipherCount: 2,
processedCount: 2,
progressPercent: 100,
timings: {},
counts: {},
});
progressiveResults$.complete();
}
if (result.state === MemberAccessReportState.Complete) {
expect(result.members).toHaveLength(1);
expect(result.members[0].cipherCount).toBe(2);
expect(result.progressPercent).toBe(100);
done();
}
},
error: done.fail,
});
});
it("should handle errors and emit error state", (done) => {
cipherService.getAllFromApiForOrganization.mockRejectedValue(new Error("API Error"));
service.getMemberAccessSummariesProgressive$(TestOrgId, TestUserId).subscribe({
next: (result) => {
if (result.state === MemberAccessReportState.Error) {
expect(result.error).toBe("API Error");
expect(result.members).toEqual([]);
done();
}
},
error: done.fail,
});
});
});
});
// ============================================================================
// Test Helpers
// ============================================================================
function createMockCipherView(id: string): CipherView {
const cipher = new CipherView();
cipher.id = id;
cipher.name = `Cipher ${id}`;
return cipher;
}
function createMemberAccess(
userId: string,
email: string | null,
accessPaths: CipherMemberAccess["accessPaths"],
effectivePermissions?: CipherMemberAccess["effectivePermissions"],
): CipherMemberAccess {
// Calculate default effective permissions from access paths if not provided
const defaultPermissions = effectivePermissions ?? {
canEdit: accessPaths.some((p) => !p.permissions.readOnly),
canViewPasswords: accessPaths.some((p) => !p.permissions.hidePasswords),
canManage: accessPaths.some((p) => p.permissions.manage),
};
return {
userId,
email,
accessPaths,
effectivePermissions: defaultPermissions,
};
}
function createCipherWithMembers(
cipherId: string,
members: CipherMemberAccess[],
): CipherWithMemberAccess {
const cipher = createMockCipherView(cipherId);
return {
cipher,
members,
totalMemberCount: members.length,
unassigned: false,
};
}

View File

@@ -0,0 +1,557 @@
import { Injectable } from "@angular/core";
import { Observable, catchError, from, map, of, switchMap, tap } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
EffectivePermissionLevel,
MemberAccessDetailView,
MemberAccessReportProgressiveResult,
MemberAccessReportState,
MemberAccessSummary,
MemberCollectionAccessDetail,
} from "../../models/member-access-report.types";
import {
CipherAccessMappingService,
CipherAccessPath,
CipherMemberAccess,
CipherWithMemberAccess,
EffectiveCipherPermissions,
MemberAccessLoadState,
} from "./cipher-access-mapping.service";
/**
* Internal accumulator for building member summaries as cipher batches arrive.
* Tracks sets for deduplication and accumulates counts.
*/
interface MemberAccumulator {
userId: string;
email: string;
name: string | null;
cipherIds: Set<string>;
collectionIds: Set<string>;
groupIds: Set<string>;
/** Tracks if member has at least one manage permission */
hasManage: boolean;
/** Tracks if member has at least one edit (non-readOnly) permission */
hasEdit: boolean;
/** Tracks if member can view passwords (at least one non-hidePasswords) */
hasViewPasswords: boolean;
}
/**
* Service for generating member-centric access reports from cipher data.
*
* This service provides a faster alternative to the server-side member access report
* by leveraging the client-side CipherAccessMappingService infrastructure.
*
* The core algorithm pivots cipher-centric data (cipher -> members[]) to
* member-centric data (member -> {cipherCount, collectionCount, groupCount, permission}).
*
* Use Cases:
* - Faster member access reports using client-side data
* - Progressive loading for large organizations
* - Auditing member access across the organization
*/
@Injectable()
export class MemberAccessReportService {
constructor(
private readonly cipherService: CipherService,
private readonly cipherAccessMappingService: CipherAccessMappingService,
private readonly logService: LogService,
) {}
/**
* Generates member access summaries with progressive loading.
* Emits partial results as cipher batches are processed.
*
* @param organizationId - The organization to generate the report for
* @param currentUserId - The current user's ID (for collection fetching)
* @returns Observable that emits progressive results after each batch
*/
getMemberAccessSummariesProgressive$(
organizationId: OrganizationId,
currentUserId: UserId,
): Observable<MemberAccessReportProgressiveResult> {
this.logService.info(
`[MemberAccessReportService] Starting progressive member access report for org ${organizationId}`,
);
// Phase 1: Fetch all ciphers
return from(this.cipherService.getAllFromApiForOrganization(organizationId)).pipe(
tap((ciphers) => {
this.logService.info(
`[MemberAccessReportService] Fetched ${ciphers.length} ciphers for org ${organizationId}`,
);
}),
// Phase 2: Stream cipher-member mapping and pivot to member summaries
switchMap((ciphers) => {
if (ciphers.length === 0) {
// No ciphers, return empty result immediately
return of<MemberAccessReportProgressiveResult>({
state: MemberAccessReportState.Complete,
members: [],
processedCipherCount: 0,
totalCipherCount: 0,
progressPercent: 100,
});
}
// Accumulator persists across batches to build complete member data
const memberAccumulators = new Map<string, MemberAccumulator>();
return this.cipherAccessMappingService
.getAllCiphersWithMemberAccessProgressive$(organizationId, currentUserId, ciphers, 500)
.pipe(
map((progressResult) => {
// Process the latest batch of ciphers into member accumulators
this.processCipherBatchIntoAccumulators(
progressResult.processedCiphers,
memberAccumulators,
);
// Convert accumulators to summaries
const members = this.convertAccumulatorsToSummaries(memberAccumulators);
// Map states
let state: MemberAccessReportState;
if (progressResult.state === MemberAccessLoadState.Complete) {
state = MemberAccessReportState.Complete;
} else if (progressResult.state === MemberAccessLoadState.Error) {
state = MemberAccessReportState.Error;
} else {
state = MemberAccessReportState.ProcessingMembers;
}
const result: MemberAccessReportProgressiveResult = {
state,
members,
processedCipherCount: progressResult.processedCount,
totalCipherCount: progressResult.totalCipherCount,
progressPercent: progressResult.progressPercent,
error: progressResult.error,
};
if (state === MemberAccessReportState.Complete) {
this.logService.info(
`[MemberAccessReportService] Report complete: ${members.length} members across ${progressResult.totalCipherCount} ciphers`,
);
}
return result;
}),
);
}),
catchError((error: unknown) => {
this.logService.error("[MemberAccessReportService] Error generating report", error);
const errorMessage =
error instanceof Error
? error.message
: "Unknown error occurred during member access report generation";
return of<MemberAccessReportProgressiveResult>({
state: MemberAccessReportState.Error,
members: [],
processedCipherCount: 0,
totalCipherCount: 0,
progressPercent: 0,
error: errorMessage,
});
}),
);
}
/**
* Generates member access summaries (non-progressive, complete result only).
* Waits for all cipher batches to complete before returning.
*
* @param organizationId - The organization to generate the report for
* @param currentUserId - The current user's ID (for collection fetching)
* @returns Observable that emits the complete member summary list
*/
getMemberAccessSummaries$(
organizationId: OrganizationId,
currentUserId: UserId,
): Observable<MemberAccessSummary[]> {
return this.getMemberAccessSummariesProgressive$(organizationId, currentUserId).pipe(
// Only emit when complete
map((result) => {
if (result.state === MemberAccessReportState.Complete) {
return result.members;
}
// Return empty array for intermediate states (will be filtered by last())
return [];
}),
// Filter to only get the final complete result
map((members, index) => {
// We rely on the final emission from the progressive stream
return members;
}),
);
}
/**
* Retrieves detailed access information for a specific member.
* Returns collection-level breakdown with cipher counts and permissions.
*
* @param organizationId - The organization to query
* @param currentUserId - The current user's ID (for collection fetching)
* @param targetUserId - The member's user ID to get details for
* @returns Observable that emits the detail view, or null if member not found
*/
getMemberAccessDetail$(
organizationId: OrganizationId,
currentUserId: UserId,
targetUserId: string,
): Observable<MemberAccessDetailView | null> {
this.logService.info(
`[MemberAccessReportService] Getting access detail for user ${targetUserId} in org ${organizationId}`,
);
return this.cipherAccessMappingService
.findCiphersForUser$(organizationId, currentUserId, targetUserId)
.pipe(
map((userCiphers) => {
if (userCiphers.length === 0) {
this.logService.info(
`[MemberAccessReportService] No ciphers found for user ${targetUserId}`,
);
return null;
}
return this.transformToDetailView(userCiphers, targetUserId);
}),
catchError((error: unknown) => {
this.logService.error(
`[MemberAccessReportService] Error getting member detail for ${targetUserId}`,
error,
);
return of(null);
}),
);
}
/**
* Pivots an array of cipher-member data to member summaries.
* This is the core transformation logic, exposed for direct use or testing.
*
* @param ciphersWithMembers - Array of ciphers with their member access data
* @returns Array of member access summaries
*/
pivotCipherDataToMemberSummaries(
ciphersWithMembers: CipherWithMemberAccess[],
): MemberAccessSummary[] {
const memberAccumulators = new Map<string, MemberAccumulator>();
this.processCipherBatchIntoAccumulators(ciphersWithMembers, memberAccumulators);
return this.convertAccumulatorsToSummaries(memberAccumulators);
}
// ============================================================================
// PRIVATE HELPER METHODS
// ============================================================================
/**
* Processes a batch of ciphers with member access data into the accumulator map.
* Updates existing accumulators or creates new ones as needed.
*
* @param ciphersWithMembers - Batch of ciphers with member access
* @param accumulators - Map to accumulate member data into
*/
private processCipherBatchIntoAccumulators(
ciphersWithMembers: CipherWithMemberAccess[],
accumulators: Map<string, MemberAccumulator>,
): void {
for (const cipherData of ciphersWithMembers) {
const cipherId = cipherData.cipher.id;
for (const memberAccess of cipherData.members) {
this.updateAccumulatorFromMemberAccess(accumulators, cipherId, memberAccess);
}
}
}
/**
* Updates or creates an accumulator for a member based on their access to a cipher.
*
* @param accumulators - The accumulator map
* @param cipherId - The cipher ID being processed
* @param memberAccess - The member's access data for this cipher
*/
private updateAccumulatorFromMemberAccess(
accumulators: Map<string, MemberAccumulator>,
cipherId: string,
memberAccess: CipherMemberAccess,
): void {
const userId = memberAccess.userId;
// Get or create accumulator
let accumulator = accumulators.get(userId);
if (!accumulator) {
accumulator = {
userId,
email: memberAccess.email ?? "(unknown)",
name: null, // Name is not available in CipherMemberAccess
cipherIds: new Set<string>(),
collectionIds: new Set<string>(),
groupIds: new Set<string>(),
hasManage: false,
hasEdit: false,
hasViewPasswords: false,
};
accumulators.set(userId, accumulator);
}
// Add cipher to the set (deduplication)
accumulator.cipherIds.add(cipherId);
// Process access paths to collect collections and groups
for (const accessPath of memberAccess.accessPaths) {
this.processAccessPath(accumulator, accessPath);
}
// Update permission flags from effective permissions
this.updatePermissionFlags(accumulator, memberAccess.effectivePermissions);
}
/**
* Processes a single access path to extract collection and group IDs.
*
* @param accumulator - The member accumulator to update
* @param accessPath - The access path to process
*/
private processAccessPath(accumulator: MemberAccumulator, accessPath: CipherAccessPath): void {
// Always add collection
accumulator.collectionIds.add(accessPath.collectionId);
// Add group if this is a group-based access path
if (accessPath.type === "group" && accessPath.groupId) {
accumulator.groupIds.add(accessPath.groupId);
}
}
/**
* Updates permission flags based on effective permissions.
* Uses "most permissive" logic - if any path grants a permission, it's tracked.
*
* @param accumulator - The member accumulator to update
* @param permissions - The effective permissions for this cipher access
*/
private updatePermissionFlags(
accumulator: MemberAccumulator,
permissions: EffectiveCipherPermissions,
): void {
if (permissions.canManage) {
accumulator.hasManage = true;
}
if (permissions.canEdit) {
accumulator.hasEdit = true;
}
if (permissions.canViewPasswords) {
accumulator.hasViewPasswords = true;
}
}
/**
* Converts accumulator map to sorted array of member summaries.
*
* @param accumulators - Map of member accumulators
* @returns Sorted array of member access summaries
*/
private convertAccumulatorsToSummaries(
accumulators: Map<string, MemberAccumulator>,
): MemberAccessSummary[] {
const summaries: MemberAccessSummary[] = [];
for (const accumulator of accumulators.values()) {
summaries.push({
userId: accumulator.userId,
email: accumulator.email,
name: accumulator.name,
cipherCount: accumulator.cipherIds.size,
collectionCount: accumulator.collectionIds.size,
groupCount: accumulator.groupIds.size,
highestPermission: this.calculateHighestPermission(accumulator),
});
}
// Sort by cipher count descending, then by email ascending
summaries.sort((a, b) => {
if (b.cipherCount !== a.cipherCount) {
return b.cipherCount - a.cipherCount;
}
return a.email.localeCompare(b.email);
});
return summaries;
}
/**
* Calculates the highest permission level from accumulated flags.
*
* Permission hierarchy (highest to lowest):
* 1. Manage - Full control including delete
* 2. Edit - Can modify but not manage
* 3. ViewOnly - Can view but not modify (readOnly: true, hidePasswords: false)
* 4. HidePasswords - Most restricted (readOnly: true, hidePasswords: true)
*
* @param accumulator - The member accumulator with permission flags
* @returns The highest effective permission level
*/
private calculateHighestPermission(accumulator: MemberAccumulator): EffectivePermissionLevel {
// Check in order of priority (highest to lowest)
if (accumulator.hasManage) {
return EffectivePermissionLevel.Manage;
}
if (accumulator.hasEdit) {
return EffectivePermissionLevel.Edit;
}
if (accumulator.hasViewPasswords) {
return EffectivePermissionLevel.ViewOnly;
}
// Default to most restricted if none of the above
return EffectivePermissionLevel.HidePasswords;
}
/**
* Transforms cipher access data into a member detail view.
* Groups ciphers by collection+accessType+groupId to create access path summaries.
*
* @param userCiphers - Array of ciphers the user has access to
* @param targetUserId - The user ID to extract details for
* @returns MemberAccessDetailView with collection breakdown
*/
private transformToDetailView(
userCiphers: CipherWithMemberAccess[],
targetUserId: string,
): MemberAccessDetailView {
// Extract user info from first cipher's member data
let email = "(unknown)";
const name: string | null = null;
for (const cipherData of userCiphers) {
const memberData = cipherData.members.find((m) => m.userId === targetUserId);
if (memberData?.email) {
email = memberData.email;
break;
}
}
// Build a map keyed by "collectionId|accessType|groupId" to group ciphers by access path
const accessPathMap = new Map<
string,
{
collectionId: string;
collectionName: string;
accessType: "direct" | "group";
groupId: string | null;
groupName: string | null;
permissions: { readOnly: boolean; hidePasswords: boolean; manage: boolean };
cipherIds: Set<string>;
}
>();
// Process each cipher and its access paths for this user
for (const cipherData of userCiphers) {
const memberData = cipherData.members.find((m) => m.userId === targetUserId);
if (!memberData) {
continue;
}
for (const accessPath of memberData.accessPaths) {
const groupId = accessPath.type === "group" ? (accessPath.groupId ?? null) : null;
const key = `${accessPath.collectionId}|${accessPath.type}|${groupId ?? ""}`;
let pathData = accessPathMap.get(key);
if (!pathData) {
pathData = {
collectionId: accessPath.collectionId,
collectionName: accessPath.collectionName,
accessType: accessPath.type,
groupId: groupId,
groupName: accessPath.type === "group" ? (accessPath.groupName ?? null) : null,
permissions: { ...accessPath.permissions },
cipherIds: new Set<string>(),
};
accessPathMap.set(key, pathData);
}
pathData.cipherIds.add(cipherData.cipher.id);
}
}
// Convert to MemberCollectionAccessDetail array
const collectionDetails: MemberCollectionAccessDetail[] = [];
for (const pathData of accessPathMap.values()) {
collectionDetails.push({
collectionId: pathData.collectionId,
collectionName: pathData.collectionName,
cipherCount: pathData.cipherIds.size,
permission: this.calculatePermissionFromFlags(pathData.permissions),
accessType: pathData.accessType,
groupName: pathData.groupName,
groupId: pathData.groupId,
});
}
// Sort by collection name, then by access type (direct first)
collectionDetails.sort((a, b) => {
const nameCompare = a.collectionName.localeCompare(b.collectionName);
if (nameCompare !== 0) {
return nameCompare;
}
// Direct access before group access
if (a.accessType !== b.accessType) {
return a.accessType === "direct" ? -1 : 1;
}
// If both are group access, sort by group name
if (a.groupName && b.groupName) {
return a.groupName.localeCompare(b.groupName);
}
return 0;
});
// Count unique ciphers
const uniqueCipherIds = new Set<string>();
for (const cipherData of userCiphers) {
uniqueCipherIds.add(cipherData.cipher.id);
}
this.logService.info(
`[MemberAccessReportService] Built detail view for ${email}: ${uniqueCipherIds.size} ciphers, ${collectionDetails.length} access paths`,
);
return {
userId: targetUserId,
email,
name,
totalCipherCount: uniqueCipherIds.size,
collectionDetails,
};
}
/**
* Calculates permission level from raw permission flags.
*
* @param permissions - Raw permission flags from access path
* @returns The effective permission level
*/
private calculatePermissionFromFlags(permissions: {
readOnly: boolean;
hidePasswords: boolean;
manage: boolean;
}): EffectivePermissionLevel {
if (permissions.manage) {
return EffectivePermissionLevel.Manage;
}
if (!permissions.readOnly) {
return EffectivePermissionLevel.Edit;
}
if (!permissions.hidePasswords) {
return EffectivePermissionLevel.ViewOnly;
}
return EffectivePermissionLevel.HidePasswords;
}
}

View File

@@ -4,6 +4,7 @@ export * from "./api/risk-insights-api.service";
export * from "./api/security-tasks-api.service";
export * from "./domain/cipher-access-mapping.service";
export * from "./domain/critical-apps.service";
export * from "./domain/member-access-report.service";
export * from "./domain/password-health.service";
export * from "./domain/risk-insights-encryption.service";
export * from "./domain/risk-insights-orchestrator.service";

View File

@@ -78,6 +78,17 @@ const routes: Routes = [
},
canActivate: [isEnterpriseOrgGuard()],
},
{
path: "member-access-report-prototype",
loadComponent: () =>
import("../../dirt/reports/member-access-report-prototype/member-access-report-prototype.component").then(
(mod) => mod.MemberAccessReportPrototypeComponent,
),
data: {
titleId: "memberAccessReport",
},
canActivate: [isEnterpriseOrgGuard()],
},
],
},
{

View File

@@ -0,0 +1,73 @@
<bit-dialog title="Member Access Details">
<div bitDialogContent>
<ng-container *ngIf="loading">
<div class="tw-flex tw-justify-center tw-items-center tw-py-8">
<i class="bwi bwi-spinner bwi-spin tw-text-primary-600" aria-hidden="true"></i>
<span class="tw-ml-2">Loading access details...</span>
</div>
</ng-container>
<ng-container *ngIf="!loading && !detailView">
<div class="tw-text-center tw-py-8 tw-text-muted">
No access details found for this member.
</div>
</ng-container>
<ng-container *ngIf="!loading && detailView">
<!-- Member Info -->
<div class="tw-mb-4">
<p bitTypography="body1" class="tw-font-semibold tw-mb-1">
{{ detailView.name || detailView.email }}
</p>
<p bitTypography="body2" class="tw-text-muted" *ngIf="detailView.name">
{{ detailView.email }}
</p>
</div>
<!-- Summary -->
<div class="tw-mb-4 tw-p-3 tw-bg-background-alt tw-rounded">
<p bitTypography="body2">
<strong>{{ detailView.totalCipherCount }}</strong> items accessible via
<strong>{{ detailView.collectionDetails.length }}</strong> access paths
</p>
</div>
<!-- Access Details Table -->
<div class="tw-max-h-[400px] tw-overflow-y-auto">
<table class="tw-w-full tw-text-sm">
<thead class="tw-bg-background-alt tw-sticky tw-top-0">
<tr>
<th class="tw-text-left tw-p-2 tw-font-medium">Collection</th>
<th class="tw-text-left tw-p-2 tw-font-medium">Access Via</th>
<th class="tw-text-right tw-p-2 tw-font-medium tw-w-[80px]">Items</th>
<th class="tw-text-left tw-p-2 tw-font-medium tw-w-[120px]">Permission</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let detail of detailView.collectionDetails"
class="tw-border-b tw-border-secondary-300"
>
<td class="tw-p-2">{{ detail.collectionName }}</td>
<td class="tw-p-2 tw-text-muted">
<ng-container *ngIf="detail.accessType === 'direct'"> Direct </ng-container>
<ng-container *ngIf="detail.accessType === 'group'">
via {{ detail.groupName || "Group" }}
</ng-container>
</td>
<td class="tw-p-2 tw-text-right">{{ detail.cipherCount }}</td>
<td class="tw-p-2">
<span bitBadge [variant]="getPermissionBadgeVariant(detail.permission)">
{{ getPermissionLabel(detail.permission) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</ng-container>
</div>
<div bitDialogFooter>
<button type="button" bitButton buttonType="secondary" bitDialogClose>Close</button>
</div>
</bit-dialog>

View File

@@ -0,0 +1,113 @@
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
Inject,
OnInit,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
CipherAccessMappingService,
EffectivePermissionLevel,
MemberAccessDetailView,
MemberAccessReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import {
BadgeModule,
ButtonModule,
DIALOG_DATA,
DialogModule,
DialogService,
TypographyModule,
} from "@bitwarden/components";
export interface MemberAccessDetailDialogData {
organizationId: OrganizationId;
currentUserId: UserId;
targetUserId: string;
memberEmail: string;
memberName: string | null;
}
@Component({
selector: "app-member-access-detail-dialog",
templateUrl: "member-access-detail-dialog.component.html",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, DialogModule, ButtonModule, BadgeModule, TypographyModule],
providers: [CipherAccessMappingService, MemberAccessReportService],
})
export class MemberAccessDetailDialogComponent implements OnInit {
protected loading = true;
protected detailView: MemberAccessDetailView | null = null;
protected readonly EffectivePermissionLevel = EffectivePermissionLevel;
constructor(
@Inject(DIALOG_DATA) protected dialogData: MemberAccessDetailDialogData,
private readonly memberAccessReportService: MemberAccessReportService,
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly destroyRef: DestroyRef,
) {}
ngOnInit() {
this.memberAccessReportService
.getMemberAccessDetail$(
this.dialogData.organizationId,
this.dialogData.currentUserId,
this.dialogData.targetUserId,
)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (detail) => {
this.detailView = detail;
this.loading = false;
this.changeDetectorRef.markForCheck();
},
error: () => {
this.loading = false;
this.changeDetectorRef.markForCheck();
},
});
}
static open(dialogService: DialogService, data: MemberAccessDetailDialogData) {
return dialogService.open(MemberAccessDetailDialogComponent, { data });
}
getPermissionBadgeVariant(
permission: EffectivePermissionLevel,
): "primary" | "secondary" | "success" | "danger" | "warning" | "info" {
switch (permission) {
case EffectivePermissionLevel.Manage:
return "success";
case EffectivePermissionLevel.Edit:
return "primary";
case EffectivePermissionLevel.ViewOnly:
return "info";
case EffectivePermissionLevel.HidePasswords:
return "warning";
default:
return "secondary";
}
}
getPermissionLabel(permission: EffectivePermissionLevel): string {
switch (permission) {
case EffectivePermissionLevel.Manage:
return "Manage";
case EffectivePermissionLevel.Edit:
return "Edit";
case EffectivePermissionLevel.ViewOnly:
return "View Only";
case EffectivePermissionLevel.HidePasswords:
return "Hide Passwords";
default:
return "Unknown";
}
}
}

View File

@@ -0,0 +1,115 @@
<app-header>
<bit-search
[formControl]="searchControl"
[placeholder]="'searchMembers' | i18n"
class="tw-grow"
*ngIf="isComplete"
></bit-search>
<button
type="button"
bitButton
buttonType="secondary"
(click)="startLoading()"
*ngIf="isComplete || isError"
>
<i class="bwi bwi-refresh" aria-hidden="true"></i>
Reload
</button>
</app-header>
<div class="tw-max-w-4xl tw-mb-4">
<p bitTypography="body1">
<strong>Prototype:</strong> Member access report using client-side data assembly. This approach
pivots cipher-access data to member-centric summaries without server-side API calls.
</p>
</div>
<!-- Loading State -->
<ng-container *ngIf="isLoading">
<div class="tw-flex-col tw-flex tw-justify-center tw-items-center tw-gap-4 tw-my-8">
<i class="bwi bwi-2x bwi-spinner bwi-spin tw-text-primary-600" aria-hidden="true"></i>
<h2 bitTypography="h2">
<ng-container *ngIf="state === MemberAccessReportState.LoadingCiphers">
Loading ciphers...
</ng-container>
<ng-container *ngIf="state === MemberAccessReportState.ProcessingMembers">
Processing member access...
</ng-container>
</h2>
<div class="tw-w-full tw-max-w-md">
<bit-progress [barWidth]="progressPercent"></bit-progress>
<p class="tw-text-center tw-text-muted tw-mt-2">
{{ processedCipherCount }} / {{ totalCipherCount }} ciphers processed ({{
progressPercent
}}%)
</p>
</div>
</div>
</ng-container>
<!-- Error State -->
<ng-container *ngIf="isError">
<div
class="tw-flex-col tw-flex tw-justify-center tw-items-center tw-gap-4 tw-mt-8 tw-p-4 tw-bg-danger-100 tw-rounded"
>
<i class="bwi bwi-2x bwi-error tw-text-danger-600" aria-hidden="true"></i>
<h2 bitTypography="h2" class="tw-text-danger-600">Error Loading Report</h2>
<p class="tw-text-danger-700">{{ errorMessage }}</p>
</div>
</ng-container>
<!-- Results -->
<ng-container *ngIf="isComplete">
<!-- Stats Bar -->
<div class="tw-flex tw-gap-6 tw-mb-4 tw-p-4 tw-bg-background-alt tw-rounded tw-items-center">
<div>
<span class="tw-text-muted">Members:</span>
<strong class="tw-ml-1">{{ dataSource.data.length }}</strong>
</div>
<div>
<span class="tw-text-muted">Ciphers Processed:</span>
<strong class="tw-ml-1">{{ totalCipherCount }}</strong>
</div>
<div>
<span class="tw-text-muted">Load Time:</span>
<strong class="tw-ml-1">{{ loadTimeMs }}ms</strong>
</div>
</div>
<!-- Data Table -->
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
<ng-container header>
<th bitCell bitSortable="email" default>Member</th>
<th bitCell bitSortable="cipherCount" class="tw-w-[150px]">Items</th>
<th bitCell bitSortable="collectionCount" class="tw-w-[150px]">Collections</th>
<th bitCell bitSortable="groupCount" class="tw-w-[150px]">Groups</th>
</ng-container>
<ng-template bitRowDef let-row let-rowAttr="attr">
<td
bitCell
[attr.data-row-id]="rowAttr"
class="tw-cursor-pointer"
(click)="onMemberRowClick(row)"
>
<div class="tw-flex tw-items-center">
<bit-avatar size="small" [text]="row.email" class="tw-mr-3"></bit-avatar>
<div class="tw-flex tw-flex-col">
<span class="tw-font-medium">{{ row.name || row.email }}</span>
<span class="tw-text-sm tw-text-muted" *ngIf="row.name">{{ row.email }}</span>
</div>
</div>
</td>
<td bitCell class="tw-text-muted tw-cursor-pointer" (click)="onMemberRowClick(row)">
{{ row.cipherCount }}
</td>
<td bitCell class="tw-text-muted tw-cursor-pointer" (click)="onMemberRowClick(row)">
{{ row.collectionCount }}
</td>
<td bitCell class="tw-text-muted tw-cursor-pointer" (click)="onMemberRowClick(row)">
{{ row.groupCount }}
</td>
</ng-template>
</bit-table-scroll>
</ng-container>

View File

@@ -0,0 +1,221 @@
import { CommonModule } from "@angular/common";
import {
Component,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
ChangeDetectorRef,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { Subject, debounceTime, firstValueFrom, takeUntil } from "rxjs";
import {
CipherAccessMappingService,
EffectivePermissionLevel,
MemberAccessReportService,
MemberAccessReportState,
MemberAccessSummary,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import {
BadgeModule,
DialogService,
IconModule,
ProgressModule,
SearchModule,
TableDataSource,
TableModule,
} from "@bitwarden/components";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { MemberAccessDetailDialogComponent } from "./member-access-detail-dialog.component";
@Component({
selector: "app-member-access-report-prototype",
templateUrl: "member-access-report-prototype.component.html",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
SharedModule,
SearchModule,
HeaderModule,
TableModule,
BadgeModule,
IconModule,
ProgressModule,
ReactiveFormsModule,
],
providers: [CipherAccessMappingService, MemberAccessReportService],
})
export class MemberAccessReportPrototypeComponent implements OnInit, OnDestroy {
protected dataSource = new TableDataSource<MemberAccessSummary>();
protected searchControl = new FormControl("", { nonNullable: true });
protected organizationId: OrganizationId;
protected currentUserId: UserId;
// Loading state
protected state: MemberAccessReportState = MemberAccessReportState.Idle;
protected progressPercent = 0;
protected processedCipherCount = 0;
protected totalCipherCount = 0;
protected errorMessage: string | null = null;
// Timing info
protected loadStartTime: number = 0;
protected loadEndTime: number = 0;
// Constants for template
protected readonly MemberAccessReportState = MemberAccessReportState;
protected readonly EffectivePermissionLevel = EffectivePermissionLevel;
private readonly destroy$ = new Subject<void>();
constructor(
private readonly route: ActivatedRoute,
private readonly accountService: AccountService,
private readonly memberAccessReportService: MemberAccessReportService,
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly dialogService: DialogService,
) {
// Connect the search input to the table dataSource filter
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
.subscribe((v) => (this.dataSource.filter = v));
}
async ngOnInit() {
const params = await firstValueFrom(this.route.params);
this.organizationId = params.organizationId;
const account = await firstValueFrom(this.accountService.activeAccount$);
if (account) {
this.currentUserId = account.id;
}
// Auto-start loading
this.startLoading();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
startLoading() {
if (!this.organizationId || !this.currentUserId) {
this.errorMessage = "Organization ID or User ID not available";
this.state = MemberAccessReportState.Error;
this.changeDetectorRef.markForCheck();
return;
}
// Reset state
this.state = MemberAccessReportState.LoadingCiphers;
this.progressPercent = 0;
this.processedCipherCount = 0;
this.totalCipherCount = 0;
this.errorMessage = null;
this.dataSource.data = [];
this.loadStartTime = performance.now();
this.loadEndTime = 0;
this.changeDetectorRef.markForCheck();
this.memberAccessReportService
.getMemberAccessSummariesProgressive$(this.organizationId, this.currentUserId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (result) => {
this.state = result.state;
this.progressPercent = result.progressPercent;
this.processedCipherCount = result.processedCipherCount;
this.totalCipherCount = result.totalCipherCount;
this.dataSource.data = result.members;
if (result.state === MemberAccessReportState.Complete) {
this.loadEndTime = performance.now();
}
if (result.error) {
this.errorMessage = result.error;
}
this.changeDetectorRef.markForCheck();
},
error: (err: unknown) => {
this.state = MemberAccessReportState.Error;
this.errorMessage = err instanceof Error ? err.message : "An error occurred";
this.loadEndTime = performance.now();
this.changeDetectorRef.markForCheck();
},
});
}
get loadTimeMs(): number {
if (this.loadEndTime > 0 && this.loadStartTime > 0) {
return Math.round(this.loadEndTime - this.loadStartTime);
}
return 0;
}
get isLoading(): boolean {
return (
this.state === MemberAccessReportState.LoadingCiphers ||
this.state === MemberAccessReportState.ProcessingMembers
);
}
get isComplete(): boolean {
return this.state === MemberAccessReportState.Complete;
}
get isError(): boolean {
return this.state === MemberAccessReportState.Error;
}
getPermissionBadgeVariant(
permission: EffectivePermissionLevel,
): "primary" | "secondary" | "success" | "danger" | "warning" | "info" {
switch (permission) {
case EffectivePermissionLevel.Manage:
return "success";
case EffectivePermissionLevel.Edit:
return "primary";
case EffectivePermissionLevel.ViewOnly:
return "info";
case EffectivePermissionLevel.HidePasswords:
return "warning";
default:
return "secondary";
}
}
getPermissionLabel(permission: EffectivePermissionLevel): string {
switch (permission) {
case EffectivePermissionLevel.Manage:
return "Manage";
case EffectivePermissionLevel.Edit:
return "Edit";
case EffectivePermissionLevel.ViewOnly:
return "View Only";
case EffectivePermissionLevel.HidePasswords:
return "Hide Passwords";
default:
return "Unknown";
}
}
onMemberRowClick(member: MemberAccessSummary) {
MemberAccessDetailDialogComponent.open(this.dialogService, {
organizationId: this.organizationId,
currentUserId: this.currentUserId,
targetUserId: member.userId,
memberEmail: member.email,
memberName: member.name,
});
}
}