[PM-25126] Add Bulk Policy Details (#6256)

* Added new bulk get for policy details

* Query improvements to avoid unnecessary look-ups.
This commit is contained in:
Jared McCannon 2025-09-09 13:43:14 -05:00 committed by GitHub
parent 3dd5accb56
commit 2986a883eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 737 additions and 8 deletions

View File

@ -44,4 +44,15 @@ public interface IPolicyRepository : IRepository<Policy, Guid>
/// You probably do not want to call it directly.
/// </remarks>
Task<IEnumerable<OrganizationPolicyDetails>> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType);
/// <summary>
/// Retrieves policy details for a list of users filtered by the specified policy type.
/// </summary>
/// <param name="userIds">A collection of user identifiers for which the policy details are to be fetched.</param>
/// <param name="policyType">The type of policy for which the details are required.</param>
/// <returns>
/// An asynchronous task that returns a collection of <see cref="OrganizationPolicyDetails"/> objects containing the policy information
/// associated with the specified users and policy type.
/// </returns>
Task<IEnumerable<OrganizationPolicyDetails>> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable<Guid> userIds, PolicyType policyType);
}

View File

@ -74,6 +74,21 @@ public class PolicyRepository : Repository<Policy, Guid>, IPolicyRepository
}
}
public async Task<IEnumerable<OrganizationPolicyDetails>> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable<Guid> userIds, PolicyType type)
{
await using var connection = new SqlConnection(ConnectionString);
var results = await connection.QueryAsync<OrganizationPolicyDetails>(
$"[{Schema}].[PolicyDetails_ReadByUserIdsPolicyType]",
new
{
UserIds = userIds.ToGuidIdArrayTVP(),
PolicyType = (byte)type
},
commandType: CommandType.StoredProcedure);
return results.ToList();
}
public async Task<IEnumerable<OrganizationPolicyDetails>> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType)
{
using (var connection = new SqlConnection(ConnectionString))

View File

@ -183,4 +183,94 @@ public class PolicyRepository : Repository<AdminConsoleEntities.Policy, Policy,
return await policyWithAffectedUsers.ToListAsync();
}
public async Task<IEnumerable<OrganizationPolicyDetails>> GetPolicyDetailsByUserIdsAndPolicyType(
IEnumerable<Guid> 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();
}
}

View File

@ -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

View File

@ -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,

View File

@ -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<Guid>(),
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<Organization> 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
};
}

View File

@ -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