mirror of
https://github.com/bitwarden/server
synced 2025-12-28 22:23:30 +00:00
[PM-21031] Optimize GET Members endpoint performance (#5907)
* Add new feature flag for Members Get Endpoint Optimization * Add a new version of OrganizationUser_ReadByOrganizationIdWithClaimedDomains that uses CTE for better performance * Add stored procedure OrganizationUserUserDetails_ReadByOrganizationId_V2 for retrieving user details, group associations, and collection associations by organization ID. * Add the sql migration script to add the new stored procedures * Introduce GetManyDetailsByOrganizationAsync_vNext and GetManyByOrganizationWithClaimedDomainsAsync_vNext in IOrganizationUserRepository to enhance performance by reducing database round trips. * Updated GetOrganizationUsersClaimedStatusQuery to use an optimized query when the feature flag is enabled * Updated OrganizationUserUserDetailsQuery to use optimized queries when the feature flag is enabled * Add integration tests for GetManyDetailsByOrganizationAsync_vNext * Add integration tests for GetManyByOrganizationWithClaimedDomainsAsync_vNext to validate behavior with verified and unverified domains. * Optimize performance by conditionally setting permissions only for Custom user types in OrganizationUserUserDetailsQuery. * Create UserEmailDomainView to extract email domains from users' email addresses * Create stored procedure Organization_ReadByClaimedUserEmailDomain_V2 that uses UserEmailDomainView to fetch Email domains * Add GetByVerifiedUserEmailDomainAsync_vNext method to IOrganizationRepository and its implementations * Refactor OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2 stored procedure to use UserEmailDomainView for email domain extraction, improving query efficiency and clarity. * Enhance IOrganizationUserRepository with detailed documentation for GetManyDetailsByOrganizationAsync method, clarifying its purpose and performance optimizations. Added remarks for better understanding of its functionality. * Fix missing newline at the end of Organization_ReadByClaimedUserEmailDomain_V2.sql to adhere to coding standards. * Update the database migration script to include UserEmailDomainView * Bumped the date on the migration script * Remove GetByVerifiedUserEmailDomainAsync_vNext method and its stored procedure. * Refactor UserEmailDomainView index creation to check for existence before creation * Update OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2 to use CTE and add indexes * Remove creation of unique clustered index from UserEmailDomainView and related migration script adjustments * Update indexes and sproc * Fix index name when checking if it already exists * Bump up date on migration script
This commit is contained in:
@@ -8,13 +8,16 @@ public class GetOrganizationUsersClaimedStatusQuery : IGetOrganizationUsersClaim
|
||||
{
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public GetOrganizationUsersClaimedStatusQuery(
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IOrganizationUserRepository organizationUserRepository)
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
public async Task<IDictionary<Guid, bool>> GetUsersOrganizationClaimedStatusAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds)
|
||||
@@ -27,7 +30,9 @@ public class GetOrganizationUsersClaimedStatusQuery : IGetOrganizationUsersClaim
|
||||
if (organizationAbility is { Enabled: true, UseOrganizationDomains: true })
|
||||
{
|
||||
// Get all organization users with claimed domains by the organization
|
||||
var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId);
|
||||
var organizationUsersWithClaimedDomain = _featureService.IsEnabled(FeatureFlagKeys.MembersGetEndpointOptimization)
|
||||
? await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync_vNext(organizationId)
|
||||
: await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId);
|
||||
|
||||
// Create a dictionary with the OrganizationUserId and a boolean indicating if the user is claimed by the organization
|
||||
return organizationUserIds.ToDictionary(ouId => ouId, ouId => organizationUsersWithClaimedDomain.Any(ou => ou.Id == ouId));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
@@ -43,9 +44,12 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer
|
||||
return organizationUsers
|
||||
.Select(o =>
|
||||
{
|
||||
var userPermissions = o.GetPermissions();
|
||||
|
||||
o.Permissions = CoreHelpers.ClassToJsonData(userPermissions);
|
||||
// Only set permissions for Custom user types for performance optimization
|
||||
if (o.Type == OrganizationUserType.Custom)
|
||||
{
|
||||
var userPermissions = o.GetPermissions();
|
||||
o.Permissions = CoreHelpers.ClassToJsonData(userPermissions);
|
||||
}
|
||||
|
||||
return o;
|
||||
});
|
||||
@@ -59,6 +63,11 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer
|
||||
/// <returns>List of OrganizationUserUserDetails</returns>
|
||||
public async Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> Get(OrganizationUserUserDetailsQueryRequest request)
|
||||
{
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.MembersGetEndpointOptimization))
|
||||
{
|
||||
return await Get_vNext(request);
|
||||
}
|
||||
|
||||
var organizationUsers = await GetOrganizationUserUserDetails(request);
|
||||
|
||||
var organizationUsersTwoFactorEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers)).ToDictionary(u => u.user.Id);
|
||||
@@ -77,6 +86,11 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer
|
||||
/// <returns>List of OrganizationUserUserDetails</returns>
|
||||
public async Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> GetAccountRecoveryEnrolledUsers(OrganizationUserUserDetailsQueryRequest request)
|
||||
{
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.MembersGetEndpointOptimization))
|
||||
{
|
||||
return await GetAccountRecoveryEnrolledUsers_vNext(request);
|
||||
}
|
||||
|
||||
var organizationUsers = (await GetOrganizationUserUserDetails(request))
|
||||
.Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey));
|
||||
|
||||
@@ -88,4 +102,65 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer
|
||||
return responses;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> Get_vNext(OrganizationUserUserDetailsQueryRequest request)
|
||||
{
|
||||
var organizationUsers = await _organizationUserRepository
|
||||
.GetManyDetailsByOrganizationAsync_vNext(request.OrganizationId, request.IncludeGroups, request.IncludeCollections);
|
||||
|
||||
var twoFactorTask = _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
|
||||
var claimedStatusTask = _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id));
|
||||
|
||||
await Task.WhenAll(twoFactorTask, claimedStatusTask);
|
||||
|
||||
var organizationUsersTwoFactorEnabled = twoFactorTask.Result.ToDictionary(u => u.user.Id, u => u.twoFactorIsEnabled);
|
||||
var organizationUsersClaimedStatus = claimedStatusTask.Result;
|
||||
var responses = organizationUsers.Select(organizationUserDetails =>
|
||||
{
|
||||
// Only set permissions for Custom user types for performance optimization
|
||||
if (organizationUserDetails.Type == OrganizationUserType.Custom)
|
||||
{
|
||||
var organizationUserPermissions = organizationUserDetails.GetPermissions();
|
||||
organizationUserDetails.Permissions = CoreHelpers.ClassToJsonData(organizationUserPermissions);
|
||||
}
|
||||
|
||||
var userHasTwoFactorEnabled = organizationUsersTwoFactorEnabled[organizationUserDetails.Id];
|
||||
var userIsClaimedByOrganization = organizationUsersClaimedStatus[organizationUserDetails.Id];
|
||||
|
||||
return (organizationUserDetails, userHasTwoFactorEnabled, userIsClaimedByOrganization);
|
||||
});
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> GetAccountRecoveryEnrolledUsers_vNext(OrganizationUserUserDetailsQueryRequest request)
|
||||
{
|
||||
var organizationUsers = (await _organizationUserRepository
|
||||
.GetManyDetailsByOrganizationAsync_vNext(request.OrganizationId, request.IncludeGroups, request.IncludeCollections))
|
||||
.Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey))
|
||||
.ToArray();
|
||||
|
||||
var twoFactorTask = _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
|
||||
var claimedStatusTask = _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id));
|
||||
|
||||
await Task.WhenAll(twoFactorTask, claimedStatusTask);
|
||||
|
||||
var organizationUsersTwoFactorEnabled = twoFactorTask.Result.ToDictionary(u => u.user.Id, u => u.twoFactorIsEnabled);
|
||||
var organizationUsersClaimedStatus = claimedStatusTask.Result;
|
||||
var responses = organizationUsers.Select(organizationUserDetails =>
|
||||
{
|
||||
// Only set permissions for Custom user types for performance optimization
|
||||
if (organizationUserDetails.Type == OrganizationUserType.Custom)
|
||||
{
|
||||
var organizationUserPermissions = organizationUserDetails.GetPermissions();
|
||||
organizationUserDetails.Permissions = CoreHelpers.ClassToJsonData(organizationUserPermissions);
|
||||
}
|
||||
|
||||
var userHasTwoFactorEnabled = organizationUsersTwoFactorEnabled[organizationUserDetails.Id];
|
||||
var userIsClaimedByOrganization = organizationUsersClaimedStatus[organizationUserDetails.Id];
|
||||
|
||||
return (organizationUserDetails, userHasTwoFactorEnabled, userIsClaimedByOrganization);
|
||||
});
|
||||
|
||||
return responses;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
|
||||
/// <param name="includeCollections">Whether to include collections</param>
|
||||
/// <returns>A list of OrganizationUserUserDetails</returns>
|
||||
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
|
||||
/// <inheritdoc cref="GetManyDetailsByOrganizationAsync"/>
|
||||
/// <remarks>
|
||||
/// This method is optimized for performance.
|
||||
/// Reduces database round trips by fetching all data in fewer queries.
|
||||
/// </remarks>
|
||||
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
|
||||
Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
|
||||
OrganizationUserStatusType? status = null);
|
||||
Task<OrganizationUserOrganizationDetails?> GetDetailsByUserAsync(Guid userId, Guid organizationId,
|
||||
@@ -70,7 +76,10 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
|
||||
/// Returns a list of OrganizationUsers with email domains that match one of the Organization's claimed domains.
|
||||
/// </summary>
|
||||
Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId);
|
||||
|
||||
/// <summary>
|
||||
/// Optimized version of <see cref="GetManyByOrganizationWithClaimedDomainsAsync"/> with better performance.
|
||||
/// </summary>
|
||||
Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync_vNext(Guid organizationId);
|
||||
Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds);
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user