diff --git a/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs b/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs index a08359a84f..bfb3b4da60 100644 --- a/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs +++ b/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Collections.Concurrent; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Entities; @@ -59,22 +60,30 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); - var memberAccessCipherDetails = GenerateAccessData( + // Keep this original method for reference and comparison. Until we are confident in the parallel version. + // var memberAccessCipherDetails = GenerateAccessData( + // orgGroups, + // orgCollectionsWithAccess, + // orgItems, + // organizationUsersTwoFactorEnabled, + // orgAbility + // ); + + var memberAccessCipherDetails = GenerateAccessDataParallel( orgGroups, orgCollectionsWithAccess, orgItems, organizationUsersTwoFactorEnabled, - orgAbility - ); + 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. + /// 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. + /// with their permissions. /// /// Organization groups collection /// Collections for the organization and the groups/users and permissions @@ -90,7 +99,7 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery OrganizationAbility orgAbility) { var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user); - // Create a dictionary to lookup the group names later. + // 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 @@ -197,7 +206,7 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery report.CipherIds = userCiphers; report.TotalItemCount = userCiphers.Count(); - // Distinct items only + // 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(); @@ -205,4 +214,135 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery } return memberAccessCipherDetails; } + + /// + /// Similar to the GenerateAccessData method, but processes things in parallel. + /// + /// 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 GenerateAccessDataParallel( + ICollection orgGroups, + ICollection> orgCollectionsWithAccess, + IEnumerable orgItems, + IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled, + OrganizationAbility orgAbility) + { + var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user).ToList(); + var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name); + 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()).ToList()); + + var memberAccessCipherDetails = new ConcurrentBag(); + + Parallel.ForEach(orgUsers, user => + { + var groupAccessDetails = new List(); + var userCollectionAccessDetails = new List(); + + foreach (var tCollect in orgCollectionsWithAccess) + { + if (itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items)) + { + var itemCounts = items.Count; + + if (tCollect.Item2.Groups.Any()) + { + 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); + } + + if (tCollect.Item2.Users.Any()) + { + 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, + 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); + } + + var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId)); + if (groupsWithoutCollections.Any()) + { + 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(); + + 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; + } }