1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-17 15:40:59 -05:00

[PM-20112] Member access stored proc and splitting the query (#5943)

This commit is contained in:
Tom
2025-06-16 17:32:36 -04:00
committed by GitHub
parent 66d1c70dc6
commit b8244908ec
32 changed files with 10480 additions and 347 deletions

View File

@ -0,0 +1,19 @@
namespace Bit.Core.Dirt.Reports.Models.Data;
public class MemberAccessReportDetail
{
public Guid? UserGuid { get; set; }
public string UserName { get; set; }
public string Email { get; set; }
public bool TwoFactorEnabled { get; set; }
public bool AccountRecoveryEnabled { get; set; }
public bool UsesKeyConnector { get; set; }
public Guid? CollectionId { get; set; }
public Guid? GroupId { get; set; }
public string GroupName { get; set; }
public string CollectionName { get; set; }
public bool? ReadOnly { get; set; }
public bool? HidePasswords { get; set; }
public bool? Manage { get; set; }
public IEnumerable<Guid> CipherIds { get; set; }
}

View File

@ -0,0 +1,19 @@
namespace Bit.Core.Dirt.Reports.Models.Data;
public class OrganizationMemberBaseDetail
{
public Guid? UserGuid { get; set; }
public string UserName { get; set; }
public string Email { get; set; }
public string TwoFactorProviders { get; set; }
public bool UsesKeyConnector { get; set; }
public string ResetPasswordKey { get; set; }
public Guid? CollectionId { get; set; }
public Guid? GroupId { get; set; }
public string GroupName { get; set; }
public string CollectionName { get; set; }
public bool? ReadOnly { get; set; }
public bool? HidePasswords { get; set; }
public bool? Manage { get; set; }
public Guid CipherId { get; set; }
}

View File

@ -0,0 +1,10 @@
namespace Bit.Core.Dirt.Reports.Models.Data;
public class RiskInsightsReportDetail
{
public Guid? UserGuid { get; set; }
public string UserName { get; set; }
public string Email { get; set; }
public bool UsesKeyConnector { get; set; }
public IEnumerable<string> CipherIds { get; set; }
}

View File

@ -1,206 +0,0 @@
using System.Collections.Concurrent;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Dirt.Reports.Models.Data;
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
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.Vault.Models.Data;
using Bit.Core.Vault.Queries;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
namespace Bit.Core.Dirt.Reports.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<IEnumerable<MemberAccessCipherDetails>> 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 = GenerateAccessDataParallel(
orgGroups,
orgCollectionsWithAccess,
orgItems,
organizationUsersTwoFactorEnabled,
orgAbility);
return memberAccessCipherDetails;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="orgGroups">Organization groups collection</param>
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
/// <param name="orgItems">Cipher items for the organization with the collections associated with them</param>
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
/// <param name="orgAbility">Organization ability for account recovery status</param>
/// <returns>List of the MemberAccessCipherDetailsModel</returns>;
private IEnumerable<MemberAccessCipherDetails> GenerateAccessDataParallel(
ICollection<Group> orgGroups,
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
IEnumerable<CipherOrganizationDetailsWithCollections> 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<MemberAccessCipherDetails>();
Parallel.ForEach(orgUsers, user =>
{
var groupAccessDetails = new List<MemberAccessDetails>();
var userCollectionAccessDetails = new List<MemberAccessDetails>();
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<MemberAccessDetails>();
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;
}
}

View File

@ -0,0 +1,65 @@
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Dirt.Reports.Models.Data;
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
using Bit.Core.Dirt.Reports.Repositories;
using Bit.Core.Services;
namespace Bit.Core.Dirt.Reports.ReportFeatures;
public class MemberAccessReportQuery(
IOrganizationMemberBaseDetailRepository organizationMemberBaseDetailRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IApplicationCacheService applicationCacheService)
: IMemberAccessReportQuery
{
public async Task<IEnumerable<MemberAccessReportDetail>> GetMemberAccessReportsAsync(
MemberAccessReportRequest request)
{
var baseDetails =
await organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId(
request.OrganizationId);
var orgUsers = baseDetails.Select(x => x.UserGuid.GetValueOrDefault()).Distinct();
var orgUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId);
var accessDetails = baseDetails
.GroupBy(b => new
{
b.UserGuid,
b.UserName,
b.Email,
b.TwoFactorProviders,
b.ResetPasswordKey,
b.UsesKeyConnector,
b.GroupId,
b.GroupName,
b.CollectionId,
b.CollectionName,
b.ReadOnly,
b.HidePasswords,
b.Manage
})
.Select(g => new MemberAccessReportDetail
{
UserGuid = g.Key.UserGuid,
UserName = g.Key.UserName,
Email = g.Key.Email,
TwoFactorEnabled = orgUsersTwoFactorEnabled.FirstOrDefault(x => x.userId == g.Key.UserGuid).twoFactorIsEnabled,
AccountRecoveryEnabled = !string.IsNullOrWhiteSpace(g.Key.ResetPasswordKey) && orgAbility.UseResetPassword,
UsesKeyConnector = g.Key.UsesKeyConnector,
GroupId = g.Key.GroupId,
GroupName = g.Key.GroupName,
CollectionId = g.Key.CollectionId,
CollectionName = g.Key.CollectionName,
ReadOnly = g.Key.ReadOnly,
HidePasswords = g.Key.HidePasswords,
Manage = g.Key.Manage,
CipherIds = g.Select(c => c.CipherId)
});
return accessDetails;
}
}

View File

@ -3,7 +3,7 @@ using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
public interface IMemberAccessCipherDetailsQuery
public interface IMemberAccessReportQuery
{
Task<IEnumerable<MemberAccessCipherDetails>> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request);
Task<IEnumerable<MemberAccessReportDetail>> GetMemberAccessReportsAsync(MemberAccessReportRequest request);
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Dirt.Reports.Models.Data;
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
public interface IRiskInsightsReportQuery
{
Task<IEnumerable<RiskInsightsReportDetail>> GetRiskInsightsReportDetails(RiskInsightsReportRequest request);
}

View File

@ -8,7 +8,8 @@ public static class ReportingServiceCollectionExtensions
{
public static void AddReportingServices(this IServiceCollection services)
{
services.AddScoped<IMemberAccessCipherDetailsQuery, MemberAccessCipherDetailsQuery>();
services.AddScoped<IRiskInsightsReportQuery, RiskInsightsReportQuery>();
services.AddScoped<IMemberAccessReportQuery, MemberAccessReportQuery>();
services.AddScoped<IAddPasswordHealthReportApplicationCommand, AddPasswordHealthReportApplicationCommand>();
services.AddScoped<IGetPasswordHealthReportApplicationQuery, GetPasswordHealthReportApplicationQuery>();
services.AddScoped<IDropPasswordHealthReportApplicationCommand, DropPasswordHealthReportApplicationCommand>();

View File

@ -1,6 +1,6 @@
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
public class MemberAccessCipherDetailsRequest
public class MemberAccessReportRequest
{
public Guid OrganizationId { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
public class RiskInsightsReportRequest
{
public Guid OrganizationId { get; set; }
}

View File

@ -0,0 +1,39 @@
using Bit.Core.Dirt.Reports.Models.Data;
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
using Bit.Core.Dirt.Reports.Repositories;
namespace Bit.Core.Dirt.Reports.ReportFeatures;
public class RiskInsightsReportQuery : IRiskInsightsReportQuery
{
private readonly IOrganizationMemberBaseDetailRepository _organizationMemberBaseDetailRepository;
public RiskInsightsReportQuery(IOrganizationMemberBaseDetailRepository repository)
{
_organizationMemberBaseDetailRepository = repository;
}
public async Task<IEnumerable<RiskInsightsReportDetail>> GetRiskInsightsReportDetails(
RiskInsightsReportRequest request)
{
var baseDetails =
await _organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId(
request.OrganizationId);
var insightsDetails = baseDetails
.GroupBy(b => new { b.UserGuid, b.UserName, b.Email, b.UsesKeyConnector })
.Select(g => new RiskInsightsReportDetail
{
UserGuid = g.Key.UserGuid,
UserName = g.Key.UserName,
Email = g.Key.Email,
UsesKeyConnector = g.Key.UsesKeyConnector,
CipherIds = g
.Select(x => x.CipherId.ToString())
.Distinct()
});
return insightsDetails;
}
}

View File

@ -0,0 +1,8 @@
using Bit.Core.Dirt.Reports.Models.Data;
namespace Bit.Core.Dirt.Reports.Repositories;
public interface IOrganizationMemberBaseDetailRepository
{
Task<IEnumerable<OrganizationMemberBaseDetail>> GetOrganizationMemberBaseDetailsByOrganizationId(Guid organizationId);
}