diff --git a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs index 2b46c040bb..9f5c7f3fc4 100644 --- a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs +++ b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs @@ -44,4 +44,15 @@ public interface IPolicyRepository : IRepository /// You probably do not want to call it directly. /// Task> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType); + + /// + /// Retrieves policy details for a list of users filtered by the specified policy type. + /// + /// A collection of user identifiers for which the policy details are to be fetched. + /// The type of policy for which the details are required. + /// + /// An asynchronous task that returns a collection of objects containing the policy information + /// associated with the specified users and policy type. + /// + Task> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable userIds, PolicyType policyType); } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs index c93c66c94d..83d5ef6a70 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs @@ -74,6 +74,21 @@ public class PolicyRepository : Repository, IPolicyRepository } } + public async Task> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable userIds, PolicyType type) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PolicyDetails_ReadByUserIdsPolicyType]", + new + { + UserIds = userIds.ToGuidIdArrayTVP(), + PolicyType = (byte)type + }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + public async Task> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs index 9d25fd5541..72c277f1d7 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs @@ -183,4 +183,94 @@ public class PolicyRepository : Repository> GetPolicyDetailsByUserIdsAndPolicyType( + IEnumerable userIds, PolicyType policyType) + { + ArgumentNullException.ThrowIfNull(userIds); + + var userIdsList = userIds.Where(id => id != Guid.Empty).ToList(); + + if (userIdsList.Count == 0) + { + return []; + } + + using var scope = ServiceScopeFactory.CreateScope(); + await using var dbContext = GetDatabaseContext(scope); + + // Get provider relationships + var providerLookup = await (from pu in dbContext.ProviderUsers + join po in dbContext.ProviderOrganizations on pu.ProviderId equals po.ProviderId + where pu.UserId != null && userIdsList.Contains(pu.UserId.Value) + select new { pu.UserId, po.OrganizationId }) + .ToListAsync(); + + // Hashset for lookup + var providerSet = new HashSet<(Guid UserId, Guid OrganizationId)>( + providerLookup.Select(p => (p.UserId!.Value, p.OrganizationId))); + + // Branch 1: Accepted users + var acceptedUsers = await (from p in dbContext.Policies + join ou in dbContext.OrganizationUsers on p.OrganizationId equals ou.OrganizationId + join o in dbContext.Organizations on p.OrganizationId equals o.Id + where p.Enabled + && p.Type == policyType + && o.Enabled + && o.UsePolicies + && ou.Status != OrganizationUserStatusType.Invited + && ou.UserId != null + && userIdsList.Contains(ou.UserId.Value) + select new + { + OrganizationUserId = ou.Id, + OrganizationId = p.OrganizationId, + PolicyType = p.Type, + PolicyData = p.Data, + OrganizationUserType = ou.Type, + OrganizationUserStatus = ou.Status, + OrganizationUserPermissionsData = ou.Permissions, + UserId = ou.UserId.Value + }).ToListAsync(); + + // Branch 2: Invited users + var invitedUsers = await (from p in dbContext.Policies + join ou in dbContext.OrganizationUsers on p.OrganizationId equals ou.OrganizationId + join o in dbContext.Organizations on p.OrganizationId equals o.Id + join u in dbContext.Users on ou.Email equals u.Email + where p.Enabled + && o.Enabled + && o.UsePolicies + && ou.Status == OrganizationUserStatusType.Invited + && userIdsList.Contains(u.Id) + && p.Type == policyType + select new + { + OrganizationUserId = ou.Id, + OrganizationId = p.OrganizationId, + PolicyType = p.Type, + PolicyData = p.Data, + OrganizationUserType = ou.Type, + OrganizationUserStatus = ou.Status, + OrganizationUserPermissionsData = ou.Permissions, + UserId = u.Id + }).ToListAsync(); + + // Combine results with provder lookup + var allResults = acceptedUsers.Concat(invitedUsers) + .Select(item => new OrganizationPolicyDetails + { + OrganizationUserId = item.OrganizationUserId, + OrganizationId = item.OrganizationId, + PolicyType = item.PolicyType, + PolicyData = item.PolicyData, + OrganizationUserType = item.OrganizationUserType, + OrganizationUserStatus = item.OrganizationUserStatus, + OrganizationUserPermissionsData = item.OrganizationUserPermissionsData, + UserId = item.UserId, + IsProvider = providerSet.Contains((item.UserId, item.OrganizationId)) + }); + + return allResults.ToList(); + } } diff --git a/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql new file mode 100644 index 0000000000..8686802f87 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql @@ -0,0 +1,83 @@ +CREATE PROCEDURE [dbo].[PolicyDetails_ReadByUserIdsPolicyType] + @UserIds AS [dbo].[GuidIdArray] READONLY, + @PolicyType AS TINYINT +AS +BEGIN + SET NOCOUNT ON; + + WITH AcceptedUsers AS ( + -- Branch 1: Accepted users linked by UserId + SELECT + OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + OU.[UserId] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN @UserIds UI ON OU.[UserId] = UI.Id -- Direct join with TVP + WHERE + P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] != 0 -- Accepted users + AND P.[Type] = @PolicyType + ), + InvitedUsers AS ( + -- Branch 2: Invited users matched by email + SELECT + OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + U.[Id] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN [dbo].[UserView] U ON U.[Email] = OU.[Email] -- Join on email + INNER JOIN @UserIds UI ON U.[Id] = UI.Id -- Join with TVP + WHERE + P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] = 0 -- Invited users only + AND P.[Type] = @PolicyType + ), + AllUsers AS ( + -- Combine both user sets + SELECT * FROM AcceptedUsers + UNION + SELECT * FROM InvitedUsers + ), + ProviderLookup AS ( + -- Pre-calculate provider relationships for all relevant user/org combinations + SELECT DISTINCT + PU.[UserId], + PO.[OrganizationId] + FROM [dbo].[ProviderUserView] PU + INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId] + INNER JOIN AllUsers AU ON PU.[UserId] = AU.UserId AND PO.[OrganizationId] = AU.OrganizationId + ) + -- Final result with efficient IsProvider lookup + SELECT + AU.OrganizationUserId, + AU.OrganizationId, + AU.PolicyType, + AU.PolicyData, + AU.OrganizationUserType, + AU.OrganizationUserStatus, + AU.OrganizationUserPermissionsData, + AU.UserId, + IIF(PL.UserId IS NOT NULL, 1, 0) AS IsProvider + FROM AllUsers AU + LEFT JOIN ProviderLookup PL + ON AU.UserId = PL.UserId + AND AU.OrganizationId = PL.OrganizationId +END diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs index 07cb82dc02..0a2ddd7387 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs @@ -16,7 +16,7 @@ namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.PolicyRep public class GetPolicyDetailsByUserIdTests { - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_NonInvitedUsers_Works( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -105,7 +105,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(new Permissions { ManagePolicies = true }, actualPolicyDetails2.GetOrganizationUserCustomPermissions(), strict: true); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_InvitedUser_Works( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -148,7 +148,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_RevokedConfirmedUser_Works( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -192,7 +192,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_RevokedInvitedUser_DoesntReturnPolicies( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -227,7 +227,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Empty(actualPolicyDetails); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_SetsIsProvider( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -283,7 +283,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_IgnoresDisabledOrganizations( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -312,7 +312,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Empty(actualPolicyDetails); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_IgnoresDowngradedOrganizations( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -342,7 +342,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Empty(actualPolicyDetails); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_IgnoresDisabledPolicies( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdsAndPolicyTypeTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdsAndPolicyTypeTests.cs new file mode 100644 index 0000000000..9576967a25 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdsAndPolicyTypeTests.cs @@ -0,0 +1,457 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.PolicyRepository; + +public class GetPolicyDetailsByUserIdsAndPolicyTypeTests +{ + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenTwoUsersForAnEnterpriseOrgWithTwoFactorEnabled_WhenUsersHaveBeenConfirmedOrAccepted_ThenShouldReturnCorrectPolicyDetailsAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user1 = await userRepository.CreateAsync(GetDefaultUser()); + + var user2 = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + var policy = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.TwoFactorAuthentication, + Data = string.Empty, + Enabled = true + }); + + var orgUser1 = await organizationUserRepository.CreateAsync(GetAcceptedOrganizationUser(organization, user1)); + + var orgUser2 = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user2)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user1.Id, user2.Id], + PolicyType.TwoFactorAuthentication); + + // Assert + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var result1 = resultsList.First(r => r.UserId == user1.Id); + Assert.Equal(orgUser1.Id, result1.OrganizationUserId); + Assert.Equal(organization.Id, result1.OrganizationId); + Assert.Equal(PolicyType.TwoFactorAuthentication, result1.PolicyType); + Assert.Equal(policy.Data, result1.PolicyData); + Assert.Equal(OrganizationUserStatusType.Accepted, result1.OrganizationUserStatus); + + var result2 = resultsList.First(r => r.UserId == user2.Id); + Assert.Equal(orgUser2.Id, result2.OrganizationUserId); + Assert.Equal(organization.Id, result2.OrganizationId); + Assert.Equal(PolicyType.TwoFactorAuthentication, result2.PolicyType); + Assert.Equal(policy.Data, result2.PolicyData); + Assert.Equal(OrganizationUserStatusType.Confirmed, result2.OrganizationUserStatus); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user1, user2]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenTwoUsersForEnterpriseOrgWithMasterPasswordEnabled_WhenUsersHaveBeenInvited_ThenShouldReturnCorrectPolicyDetailsForInvitedUsersAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user1 = await userRepository.CreateAsync(GetDefaultUser()); + + var user2 = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.MasterPassword, + Data = "{\"minComplexity\":4}", + Enabled = true, + }); + + var orgUser1 = await organizationUserRepository.CreateAsync(GetInvitedOrganizationUser(organization, user1)); + + var orgUser2 = await organizationUserRepository.CreateAsync(GetInvitedOrganizationUser(organization, user2)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user1.Id, user2.Id], + PolicyType.MasterPassword); + + // Assert + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var result1 = resultsList.First(r => r.UserId == user1.Id); + Assert.Equal(orgUser1.Id, result1.OrganizationUserId); + Assert.Equal(organization.Id, result1.OrganizationId); + Assert.Equal(PolicyType.MasterPassword, result1.PolicyType); + Assert.Equal(OrganizationUserStatusType.Invited, result1.OrganizationUserStatus); + + var result2 = resultsList.First(r => r.UserId == user2.Id); + Assert.Equal(orgUser2.Id, result2.OrganizationUserId); + Assert.Equal(organization.Id, result2.OrganizationId); + Assert.Equal(PolicyType.MasterPassword, result2.PolicyType); + Assert.Equal(OrganizationUserStatusType.Invited, result2.OrganizationUserStatus); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user1, user2]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenConfirmedUserEnterpriseOrgWithPolicyEnabled_WhenUserIsAProvider_ThenShouldContainProviderDataAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository, + IProviderOrganizationRepository providerOrganizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.SingleOrg, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + BusinessName = "Test Provider Business", + BusinessAddress1 = "123 Test St", + BusinessAddress2 = "Suite 456", + BusinessAddress3 = "Floor 7", + BusinessCountry = "US", + BusinessTaxNumber = "123456789", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com" + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + await providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organization.Id + }); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.SingleOrg); + + // Assert + var resultsList = results.ToList(); + Assert.Single(resultsList); + + var result = resultsList.First(); + Assert.True(result.IsProvider); + Assert.Equal(user.Id, result.UserId); + Assert.Equal(organization.Id, result.OrganizationId); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenEnterpriseOrgWithTwoEnabledPolicies_WhenRequestingTwoFactor_ShouldOnlyReturnInputPolicyType( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + // Create multiple policies + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization)); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.MasterPassword, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act - Request only TwoFactorAuthentication policy + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.TwoFactorAuthentication); + + // Assert + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.All(resultsList, r => Assert.Equal(PolicyType.TwoFactorAuthentication, r.PolicyType)); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenEnterpriseOrg_WhenSendPolicyIsDisabled_ShouldNotReturnDisabledPoliciesAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + _ = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.DisableSend, + Data = "{}", + Enabled = false // Disabled policy + }); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.DisableSend); + + // Assert + Assert.Empty(results); + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenEnterpriseOrgWithPolicies_WhenOrgIsDisabled_ThenShouldNotReturnResults( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com", + Plan = "EnterpriseAnnually", + PlanType = PlanType.EnterpriseAnnually, + Seats = 10, + MaxCollections = 10, + UsePolicies = true, + UseDirectory = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + SelfHost = true, + Enabled = false, // Disabled organization + }); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.RequireSso, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.RequireSso); + + // Assert + Assert.Empty(results); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenOrganization_WhenNotUsingPolicies_ThenShouldNotReturnResults( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com", + Plan = "EnterpriseAnnually", + PlanType = PlanType.EnterpriseAnnually, + Seats = 10, + MaxCollections = 10, + UsePolicies = false, // Not using policies + UseDirectory = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + SelfHost = true, + Enabled = true, + }); + + var policy = await policyRepository.CreateAsync(GetPolicy(PolicyType.PasswordGenerator, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.PasswordGenerator); + + // Assert + Assert.Empty(results); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenOrganization_WhenRequestingWithNoUsers_ShouldReturnEmptyList( + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + new List(), + PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Empty(results); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenTwoOrganizations_WhenUserIsAMemberOfBoth_ShouldReturnResultsForBothOrganizations( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization1 = await CreateEnterpriseOrgAsync(organizationRepository); + var organization2 = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization1)); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization2)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization1, user)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization2, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.TwoFactorAuthentication); + + // Assert + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var organizationIds = resultsList.Select(r => r.OrganizationId).ToList(); + Assert.Contains(organization1.Id, organizationIds); + Assert.Contains(organization2.Id, organizationIds); + } + + private static async Task CreateEnterpriseOrgAsync(IOrganizationRepository orgRepo) + { + return await orgRepo.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com", + Plan = "EnterpriseAnnually", + PlanType = PlanType.EnterpriseAnnually, + Seats = 10, + MaxCollections = 10, + UsePolicies = true, + UseDirectory = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + SelfHost = true, + Enabled = true, + }); + } + + private static User GetDefaultUser() => new() + { + Name = $"Test User {Guid.NewGuid()}", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = $"test.api.key.{Guid.NewGuid()}"[..30], + SecurityStamp = Guid.NewGuid().ToString() + }; + + private static OrganizationUser GetAcceptedOrganizationUser(Organization organization, User user) => new() + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User + }; + + private static OrganizationUser GetConfirmedOrganizationUser(Organization organization, User user) => new() + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User + }; + + private static OrganizationUser GetInvitedOrganizationUser(Organization organization, User user) => new() + { + OrganizationId = organization.Id, + UserId = null, // Invited users don't have UserId + Email = user.Email, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, + }; + + private static Policy GetPolicy(PolicyType policyType, Organization organization) => new() + { + OrganizationId = organization.Id, + Type = policyType, + Data = "{\"test\": \"value\"}", + Enabled = true + }; +} diff --git a/util/Migrator/DbScripts/2025-08-26_00_PolicyDetails_ReadByUserIdsPolicyType.sql b/util/Migrator/DbScripts/2025-08-26_00_PolicyDetails_ReadByUserIdsPolicyType.sql new file mode 100644 index 0000000000..52c335a790 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-26_00_PolicyDetails_ReadByUserIdsPolicyType.sql @@ -0,0 +1,73 @@ +CREATE OR ALTER PROCEDURE [dbo].[PolicyDetails_ReadByUserIdsPolicyType] + @UserIds AS [dbo].[GuidIdArray] READONLY, + @PolicyType AS TINYINT +AS +BEGIN + SET NOCOUNT ON; + + WITH AcceptedUsers AS ( + -- Branch 1: Accepted users linked by UserId + SELECT OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + OU.[UserId] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN @UserIds UI ON OU.[UserId] = UI.Id -- Direct join with TVP + WHERE P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] != 0 -- Accepted users + AND P.[Type] = @PolicyType), + InvitedUsers AS ( + -- Branch 2: Invited users matched by email + SELECT OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + U.[Id] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN [dbo].[UserView] U ON U.[Email] = OU.[Email] -- Join on email + INNER JOIN @UserIds UI ON U.[Id] = UI.Id -- Join with TVP + WHERE P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] = 0 -- Invited users only + AND P.[Type] = @PolicyType), + AllUsers AS ( + -- Combine both user sets + SELECT * + FROM AcceptedUsers + UNION + SELECT * + FROM InvitedUsers), + ProviderLookup AS ( + -- Pre-calculate provider relationships for all relevant user/org combinations + SELECT DISTINCT PU.[UserId], + PO.[OrganizationId] + FROM [dbo].[ProviderUserView] PU + INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId] + INNER JOIN AllUsers AU ON PU.[UserId] = AU.UserId AND PO.[OrganizationId] = AU.OrganizationId) + -- Final result with efficient IsProvider lookup + SELECT AU.OrganizationUserId, + AU.OrganizationId, + AU.PolicyType, + AU.PolicyData, + AU.OrganizationUserType, + AU.OrganizationUserStatus, + AU.OrganizationUserPermissionsData, + AU.UserId, + IIF(PL.UserId IS NOT NULL, 1, 0) AS IsProvider + FROM AllUsers AU + LEFT JOIN ProviderLookup PL ON AU.UserId = PL.UserId AND AU.OrganizationId = PL.OrganizationId +END