mirror of
https://github.com/bitwarden/server.git
synced 2025-12-11 04:34:37 -06: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:
parent
947ae8db51
commit
acd556d56f
@ -8,13 +8,16 @@ public class GetOrganizationUsersClaimedStatusQuery : IGetOrganizationUsersClaim
|
|||||||
{
|
{
|
||||||
private readonly IApplicationCacheService _applicationCacheService;
|
private readonly IApplicationCacheService _applicationCacheService;
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public GetOrganizationUsersClaimedStatusQuery(
|
public GetOrganizationUsersClaimedStatusQuery(
|
||||||
IApplicationCacheService applicationCacheService,
|
IApplicationCacheService applicationCacheService,
|
||||||
IOrganizationUserRepository organizationUserRepository)
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IFeatureService featureService)
|
||||||
{
|
{
|
||||||
_applicationCacheService = applicationCacheService;
|
_applicationCacheService = applicationCacheService;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IDictionary<Guid, bool>> GetUsersOrganizationClaimedStatusAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds)
|
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 })
|
if (organizationAbility is { Enabled: true, UseOrganizationDomains: true })
|
||||||
{
|
{
|
||||||
// Get all organization users with claimed domains by the organization
|
// 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
|
// 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));
|
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.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
@ -43,9 +44,12 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer
|
|||||||
return organizationUsers
|
return organizationUsers
|
||||||
.Select(o =>
|
.Select(o =>
|
||||||
{
|
{
|
||||||
var userPermissions = o.GetPermissions();
|
// Only set permissions for Custom user types for performance optimization
|
||||||
|
if (o.Type == OrganizationUserType.Custom)
|
||||||
o.Permissions = CoreHelpers.ClassToJsonData(userPermissions);
|
{
|
||||||
|
var userPermissions = o.GetPermissions();
|
||||||
|
o.Permissions = CoreHelpers.ClassToJsonData(userPermissions);
|
||||||
|
}
|
||||||
|
|
||||||
return o;
|
return o;
|
||||||
});
|
});
|
||||||
@ -59,6 +63,11 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer
|
|||||||
/// <returns>List of OrganizationUserUserDetails</returns>
|
/// <returns>List of OrganizationUserUserDetails</returns>
|
||||||
public async Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> Get(OrganizationUserUserDetailsQueryRequest request)
|
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 organizationUsers = await GetOrganizationUserUserDetails(request);
|
||||||
|
|
||||||
var organizationUsersTwoFactorEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers)).ToDictionary(u => u.user.Id);
|
var organizationUsersTwoFactorEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers)).ToDictionary(u => u.user.Id);
|
||||||
@ -77,6 +86,11 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer
|
|||||||
/// <returns>List of OrganizationUserUserDetails</returns>
|
/// <returns>List of OrganizationUserUserDetails</returns>
|
||||||
public async Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> GetAccountRecoveryEnrolledUsers(OrganizationUserUserDetailsQueryRequest request)
|
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))
|
var organizationUsers = (await GetOrganizationUserUserDetails(request))
|
||||||
.Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey));
|
.Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey));
|
||||||
|
|
||||||
@ -88,4 +102,65 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer
|
|||||||
return responses;
|
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>
|
/// <param name="includeCollections">Whether to include collections</param>
|
||||||
/// <returns>A list of OrganizationUserUserDetails</returns>
|
/// <returns>A list of OrganizationUserUserDetails</returns>
|
||||||
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
|
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,
|
Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
|
||||||
OrganizationUserStatusType? status = null);
|
OrganizationUserStatusType? status = null);
|
||||||
Task<OrganizationUserOrganizationDetails?> GetDetailsByUserAsync(Guid userId, Guid organizationId,
|
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.
|
/// Returns a list of OrganizationUsers with email domains that match one of the Organization's claimed domains.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId);
|
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);
|
Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -115,6 +115,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
|
public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
|
||||||
public const string ImportAsyncRefactor = "pm-22583-refactor-import-async";
|
public const string ImportAsyncRefactor = "pm-22583-refactor-import-async";
|
||||||
public const string CreateDefaultLocation = "pm-19467-create-default-location";
|
public const string CreateDefaultLocation = "pm-19467-create-default-location";
|
||||||
|
public const string MembersGetEndpointOptimization = "pm-21031-members-get-endpoint-optimization";
|
||||||
|
|
||||||
/* Auth Team */
|
/* Auth Team */
|
||||||
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
||||||
|
|||||||
@ -268,6 +268,68 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ICollection<OrganizationUserUserDetails>> 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<OrganizationUserUserDetails>()).ToList();
|
||||||
|
|
||||||
|
// Read group associations (second result set, if requested)
|
||||||
|
Dictionary<Guid, List<Guid>>? userGroupMap = null;
|
||||||
|
if (includeGroups)
|
||||||
|
{
|
||||||
|
var groupUsers = await results.ReadAsync<GroupUser>();
|
||||||
|
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<Guid, List<CollectionAccessSelection>>? userCollectionMap = null;
|
||||||
|
if (includeCollections)
|
||||||
|
{
|
||||||
|
var collectionUsers = await results.ReadAsync<CollectionUser>();
|
||||||
|
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<Guid>());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userCollectionMap != null)
|
||||||
|
{
|
||||||
|
user.Collections = userCollectionMap.GetValueOrDefault(user.Id, new List<CollectionAccessSelection>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
|
public async Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
|
||||||
OrganizationUserStatusType? status = null)
|
OrganizationUserStatusType? status = null)
|
||||||
{
|
{
|
||||||
@ -558,6 +620,19 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync_vNext(Guid organizationId)
|
||||||
|
{
|
||||||
|
using (var connection = new SqlConnection(ConnectionString))
|
||||||
|
{
|
||||||
|
var results = await connection.QueryAsync<OrganizationUser>(
|
||||||
|
$"[{Schema}].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2]",
|
||||||
|
new { OrganizationId = organizationId },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
return results.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds)
|
public async Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds)
|
||||||
{
|
{
|
||||||
await using var connection = new SqlConnection(ConnectionString);
|
await using var connection = new SqlConnection(ConnectionString);
|
||||||
|
|||||||
@ -404,6 +404,56 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ICollection<OrganizationUserUserDetails>> 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<Guid>(),
|
||||||
|
|
||||||
|
Collections = includeCollections
|
||||||
|
? ou.CollectionUsers.Select(cu => new CollectionAccessSelection
|
||||||
|
{
|
||||||
|
Id = cu.CollectionId,
|
||||||
|
ReadOnly = cu.ReadOnly,
|
||||||
|
HidePasswords = cu.HidePasswords,
|
||||||
|
Manage = cu.Manage
|
||||||
|
}).ToList()
|
||||||
|
: new List<CollectionAccessSelection>()
|
||||||
|
};
|
||||||
|
|
||||||
|
return await query.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
|
public async Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
|
||||||
OrganizationUserStatusType? status = null)
|
OrganizationUserStatusType? status = null)
|
||||||
{
|
{
|
||||||
@ -732,6 +782,12 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ICollection<Core.Entities.OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync_vNext(Guid organizationId)
|
||||||
|
{
|
||||||
|
// No EF optimization is required for this query
|
||||||
|
return await GetManyByOrganizationWithClaimedDomainsAsync(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds)
|
public async Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds)
|
||||||
{
|
{
|
||||||
using var scope = ServiceScopeFactory.CreateScope();
|
using var scope = ServiceScopeFactory.CreateScope();
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -25,5 +25,11 @@ GO
|
|||||||
|
|
||||||
CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_DomainNameVerifiedDateOrganizationId]
|
CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_DomainNameVerifiedDateOrganizationId]
|
||||||
ON [dbo].[OrganizationDomain] ([DomainName],[VerifiedDate])
|
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
|
GO
|
||||||
|
|||||||
@ -27,3 +27,10 @@ GO
|
|||||||
CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId]
|
CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId]
|
||||||
ON [dbo].[OrganizationUser]([OrganizationId] ASC);
|
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
|
||||||
|
|||||||
@ -54,3 +54,7 @@ GO
|
|||||||
CREATE NONCLUSTERED INDEX [IX_User_Premium_PremiumExpirationDate_RenewalReminderDate]
|
CREATE NONCLUSTERED INDEX [IX_User_Premium_PremiumExpirationDate_RenewalReminderDate]
|
||||||
ON [dbo].[User]([Premium] ASC, [PremiumExpirationDate] ASC, [RenewalReminderDate] ASC);
|
ON [dbo].[User]([Premium] ASC, [PremiumExpirationDate] ASC, [RenewalReminderDate] ASC);
|
||||||
|
|
||||||
|
GO
|
||||||
|
CREATE NONCLUSTERED INDEX [IX_User_Id_EmailDomain]
|
||||||
|
ON [dbo].[User]([Id] ASC, [Email] ASC);
|
||||||
|
|
||||||
|
|||||||
10
src/Sql/dbo/Views/UserEmailDomainView.sql
Normal file
10
src/Sql/dbo/Views/UserEmailDomainView.sql
Normal file
@ -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
|
||||||
@ -142,18 +142,24 @@ public class OrganizationUserRepositoryTests
|
|||||||
|
|
||||||
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||||
{
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
OrganizationId = organization.Id,
|
OrganizationId = organization.Id,
|
||||||
UserId = user1.Id,
|
UserId = user1.Id,
|
||||||
Status = OrganizationUserStatusType.Confirmed,
|
Status = OrganizationUserStatusType.Confirmed,
|
||||||
|
Type = OrganizationUserType.Owner,
|
||||||
ResetPasswordKey = "resetpasswordkey1",
|
ResetPasswordKey = "resetpasswordkey1",
|
||||||
|
AccessSecretsManager = false
|
||||||
});
|
});
|
||||||
|
|
||||||
var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||||
{
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
OrganizationId = organization.Id,
|
OrganizationId = organization.Id,
|
||||||
UserId = user2.Id,
|
UserId = user2.Id,
|
||||||
Status = OrganizationUserStatusType.Confirmed,
|
Status = OrganizationUserStatusType.Invited,
|
||||||
|
Type = OrganizationUserType.User,
|
||||||
ResetPasswordKey = "resetpasswordkey2",
|
ResetPasswordKey = "resetpasswordkey2",
|
||||||
|
AccessSecretsManager = true
|
||||||
});
|
});
|
||||||
|
|
||||||
var recoveryDetails = await organizationUserRepository.GetManyAccountRecoveryDetailsByOrganizationUserAsync(
|
var recoveryDetails = await organizationUserRepository.GetManyAccountRecoveryDetailsByOrganizationUserAsync(
|
||||||
@ -292,10 +298,13 @@ public class OrganizationUserRepositoryTests
|
|||||||
|
|
||||||
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||||
{
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
OrganizationId = organization.Id,
|
OrganizationId = organization.Id,
|
||||||
UserId = user1.Id,
|
UserId = user1.Id,
|
||||||
Status = OrganizationUserStatusType.Confirmed,
|
Status = OrganizationUserStatusType.Confirmed,
|
||||||
|
Type = OrganizationUserType.Owner,
|
||||||
ResetPasswordKey = "resetpasswordkey1",
|
ResetPasswordKey = "resetpasswordkey1",
|
||||||
|
AccessSecretsManager = false
|
||||||
});
|
});
|
||||||
|
|
||||||
var responseModel = await organizationUserRepository.GetManyDetailsByUserAsync(user1.Id);
|
var responseModel = await organizationUserRepository.GetManyDetailsByUserAsync(user1.Id);
|
||||||
@ -435,27 +444,35 @@ public class OrganizationUserRepositoryTests
|
|||||||
|
|
||||||
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||||
{
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
OrganizationId = organization.Id,
|
OrganizationId = organization.Id,
|
||||||
UserId = user1.Id,
|
UserId = user1.Id,
|
||||||
Status = OrganizationUserStatusType.Confirmed,
|
Status = OrganizationUserStatusType.Confirmed,
|
||||||
|
Type = OrganizationUserType.Owner,
|
||||||
ResetPasswordKey = "resetpasswordkey1",
|
ResetPasswordKey = "resetpasswordkey1",
|
||||||
AccessSecretsManager = false
|
AccessSecretsManager = false
|
||||||
});
|
});
|
||||||
|
|
||||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||||
{
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
OrganizationId = organization.Id,
|
OrganizationId = organization.Id,
|
||||||
UserId = user2.Id,
|
UserId = user2.Id,
|
||||||
Status = OrganizationUserStatusType.Confirmed,
|
Status = OrganizationUserStatusType.Confirmed,
|
||||||
|
Type = OrganizationUserType.User,
|
||||||
ResetPasswordKey = "resetpasswordkey1",
|
ResetPasswordKey = "resetpasswordkey1",
|
||||||
|
AccessSecretsManager = false
|
||||||
});
|
});
|
||||||
|
|
||||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||||
{
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
OrganizationId = organization.Id,
|
OrganizationId = organization.Id,
|
||||||
UserId = user3.Id,
|
UserId = user3.Id,
|
||||||
Status = OrganizationUserStatusType.Confirmed,
|
Status = OrganizationUserStatusType.Confirmed,
|
||||||
|
Type = OrganizationUserType.User,
|
||||||
ResetPasswordKey = "resetpasswordkey1",
|
ResetPasswordKey = "resetpasswordkey1",
|
||||||
|
AccessSecretsManager = false
|
||||||
});
|
});
|
||||||
|
|
||||||
var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
|
var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
|
||||||
@ -724,4 +741,410 @@ public class OrganizationUserRepositoryTests
|
|||||||
Assert.Equal(collection3.Id, orgUser3.Collections.First().Id);
|
Assert.Equal(collection3.Id, orgUser3.Collections.First().Id);
|
||||||
Assert.Equal(group3.Id, group3Database.First());
|
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<CreateOrganizationUser>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user