diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs index d8c510119ae6..b27da2a22efa 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs @@ -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> GetUsersOrganizationClaimedStatusAsync(Guid organizationId, IEnumerable organizationUserIds) @@ -27,7 +30,9 @@ public async Task> GetUsersOrganizationClaimedStatusAsyn 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)); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs index 587e04826b6a..aa2cd2df8f61 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs @@ -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 async Task> GetOrganizationUserU 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 async Task> GetOrganizationUserU /// List of OrganizationUserUserDetails public async Task> 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 async Task> GetOrganizationUserU /// List of OrganizationUserUserDetails public async Task> 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 async Task> GetOrganizationUserU return responses; } + private async Task> 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> 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; + } } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index cbdf3913cc4f..7187ab50a693 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -36,6 +36,12 @@ public interface IOrganizationUserRepository : IRepositoryWhether to include collections /// A list of OrganizationUserUserDetails Task> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false); + /// + /// + /// This method is optimized for performance. + /// Reduces database round trips by fetching all data in fewer queries. + /// + Task> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups = false, bool includeCollections = false); Task> GetManyDetailsByUserAsync(Guid userId, OrganizationUserStatusType? status = null); Task GetDetailsByUserAsync(Guid userId, Guid organizationId, @@ -70,7 +76,10 @@ UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, /// Returns a list of OrganizationUsers with email domains that match one of the Organization's claimed domains. /// Task> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId); - + /// + /// Optimized version of with better performance. + /// + Task> GetManyByOrganizationWithClaimedDomainsAsync_vNext(Guid organizationId); Task RevokeManyByIdAsync(IEnumerable organizationUserIds); /// diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 539ff6d9772b..f040dcc9e8eb 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -115,6 +115,7 @@ public static class FeatureFlagKeys public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string ImportAsyncRefactor = "pm-22583-refactor-import-async"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; + public const string MembersGetEndpointOptimization = "pm-21031-members-get-endpoint-optimization"; /* Auth Team */ public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index feecf4a5d140..2b9298a75a4d 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -268,6 +268,68 @@ public async Task> GetManyDetailsByOrga } } + public async Task> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups, bool includeCollections) + { + using (var connection = new SqlConnection(ConnectionString)) + { + // Use a single call that returns multiple result sets + var results = await connection.QueryMultipleAsync( + "[dbo].[OrganizationUserUserDetails_ReadByOrganizationId_V2]", + new + { + OrganizationId = organizationId, + IncludeGroups = includeGroups, + IncludeCollections = includeCollections + }, + commandType: CommandType.StoredProcedure); + + // Read the user details (first result set) + var users = (await results.ReadAsync()).ToList(); + + // Read group associations (second result set, if requested) + Dictionary>? userGroupMap = null; + if (includeGroups) + { + var groupUsers = await results.ReadAsync(); + userGroupMap = groupUsers + .GroupBy(gu => gu.OrganizationUserId) + .ToDictionary(g => g.Key, g => g.Select(gu => gu.GroupId).ToList()); + } + + // Read collection associations (third result set, if requested) + Dictionary>? userCollectionMap = null; + if (includeCollections) + { + var collectionUsers = await results.ReadAsync(); + userCollectionMap = collectionUsers + .GroupBy(cu => cu.OrganizationUserId) + .ToDictionary(g => g.Key, g => g.Select(cu => new CollectionAccessSelection + { + Id = cu.CollectionId, + ReadOnly = cu.ReadOnly, + HidePasswords = cu.HidePasswords, + Manage = cu.Manage + }).ToList()); + } + + // Map the associations to users + foreach (var user in users) + { + if (userGroupMap != null) + { + user.Groups = userGroupMap.GetValueOrDefault(user.Id, new List()); + } + + if (userCollectionMap != null) + { + user.Collections = userCollectionMap.GetValueOrDefault(user.Id, new List()); + } + } + + return users; + } + } + public async Task> GetManyDetailsByUserAsync(Guid userId, OrganizationUserStatusType? status = null) { @@ -558,6 +620,19 @@ public async Task> GetManyByOrganizationWithClaime } } + public async Task> GetManyByOrganizationWithClaimedDomainsAsync_vNext(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + public async Task RevokeManyByIdAsync(IEnumerable organizationUserIds) { await using var connection = new SqlConnection(ConnectionString); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index bb392a2e6060..a6bbf8e6e063 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -404,6 +404,56 @@ join c in dbContext.Collections on cu.CollectionId equals c.Id } } + public async Task> GetManyDetailsByOrganizationAsync_vNext( + Guid organizationId, bool includeGroups, bool includeCollections) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var query = from ou in dbContext.OrganizationUsers + where ou.OrganizationId == organizationId + select new OrganizationUserUserDetails + { + Id = ou.Id, + UserId = ou.UserId, + OrganizationId = ou.OrganizationId, + Name = ou.User.Name, + Email = ou.User.Email ?? ou.Email, + AvatarColor = ou.User.AvatarColor, + TwoFactorProviders = ou.User.TwoFactorProviders, + Premium = ou.User.Premium, + Status = ou.Status, + Type = ou.Type, + ExternalId = ou.ExternalId, + SsoExternalId = ou.User.SsoUsers + .Where(su => su.OrganizationId == ou.OrganizationId) + .Select(su => su.ExternalId) + .FirstOrDefault(), + Permissions = ou.Permissions, + ResetPasswordKey = ou.ResetPasswordKey, + UsesKeyConnector = ou.User != null && ou.User.UsesKeyConnector, + AccessSecretsManager = ou.AccessSecretsManager, + HasMasterPassword = ou.User != null && !string.IsNullOrWhiteSpace(ou.User.MasterPassword), + + // Project directly from navigation properties with conditional loading + Groups = includeGroups + ? ou.GroupUsers.Select(gu => gu.GroupId).ToList() + : new List(), + + Collections = includeCollections + ? ou.CollectionUsers.Select(cu => new CollectionAccessSelection + { + Id = cu.CollectionId, + ReadOnly = cu.ReadOnly, + HidePasswords = cu.HidePasswords, + Manage = cu.Manage + }).ToList() + : new List() + }; + + return await query.ToListAsync(); + } + public async Task> GetManyDetailsByUserAsync(Guid userId, OrganizationUserStatusType? status = null) { @@ -732,6 +782,12 @@ public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation( } } + public async Task> GetManyByOrganizationWithClaimedDomainsAsync_vNext(Guid organizationId) + { + // No EF optimization is required for this query + return await GetManyByOrganizationWithClaimedDomainsAsync(organizationId); + } + public async Task RevokeManyByIdAsync(IEnumerable organizationUserIds) { using var scope = ServiceScopeFactory.CreateScope(); diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadByOrganizationId_V2.sql b/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadByOrganizationId_V2.sql new file mode 100644 index 000000000000..6bf32089c257 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadByOrganizationId_V2.sql @@ -0,0 +1,31 @@ +CREATE PROCEDURE [dbo].[OrganizationUserUserDetails_ReadByOrganizationId_V2] + @OrganizationId UNIQUEIDENTIFIER, + @IncludeGroups BIT = 0, + @IncludeCollections BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + -- Result Set 1: User Details (always returned) + SELECT * + FROM [dbo].[OrganizationUserUserDetailsView] + WHERE OrganizationId = @OrganizationId + + -- Result Set 2: Group associations (if requested) + IF @IncludeGroups = 1 + BEGIN + SELECT gu.* + FROM [dbo].[GroupUser] gu + INNER JOIN [dbo].[OrganizationUser] ou ON gu.OrganizationUserId = ou.Id + WHERE ou.OrganizationId = @OrganizationId + END + + -- Result Set 3: Collection associations (if requested) + IF @IncludeCollections = 1 + BEGIN + SELECT cu.* + FROM [dbo].[CollectionUser] cu + INNER JOIN [dbo].[OrganizationUser] ou ON cu.OrganizationUserId = ou.Id + WHERE ou.OrganizationId = @OrganizationId + END +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql new file mode 100644 index 000000000000..64f3d81e08f0 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql @@ -0,0 +1,27 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON; + + WITH OrgUsers AS ( + SELECT * + FROM [dbo].[OrganizationUserView] + WHERE [OrganizationId] = @OrganizationId + ), + UserDomains AS ( + SELECT U.[Id], U.[EmailDomain] + FROM [dbo].[UserEmailDomainView] U + WHERE EXISTS ( + SELECT 1 + FROM [dbo].[OrganizationDomainView] OD + WHERE OD.[OrganizationId] = @OrganizationId + AND OD.[VerifiedDate] IS NOT NULL + AND OD.[DomainName] = U.[EmailDomain] + ) + ) + SELECT OU.* + FROM OrgUsers OU + JOIN UserDomains UD ON OU.[UserId] = UD.[Id] + OPTION (RECOMPILE); +END diff --git a/src/Sql/dbo/Tables/OrganizationDomain.sql b/src/Sql/dbo/Tables/OrganizationDomain.sql index 615dcc1557c4..582029acfeca 100644 --- a/src/Sql/dbo/Tables/OrganizationDomain.sql +++ b/src/Sql/dbo/Tables/OrganizationDomain.sql @@ -25,5 +25,11 @@ GO CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_DomainNameVerifiedDateOrganizationId] ON [dbo].[OrganizationDomain] ([DomainName],[VerifiedDate]) - INCLUDE ([OrganizationId]) + INCLUDE ([OrganizationId]); +GO + +CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_OrganizationId_VerifiedDate] + ON [dbo].[OrganizationDomain] ([OrganizationId], [VerifiedDate]) + INCLUDE ([DomainName]) + WHERE [VerifiedDate] IS NOT NULL; GO diff --git a/src/Sql/dbo/Tables/OrganizationUser.sql b/src/Sql/dbo/Tables/OrganizationUser.sql index 331e85fe6371..513a5f66961e 100644 --- a/src/Sql/dbo/Tables/OrganizationUser.sql +++ b/src/Sql/dbo/Tables/OrganizationUser.sql @@ -27,3 +27,10 @@ GO CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId] ON [dbo].[OrganizationUser]([OrganizationId] ASC); +GO + +CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId_UserId] + ON [dbo].[OrganizationUser] ([OrganizationId], [UserId]) + INCLUDE ([Email], [Status], [Type], [ExternalId], [CreationDate], + [RevisionDate], [Permissions], [ResetPasswordKey], [AccessSecretsManager]); +GO diff --git a/src/Sql/dbo/Tables/User.sql b/src/Sql/dbo/Tables/User.sql index 188dd4ea3c73..239ee67f1109 100644 --- a/src/Sql/dbo/Tables/User.sql +++ b/src/Sql/dbo/Tables/User.sql @@ -54,3 +54,7 @@ GO CREATE NONCLUSTERED INDEX [IX_User_Premium_PremiumExpirationDate_RenewalReminderDate] ON [dbo].[User]([Premium] ASC, [PremiumExpirationDate] ASC, [RenewalReminderDate] ASC); +GO +CREATE NONCLUSTERED INDEX [IX_User_Id_EmailDomain] + ON [dbo].[User]([Id] ASC, [Email] ASC); + diff --git a/src/Sql/dbo/Views/UserEmailDomainView.sql b/src/Sql/dbo/Views/UserEmailDomainView.sql new file mode 100644 index 000000000000..84930a41f1d3 --- /dev/null +++ b/src/Sql/dbo/Views/UserEmailDomainView.sql @@ -0,0 +1,10 @@ +CREATE VIEW [dbo].[UserEmailDomainView] +AS +SELECT + Id, + Email, + SUBSTRING(Email, CHARINDEX('@', Email) + 1, LEN(Email)) AS EmailDomain +FROM dbo.[User] +WHERE Email IS NOT NULL + AND CHARINDEX('@', Email) > 0 +GO diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs index 6919ce7bceeb..8eec87879422 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs @@ -142,18 +142,24 @@ public async Task GetManyAccountRecoveryDetailsByOrganizationUserAsync_Works(IUs var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser { + Id = CoreHelpers.GenerateComb(), OrganizationId = organization.Id, UserId = user1.Id, Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, ResetPasswordKey = "resetpasswordkey1", + AccessSecretsManager = false }); var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser { + Id = CoreHelpers.GenerateComb(), OrganizationId = organization.Id, UserId = user2.Id, - Status = OrganizationUserStatusType.Confirmed, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, ResetPasswordKey = "resetpasswordkey2", + AccessSecretsManager = true }); var recoveryDetails = await organizationUserRepository.GetManyAccountRecoveryDetailsByOrganizationUserAsync( @@ -292,10 +298,13 @@ public async Task GetManyDetailsByUserAsync_Works(IUserRepository userRepository var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser { + Id = CoreHelpers.GenerateComb(), OrganizationId = organization.Id, UserId = user1.Id, Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, ResetPasswordKey = "resetpasswordkey1", + AccessSecretsManager = false }); var responseModel = await organizationUserRepository.GetManyDetailsByUserAsync(user1.Id); @@ -435,27 +444,35 @@ public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithVerifiedDomai var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser { + Id = CoreHelpers.GenerateComb(), OrganizationId = organization.Id, UserId = user1.Id, Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, ResetPasswordKey = "resetpasswordkey1", AccessSecretsManager = false }); await organizationUserRepository.CreateAsync(new OrganizationUser { + Id = CoreHelpers.GenerateComb(), OrganizationId = organization.Id, UserId = user2.Id, Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, ResetPasswordKey = "resetpasswordkey1", + AccessSecretsManager = false }); await organizationUserRepository.CreateAsync(new OrganizationUser { + Id = CoreHelpers.GenerateComb(), OrganizationId = organization.Id, UserId = user3.Id, Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, ResetPasswordKey = "resetpasswordkey1", + AccessSecretsManager = false }); var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id); @@ -724,4 +741,410 @@ public async Task CreateManyAsync_WithCollectionAndGroup_SaveSuccessfully( Assert.Equal(collection3.Id, orgUser3.Collections.First().Id); Assert.Equal(group3.Id, group3Database.First()); } + + [DatabaseTheory, DatabaseData] + public async Task GetManyDetailsByOrganizationAsync_vNext_WithoutGroupsAndCollections_ReturnsBasicUserDetails( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository) + { + var id = Guid.NewGuid(); + + var user1 = await userRepository.CreateAsync(new User + { + Id = CoreHelpers.GenerateComb(), + Name = "Test User 1", + Email = $"test1+{id}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var user2 = await userRepository.CreateAsync(new User + { + Id = CoreHelpers.GenerateComb(), + Name = "Test User 2", + Email = $"test2+{id}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.Argon2id, + KdfIterations = 4, + KdfMemory = 5, + KdfParallelism = 6 + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Id = CoreHelpers.GenerateComb(), + Name = $"Test Org {id}", + BillingEmail = user1.Email, + Plan = "Test", + PrivateKey = "privatekey", + PublicKey = "publickey", + UseGroups = true, + Enabled = true, + UsePasswordManager = true + }); + + var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + ResetPasswordKey = "resetpasswordkey1", + AccessSecretsManager = false + }); + + var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = user2.Id, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, + ResetPasswordKey = "resetpasswordkey2", + AccessSecretsManager = true + }); + + var responseModel = await organizationUserRepository.GetManyDetailsByOrganizationAsync_vNext(organization.Id, includeGroups: false, includeCollections: false); + + Assert.NotNull(responseModel); + Assert.Equal(2, responseModel.Count); + + var user1Result = responseModel.FirstOrDefault(u => u.Id == orgUser1.Id); + Assert.NotNull(user1Result); + Assert.Equal(user1.Name, user1Result.Name); + Assert.Equal(user1.Email, user1Result.Email); + Assert.Equal(orgUser1.Status, user1Result.Status); + Assert.Equal(orgUser1.Type, user1Result.Type); + Assert.Equal(organization.Id, user1Result.OrganizationId); + Assert.Equal(user1.Id, user1Result.UserId); + Assert.Empty(user1Result.Groups); + Assert.Empty(user1Result.Collections); + + var user2Result = responseModel.FirstOrDefault(u => u.Id == orgUser2.Id); + Assert.NotNull(user2Result); + Assert.Equal(user2.Name, user2Result.Name); + Assert.Equal(user2.Email, user2Result.Email); + Assert.Equal(orgUser2.Status, user2Result.Status); + Assert.Equal(orgUser2.Type, user2Result.Type); + Assert.Equal(organization.Id, user2Result.OrganizationId); + Assert.Equal(user2.Id, user2Result.UserId); + Assert.Empty(user2Result.Groups); + Assert.Empty(user2Result.Collections); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManyDetailsByOrganizationAsync_vNext_WithGroupsAndCollections_ReturnsUserDetailsWithBoth( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IGroupRepository groupRepository, + ICollectionRepository collectionRepository) + { + var id = Guid.NewGuid(); + var requestTime = DateTime.UtcNow; + + var user1 = await userRepository.CreateAsync(new User + { + Id = CoreHelpers.GenerateComb(), + Name = "Test User 1", + Email = $"test1+{id}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Id = CoreHelpers.GenerateComb(), + Name = $"Test Org {id}", + BillingEmail = user1.Email, + Plan = "Test", + PrivateKey = "privatekey", + PublicKey = "publickey", + UseGroups = true, + Enabled = true + }); + + var group1 = await groupRepository.CreateAsync(new Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Group 1", + ExternalId = "external-group-1" + }); + + var group2 = await groupRepository.CreateAsync(new Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Group 2", + ExternalId = "external-group-2" + }); + + var collection1 = await collectionRepository.CreateAsync(new Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Collection 1", + ExternalId = "external-collection-1", + CreationDate = requestTime, + RevisionDate = requestTime + }); + + var collection2 = await collectionRepository.CreateAsync(new Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Collection 2", + ExternalId = "external-collection-2", + CreationDate = requestTime, + RevisionDate = requestTime + }); + + // Create organization user with both groups and collections using CreateManyAsync + var createOrgUserWithCollections = new List + { + new() + { + OrganizationUser = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + AccessSecretsManager = false + }, + Collections = + [ + new CollectionAccessSelection + { + Id = collection1.Id, + ReadOnly = true, + HidePasswords = false, + Manage = false + }, + new CollectionAccessSelection + { + Id = collection2.Id, + ReadOnly = false, + HidePasswords = true, + Manage = true + } + ], + Groups = [group1.Id, group2.Id] + } + }; + + await organizationUserRepository.CreateManyAsync(createOrgUserWithCollections); + + var responseModel = await organizationUserRepository.GetManyDetailsByOrganizationAsync_vNext(organization.Id, includeGroups: true, includeCollections: true); + + Assert.NotNull(responseModel); + Assert.Single(responseModel); + + var user1Result = responseModel.First(); + + Assert.Equal(user1.Name, user1Result.Name); + Assert.Equal(user1.Email, user1Result.Email); + Assert.Equal(organization.Id, user1Result.OrganizationId); + Assert.Equal(user1.Id, user1Result.UserId); + + Assert.NotNull(user1Result.Groups); + Assert.Equal(2, user1Result.Groups.Count()); + Assert.Contains(group1.Id, user1Result.Groups); + Assert.Contains(group2.Id, user1Result.Groups); + + Assert.NotNull(user1Result.Collections); + Assert.Equal(2, user1Result.Collections.Count()); + Assert.Contains(user1Result.Collections, c => c.Id == collection1.Id); + Assert.Contains(user1Result.Collections, c => c.Id == collection2.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManyByOrganizationWithClaimedDomainsAsync_vNext_WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + var requestTime = DateTime.UtcNow; + + var user1 = await userRepository.CreateAsync(new User + { + Id = CoreHelpers.GenerateComb(), + Name = "Test User 1", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + CreationDate = requestTime, + RevisionDate = requestTime, + AccountRevisionDate = requestTime + }); + + var user2 = await userRepository.CreateAsync(new User + { + Id = CoreHelpers.GenerateComb(), + Name = "Test User 2", + Email = $"test+{id}@x-{domainName}", // Different domain + ApiKey = "TEST", + SecurityStamp = "stamp", + CreationDate = requestTime, + RevisionDate = requestTime, + AccountRevisionDate = requestTime + }); + + var user3 = await userRepository.CreateAsync(new User + { + Id = CoreHelpers.GenerateComb(), + Name = "Test User 3", + Email = $"test+{id}@{domainName}.example.com", // Different domain + ApiKey = "TEST", + SecurityStamp = "stamp", + CreationDate = requestTime, + RevisionDate = requestTime, + AccountRevisionDate = requestTime + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Id = CoreHelpers.GenerateComb(), + Name = $"Test Org {id}", + BillingEmail = user1.Email, + Plan = "Test", + Enabled = true, + CreationDate = requestTime, + RevisionDate = requestTime + }); + + var organizationDomain = new OrganizationDomain + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + CreationDate = requestTime + }; + organizationDomain.SetNextRunDate(12); + organizationDomain.SetVerifiedDate(); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + CreationDate = requestTime, + RevisionDate = requestTime + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = user2.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + CreationDate = requestTime, + RevisionDate = requestTime + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = user3.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + CreationDate = requestTime, + RevisionDate = requestTime + }); + + var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync_vNext(organization.Id); + + Assert.NotNull(responseModel); + Assert.Single(responseModel); + Assert.Equal(orgUser1.Id, responseModel.Single().Id); + Assert.Equal(user1.Id, responseModel.Single().UserId); + Assert.Equal(organization.Id, responseModel.Single().OrganizationId); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManyByOrganizationWithClaimedDomainsAsync_vNext_WithNoVerifiedDomain_ReturnsEmpty( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + var requestTime = DateTime.UtcNow; + + var user1 = await userRepository.CreateAsync(new User + { + Id = CoreHelpers.GenerateComb(), + Name = "Test User 1", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + CreationDate = requestTime, + RevisionDate = requestTime, + AccountRevisionDate = requestTime + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Id = CoreHelpers.GenerateComb(), + Name = $"Test Org {id}", + BillingEmail = user1.Email, + Plan = "Test", + Enabled = true, + CreationDate = requestTime, + RevisionDate = requestTime + }); + + // Create domain but do NOT verify it + var organizationDomain = new OrganizationDomain + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + CreationDate = requestTime + }; + organizationDomain.SetNextRunDate(12); + // Note: NOT calling SetVerifiedDate() + await organizationDomainRepository.CreateAsync(organizationDomain); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + CreationDate = requestTime, + RevisionDate = requestTime + }); + + var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync_vNext(organization.Id); + + Assert.NotNull(responseModel); + Assert.Empty(responseModel); + } } diff --git a/util/Migrator/DbScripts/2025-07-22_00_OrgUsersQueryOptimizations.sql b/util/Migrator/DbScripts/2025-07-22_00_OrgUsersQueryOptimizations.sql new file mode 100644 index 000000000000..7a1ba682769c --- /dev/null +++ b/util/Migrator/DbScripts/2025-07-22_00_OrgUsersQueryOptimizations.sql @@ -0,0 +1,98 @@ +CREATE OR ALTER VIEW [dbo].[UserEmailDomainView] +AS +SELECT + Id, + Email, + SUBSTRING(Email, CHARINDEX('@', Email) + 1, LEN(Email)) AS EmailDomain +FROM dbo.[User] +WHERE Email IS NOT NULL + AND CHARINDEX('@', Email) > 0 +GO + +-- Index on OrganizationUser for efficient filtering +IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_OrganizationUser_OrganizationId_UserId') + BEGIN + CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId_UserId] + ON [dbo].[OrganizationUser] ([OrganizationId], [UserId]) + INCLUDE ([Email], [Status], [Type], [ExternalId], [CreationDate], + [RevisionDate], [Permissions], [ResetPasswordKey], [AccessSecretsManager]) + END +GO + +IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_User_Id_EmailDomain') + BEGIN + CREATE NONCLUSTERED INDEX [IX_User_Id_EmailDomain] + ON [dbo].[User] ([Id], [Email]) + END +GO + +IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_OrganizationDomain_OrganizationId_VerifiedDate') + BEGIN + CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_OrganizationId_VerifiedDate] + ON [dbo].[OrganizationDomain] ([OrganizationId], [VerifiedDate]) + INCLUDE ([DomainName]) + WHERE [VerifiedDate] IS NOT NULL + END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUserUserDetails_ReadByOrganizationId_V2] + @OrganizationId UNIQUEIDENTIFIER, + @IncludeGroups BIT = 0, + @IncludeCollections BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + -- Result Set 1: User Details (always returned) + SELECT * + FROM [dbo].[OrganizationUserUserDetailsView] + WHERE OrganizationId = @OrganizationId + + -- Result Set 2: Group associations (if requested) + IF @IncludeGroups = 1 + BEGIN + SELECT gu.* + FROM [dbo].[GroupUser] gu + INNER JOIN [dbo].[OrganizationUser] ou ON gu.OrganizationUserId = ou.Id + WHERE ou.OrganizationId = @OrganizationId + END + + -- Result Set 3: Collection associations (if requested) + IF @IncludeCollections = 1 + BEGIN + SELECT cu.* + FROM [dbo].[CollectionUser] cu + INNER JOIN [dbo].[OrganizationUser] ou ON cu.OrganizationUserId = ou.Id + WHERE ou.OrganizationId = @OrganizationId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON; + + WITH OrgUsers AS ( + SELECT * + FROM [dbo].[OrganizationUserView] + WHERE [OrganizationId] = @OrganizationId + ), + UserDomains AS ( + SELECT U.[Id], U.[EmailDomain] + FROM [dbo].[UserEmailDomainView] U + WHERE EXISTS ( + SELECT 1 + FROM [dbo].[OrganizationDomainView] OD + WHERE OD.[OrganizationId] = @OrganizationId + AND OD.[VerifiedDate] IS NOT NULL + AND OD.[DomainName] = U.[EmailDomain] + ) + ) + SELECT OU.* + FROM OrgUsers OU + JOIN UserDomains UD ON OU.[UserId] = UD.[Id] + OPTION (RECOMPILE); +END +GO