From 0e23a07bbcc70fe1a9756c6ff8830221c14570f7 Mon Sep 17 00:00:00 2001
From: Tom <144813356+ttalty@users.noreply.github.com>
Date: Mon, 11 Nov 2024 11:18:10 -0500
Subject: [PATCH] [PM-13298] Modify members access logic (#4876)
* Initial refactor of members acess
* Refactor of the members access report to include a list of ciphers
* Saving ciphers to parent object
* Missed saving the response model
* bit.core change and updating references. Removing unused refs
* Removing commented code
* Adding Bit to the namespaces
* The mapping to the response model missed setting the UserId
---
.../Tools/Controllers/ReportsController.cs | 97 ++++----
.../Response/MemberAccessReportModel.cs | 163 +-------------
.../MemberCipherDetailsResponseModel.cs | 24 ++
.../Models/Data/MemberAccessCipherDetails.cs | 43 ++++
.../MemberAccessCipherDetailsQuery.cs | 208 ++++++++++++++++++
.../IMemberAccessCipherDetailsQuery.cs | 9 +
.../ReportingServiceCollectionExtensions.cs | 13 ++
.../MemberAccessCipherDetailsRequest.cs | 6 +
.../Utilities/ServiceCollectionExtensions.cs | 2 +
9 files changed, 370 insertions(+), 195 deletions(-)
create mode 100644 src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs
create mode 100644 src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs
create mode 100644 src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs
create mode 100644 src/Core/Tools/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs
create mode 100644 src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs
create mode 100644 src/Core/Tools/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs
diff --git a/src/Api/Tools/Controllers/ReportsController.cs b/src/Api/Tools/Controllers/ReportsController.cs
index 5beb320e4..c8cfc0a21 100644
--- a/src/Api/Tools/Controllers/ReportsController.cs
+++ b/src/Api/Tools/Controllers/ReportsController.cs
@@ -1,13 +1,9 @@
using Bit.Api.Tools.Models.Response;
-using Bit.Core.AdminConsole.Repositories;
-using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context;
using Bit.Core.Exceptions;
-using Bit.Core.Repositories;
-using Bit.Core.Services;
-using Bit.Core.Vault.Queries;
-using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
-using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
+using Bit.Core.Tools.Models.Data;
+using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
+using Bit.Core.Tools.ReportFeatures.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -17,33 +13,49 @@ namespace Bit.Api.Tools.Controllers;
[Authorize("Application")]
public class ReportsController : Controller
{
- private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
- private readonly IGroupRepository _groupRepository;
- private readonly ICollectionRepository _collectionRepository;
private readonly ICurrentContext _currentContext;
- private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
- private readonly IApplicationCacheService _applicationCacheService;
- private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
+ private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
public ReportsController(
- IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
- IGroupRepository groupRepository,
- ICollectionRepository collectionRepository,
ICurrentContext currentContext,
- IOrganizationCiphersQuery organizationCiphersQuery,
- IApplicationCacheService applicationCacheService,
- ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
+ IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery
)
{
- _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
- _groupRepository = groupRepository;
- _collectionRepository = collectionRepository;
_currentContext = currentContext;
- _organizationCiphersQuery = organizationCiphersQuery;
- _applicationCacheService = applicationCacheService;
- _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
+ _memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
}
+ ///
+ /// Organization member information containing a list of cipher ids
+ /// assigned
+ ///
+ /// Organzation Id
+ /// IEnumerable of MemberCipherDetailsResponseModel
+ /// If Access reports permission is not assigned
+ [HttpGet("member-cipher-details/{orgId}")]
+ public async Task> GetMemberCipherDetails(Guid orgId)
+ {
+ // Using the AccessReports permission here until new permissions
+ // are needed for more control over reports
+ if (!await _currentContext.AccessReports(orgId))
+ {
+ throw new NotFoundException();
+ }
+
+ var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
+
+ var responses = memberCipherDetails.Select(x => new MemberCipherDetailsResponseModel(x));
+
+ return responses;
+ }
+
+ ///
+ /// Access details for an organization member. Includes the member information,
+ /// group collection assignment, and item counts
+ ///
+ /// Organization Id
+ /// IEnumerable of MemberAccessReportResponseModel
+ /// If Access reports permission is not assigned
[HttpGet("member-access/{orgId}")]
public async Task> GetMemberAccessReport(Guid orgId)
{
@@ -52,26 +64,23 @@ public class ReportsController : Controller
throw new NotFoundException();
}
- var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
- new OrganizationUserUserDetailsQueryRequest
- {
- OrganizationId = orgId,
- IncludeCollections = true,
- IncludeGroups = true
- });
+ var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
- var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
- var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId);
- var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId);
- var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(orgId);
- var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
+ var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
- var reports = MemberAccessReportResponseModel.CreateReport(
- orgGroups,
- orgCollectionsWithAccess,
- orgItems,
- organizationUsersTwoFactorEnabled,
- orgAbility);
- return reports;
+ return responses;
+ }
+
+ ///
+ /// Contains the organization member info, the cipher ids associated with the member,
+ /// and details on their collections, groups, and permissions
+ ///
+ /// Request to the MemberAccessCipherDetailsQuery
+ /// IEnumerable of MemberAccessCipherDetails
+ private async Task> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request)
+ {
+ var memberCipherDetails =
+ await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
+ return memberCipherDetails;
}
}
diff --git a/src/Api/Tools/Models/Response/MemberAccessReportModel.cs b/src/Api/Tools/Models/Response/MemberAccessReportModel.cs
index 378d4d94c..b110c316c 100644
--- a/src/Api/Tools/Models/Response/MemberAccessReportModel.cs
+++ b/src/Api/Tools/Models/Response/MemberAccessReportModel.cs
@@ -1,30 +1,7 @@
-using Bit.Core.AdminConsole.Entities;
-using Bit.Core.Entities;
-using Bit.Core.Models.Data;
-using Bit.Core.Models.Data.Organizations;
-using Bit.Core.Models.Data.Organizations.OrganizationUsers;
-using Bit.Core.Vault.Models.Data;
+using Bit.Core.Tools.Models.Data;
namespace Bit.Api.Tools.Models.Response;
-///
-/// Member access details. The individual item for the detailed member access
-/// report. A collection can be assigned directly to a user without a group or
-/// the user can be assigned to a collection through a group. Group level permissions
-/// can override collection level permissions.
-///
-public class MemberAccessReportAccessDetails
-{
- public Guid? CollectionId { get; set; }
- public Guid? GroupId { get; set; }
- public string GroupName { get; set; }
- public string CollectionName { get; set; }
- public int ItemCount { get; set; }
- public bool? ReadOnly { get; set; }
- public bool? HidePasswords { get; set; }
- public bool? Manage { get; set; }
-}
-
///
/// Contains the collections and group collections a user has access to including
/// the permission level for the collection and group collection.
@@ -40,134 +17,18 @@ public class MemberAccessReportResponseModel
public int TotalItemCount { get; set; }
public Guid? UserGuid { get; set; }
public bool UsesKeyConnector { get; set; }
- public IEnumerable AccessDetails { get; set; }
+ public IEnumerable AccessDetails { get; set; }
- ///
- /// Generates a report for all members of an organization. Containing summary information
- /// such as item, collection, and group counts. As well as detailed information on the
- /// user and group collections along with their permissions
- ///
- /// Organization groups collection
- /// Collections for the organization and the groups/users and permissions
- /// Cipher items for the organization with the collections associated with them
- /// Organization users and two factor status
- /// Organization ability for account recovery status
- /// List of the MemberAccessReportResponseModel;
- public static IEnumerable CreateReport(
- ICollection orgGroups,
- ICollection> orgCollectionsWithAccess,
- IEnumerable orgItems,
- IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
- OrganizationAbility orgAbility)
+ public MemberAccessReportResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
{
- var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user);
- // Create a dictionary to lookup the group names later.
- var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
-
- // Get collections grouped and into a dictionary for counts
- var collectionItems = orgItems
- .SelectMany(x => x.CollectionIds,
- (x, b) => new { CipherId = x.Id, CollectionId = b })
- .GroupBy(y => y.CollectionId,
- (key, g) => new { CollectionId = key, Ciphers = g });
- var collectionItemCounts = collectionItems.ToDictionary(x => x.CollectionId, x => x.Ciphers.Count());
-
-
- // Loop through the org users and populate report and access data
- var memberAccessReport = new List();
- foreach (var user in orgUsers)
- {
- // Take the collections/groups and create the access details items
- var groupAccessDetails = new List();
- var userCollectionAccessDetails = new List();
- foreach (var tCollect in orgCollectionsWithAccess)
- {
- var itemCounts = collectionItemCounts.TryGetValue(tCollect.Item1.Id, out var itemCount) ? itemCount : 0;
- if (tCollect.Item2.Groups.Count() > 0)
- {
- var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
- new MemberAccessReportAccessDetails
- {
- CollectionId = tCollect.Item1.Id,
- CollectionName = tCollect.Item1.Name,
- GroupId = x.Id,
- GroupName = groupNameDictionary[x.Id],
- ReadOnly = x.ReadOnly,
- HidePasswords = x.HidePasswords,
- Manage = x.Manage,
- ItemCount = itemCounts,
- });
- groupAccessDetails.AddRange(groupDetails);
- }
-
- // All collections assigned to users and their permissions
- if (tCollect.Item2.Users.Count() > 0)
- {
- var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x =>
- new MemberAccessReportAccessDetails
- {
- CollectionId = tCollect.Item1.Id,
- CollectionName = tCollect.Item1.Name,
- ReadOnly = x.ReadOnly,
- HidePasswords = x.HidePasswords,
- Manage = x.Manage,
- ItemCount = itemCounts,
- });
- userCollectionAccessDetails.AddRange(userCollectionDetails);
- }
- }
-
- var report = new MemberAccessReportResponseModel
- {
- UserName = user.Name,
- Email = user.Email,
- TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
- // Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
- AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
- UserGuid = user.Id,
- UsesKeyConnector = user.UsesKeyConnector
- };
-
- var userAccessDetails = new List();
- if (user.Groups.Any())
- {
- var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
- userAccessDetails.AddRange(userGroups);
- }
-
- // There can be edge cases where groups don't have a collection
- var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
- if (groupsWithoutCollections.Count() > 0)
- {
- var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessReportAccessDetails
- {
- GroupId = x,
- GroupName = groupNameDictionary[x],
- ItemCount = 0
- });
- userAccessDetails.AddRange(emptyGroups);
- }
-
- if (user.Collections.Any())
- {
- var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
- userAccessDetails.AddRange(userCollections);
- }
- report.AccessDetails = userAccessDetails;
-
- report.TotalItemCount = collectionItems
- .Where(x => report.AccessDetails.Any(y => x.CollectionId == y.CollectionId))
- .SelectMany(x => x.Ciphers)
- .GroupBy(g => g.CipherId).Select(grp => grp.FirstOrDefault())
- .Count();
-
- // Distinct items only
- var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
- report.CollectionsCount = distinctItems.Count();
- report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
- memberAccessReport.Add(report);
- }
- return memberAccessReport;
+ this.UserName = memberAccessCipherDetails.UserName;
+ this.Email = memberAccessCipherDetails.Email;
+ this.TwoFactorEnabled = memberAccessCipherDetails.TwoFactorEnabled;
+ this.AccountRecoveryEnabled = memberAccessCipherDetails.AccountRecoveryEnabled;
+ this.GroupsCount = memberAccessCipherDetails.GroupsCount;
+ this.CollectionsCount = memberAccessCipherDetails.CollectionsCount;
+ this.TotalItemCount = memberAccessCipherDetails.TotalItemCount;
+ this.UserGuid = memberAccessCipherDetails.UserGuid;
+ this.AccessDetails = memberAccessCipherDetails.AccessDetails;
}
-
}
diff --git a/src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs b/src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs
new file mode 100644
index 000000000..5c87264c5
--- /dev/null
+++ b/src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs
@@ -0,0 +1,24 @@
+using Bit.Core.Tools.Models.Data;
+
+namespace Bit.Api.Tools.Models.Response;
+
+public class MemberCipherDetailsResponseModel
+{
+ public string UserName { get; set; }
+ public string Email { get; set; }
+ public bool UsesKeyConnector { get; set; }
+
+ ///
+ /// A distinct list of the cipher ids associated with
+ /// the organization member
+ ///
+ public IEnumerable CipherIds { get; set; }
+
+ public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
+ {
+ this.UserName = memberAccessCipherDetails.UserName;
+ this.Email = memberAccessCipherDetails.Email;
+ this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;
+ this.CipherIds = memberAccessCipherDetails.CipherIds;
+ }
+}
diff --git a/src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs b/src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs
new file mode 100644
index 000000000..943d56c53
--- /dev/null
+++ b/src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs
@@ -0,0 +1,43 @@
+namespace Bit.Core.Tools.Models.Data;
+
+public class MemberAccessDetails
+{
+ public Guid? CollectionId { get; set; }
+ public Guid? GroupId { get; set; }
+ public string GroupName { get; set; }
+ public string CollectionName { get; set; }
+ public int ItemCount { get; set; }
+ public bool? ReadOnly { get; set; }
+ public bool? HidePasswords { get; set; }
+ public bool? Manage { get; set; }
+
+ ///
+ /// The CipherIds associated with the group/collection access
+ ///
+ public IEnumerable CollectionCipherIds { get; set; }
+}
+
+public class MemberAccessCipherDetails
+{
+ public string UserName { get; set; }
+ public string Email { get; set; }
+ public bool TwoFactorEnabled { get; set; }
+ public bool AccountRecoveryEnabled { get; set; }
+ public int GroupsCount { get; set; }
+ public int CollectionsCount { get; set; }
+ public int TotalItemCount { get; set; }
+ public Guid? UserGuid { get; set; }
+ public bool UsesKeyConnector { get; set; }
+
+ ///
+ /// The details for the member's collection access depending
+ /// on the collections and groups they are assigned to
+ ///
+ public IEnumerable AccessDetails { get; set; }
+
+ ///
+ /// A distinct list of the cipher ids associated with
+ /// the organization member
+ ///
+ public IEnumerable CipherIds { get; set; }
+}
diff --git a/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs b/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs
new file mode 100644
index 000000000..a08359a84
--- /dev/null
+++ b/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs
@@ -0,0 +1,208 @@
+using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
+using Bit.Core.Entities;
+using Bit.Core.Models.Data;
+using Bit.Core.Models.Data.Organizations;
+using Bit.Core.Models.Data.Organizations.OrganizationUsers;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Core.Tools.Models.Data;
+using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
+using Bit.Core.Tools.ReportFeatures.Requests;
+using Bit.Core.Vault.Models.Data;
+using Bit.Core.Vault.Queries;
+using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
+using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
+
+namespace Bit.Core.Tools.ReportFeatures;
+
+public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
+{
+ private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
+ private readonly IGroupRepository _groupRepository;
+ private readonly ICollectionRepository _collectionRepository;
+ private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
+ private readonly IApplicationCacheService _applicationCacheService;
+ private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
+
+ public MemberAccessCipherDetailsQuery(
+ IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
+ IGroupRepository groupRepository,
+ ICollectionRepository collectionRepository,
+ IOrganizationCiphersQuery organizationCiphersQuery,
+ IApplicationCacheService applicationCacheService,
+ ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
+ )
+ {
+ _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
+ _groupRepository = groupRepository;
+ _collectionRepository = collectionRepository;
+ _organizationCiphersQuery = organizationCiphersQuery;
+ _applicationCacheService = applicationCacheService;
+ _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
+ }
+
+ public async Task> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request)
+ {
+ var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
+ new OrganizationUserUserDetailsQueryRequest
+ {
+ OrganizationId = request.OrganizationId,
+ IncludeCollections = true,
+ IncludeGroups = true
+ });
+
+ var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(request.OrganizationId);
+ var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId);
+ var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(request.OrganizationId);
+ var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId);
+ var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
+
+ var memberAccessCipherDetails = GenerateAccessData(
+ orgGroups,
+ orgCollectionsWithAccess,
+ orgItems,
+ organizationUsersTwoFactorEnabled,
+ orgAbility
+ );
+
+ return memberAccessCipherDetails;
+ }
+
+ ///
+ /// Generates a report for all members of an organization. Containing summary information
+ /// such as item, collection, and group counts. Including the cipherIds a member is assigned.
+ /// Child collection includes detailed information on the user and group collections along
+ /// with their permissions.
+ ///
+ /// Organization groups collection
+ /// Collections for the organization and the groups/users and permissions
+ /// Cipher items for the organization with the collections associated with them
+ /// Organization users and two factor status
+ /// Organization ability for account recovery status
+ /// List of the MemberAccessCipherDetailsModel;
+ private IEnumerable GenerateAccessData(
+ ICollection orgGroups,
+ ICollection> orgCollectionsWithAccess,
+ IEnumerable orgItems,
+ IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
+ OrganizationAbility orgAbility)
+ {
+ var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user);
+ // Create a dictionary to lookup the group names later.
+ var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
+
+ // Get collections grouped and into a dictionary for counts
+ var collectionItems = orgItems
+ .SelectMany(x => x.CollectionIds,
+ (cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId })
+ .GroupBy(y => y.CollectionId,
+ (key, ciphers) => new { CollectionId = key, Ciphers = ciphers });
+ var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()));
+
+ // Loop through the org users and populate report and access data
+ var memberAccessCipherDetails = new List();
+ foreach (var user in orgUsers)
+ {
+ var groupAccessDetails = new List();
+ var userCollectionAccessDetails = new List();
+ foreach (var tCollect in orgCollectionsWithAccess)
+ {
+ var hasItems = itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items);
+ var collectionCiphers = hasItems ? items.Select(x => x) : null;
+
+ var itemCounts = hasItems ? collectionCiphers.Count() : 0;
+ if (tCollect.Item2.Groups.Count() > 0)
+ {
+
+ var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
+ new MemberAccessDetails
+ {
+ CollectionId = tCollect.Item1.Id,
+ CollectionName = tCollect.Item1.Name,
+ GroupId = x.Id,
+ GroupName = groupNameDictionary[x.Id],
+ ReadOnly = x.ReadOnly,
+ HidePasswords = x.HidePasswords,
+ Manage = x.Manage,
+ ItemCount = itemCounts,
+ CollectionCipherIds = items
+ });
+
+ groupAccessDetails.AddRange(groupDetails);
+ }
+
+ // All collections assigned to users and their permissions
+ if (tCollect.Item2.Users.Count() > 0)
+ {
+ var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x =>
+ new MemberAccessDetails
+ {
+ CollectionId = tCollect.Item1.Id,
+ CollectionName = tCollect.Item1.Name,
+ ReadOnly = x.ReadOnly,
+ HidePasswords = x.HidePasswords,
+ Manage = x.Manage,
+ ItemCount = itemCounts,
+ CollectionCipherIds = items
+ });
+ userCollectionAccessDetails.AddRange(userCollectionDetails);
+ }
+ }
+
+ var report = new MemberAccessCipherDetails
+ {
+ UserName = user.Name,
+ Email = user.Email,
+ TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
+ // Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
+ AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
+ UserGuid = user.Id,
+ UsesKeyConnector = user.UsesKeyConnector
+ };
+
+ var userAccessDetails = new List();
+ if (user.Groups.Any())
+ {
+ var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
+ userAccessDetails.AddRange(userGroups);
+ }
+
+ // There can be edge cases where groups don't have a collection
+ var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
+ if (groupsWithoutCollections.Count() > 0)
+ {
+ var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails
+ {
+ GroupId = x,
+ GroupName = groupNameDictionary[x],
+ ItemCount = 0
+ });
+ userAccessDetails.AddRange(emptyGroups);
+ }
+
+ if (user.Collections.Any())
+ {
+ var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
+ userAccessDetails.AddRange(userCollections);
+ }
+ report.AccessDetails = userAccessDetails;
+
+ var userCiphers =
+ report.AccessDetails
+ .Where(x => x.ItemCount > 0)
+ .SelectMany(y => y.CollectionCipherIds)
+ .Distinct();
+ report.CipherIds = userCiphers;
+ report.TotalItemCount = userCiphers.Count();
+
+ // Distinct items only
+ var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
+ report.CollectionsCount = distinctItems.Count();
+ report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
+ memberAccessCipherDetails.Add(report);
+ }
+ return memberAccessCipherDetails;
+ }
+}
diff --git a/src/Core/Tools/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs b/src/Core/Tools/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs
new file mode 100644
index 000000000..c55495fd1
--- /dev/null
+++ b/src/Core/Tools/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs
@@ -0,0 +1,9 @@
+using Bit.Core.Tools.Models.Data;
+using Bit.Core.Tools.ReportFeatures.Requests;
+
+namespace Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
+
+public interface IMemberAccessCipherDetailsQuery
+{
+ Task> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request);
+}
diff --git a/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs
new file mode 100644
index 000000000..5c813b8cb
--- /dev/null
+++ b/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs
@@ -0,0 +1,13 @@
+
+using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Bit.Core.Tools.ReportFeatures;
+
+public static class ReportingServiceCollectionExtensions
+{
+ public static void AddReportingServices(this IServiceCollection services)
+ {
+ services.AddScoped();
+ }
+}
diff --git a/src/Core/Tools/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs b/src/Core/Tools/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs
new file mode 100644
index 000000000..395230f43
--- /dev/null
+++ b/src/Core/Tools/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs
@@ -0,0 +1,6 @@
+namespace Bit.Core.Tools.ReportFeatures.Requests;
+
+public class MemberAccessCipherDetailsRequest
+{
+ public Guid OrganizationId { get; set; }
+}
diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
index 5a5585952..1b99b4cc8 100644
--- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
+++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
@@ -34,6 +34,7 @@ using Bit.Core.SecretsManager.Repositories.Noop;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
+using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Bit.Core.Vault;
@@ -116,6 +117,7 @@ public static class ServiceCollectionExtensions
services.AddLoginServices();
services.AddScoped();
services.AddVaultServices();
+ services.AddReportingServices();
}
public static void AddTokenizers(this IServiceCollection services)