diff --git a/src/Core/AdminConsole/Models/Data/Organizations/Policies/PolicyDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/Policies/PolicyDetails.cs
new file mode 100644
index 000000000000..5b5db85f656f
--- /dev/null
+++ b/src/Core/AdminConsole/Models/Data/Organizations/Policies/PolicyDetails.cs
@@ -0,0 +1,39 @@
+#nullable enable
+
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+using Bit.Core.Enums;
+using Bit.Core.Models.Data;
+using Bit.Core.Utilities;
+
+namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+
+///
+/// Represents an OrganizationUser and a Policy which *may* be enforced against them.
+/// You may assume that the Policy is enabled and that the organization's plan supports policies.
+/// This is consumed by to create requirements for specific policy types.
+///
+public class PolicyDetails
+{
+ public Guid OrganizationUserId { get; set; }
+ public Guid OrganizationId { get; set; }
+ public PolicyType PolicyType { get; set; }
+ public string? PolicyData { get; set; }
+ public OrganizationUserType OrganizationUserType { get; set; }
+ public OrganizationUserStatusType OrganizationUserStatus { get; set; }
+ ///
+ /// Custom permissions for the organization user, if any. Use
+ /// to deserialize.
+ ///
+ public string? OrganizationUserPermissionsData { get; set; }
+ ///
+ /// True if the user is also a ProviderUser for the organization, false otherwise.
+ ///
+ public bool IsProvider { get; set; }
+
+ public T GetDataModel() where T : IPolicyDataModel, new()
+ => CoreHelpers.LoadClassFromJsonData(PolicyData);
+
+ public Permissions GetOrganizationUserCustomPermissions()
+ => CoreHelpers.LoadClassFromJsonData(OrganizationUserPermissionsData);
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyRequirementQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyRequirementQuery.cs
new file mode 100644
index 000000000000..5736078f22a9
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyRequirementQuery.cs
@@ -0,0 +1,18 @@
+#nullable enable
+
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
+
+public interface IPolicyRequirementQuery
+{
+ ///
+ /// Get a policy requirement for a specific user.
+ /// The policy requirement represents how one or more policy types should be enforced against the user.
+ /// It will always return a value even if there are no policies that should be enforced.
+ /// This should be used for all policy checks.
+ ///
+ /// The user that you need to enforce the policy against.
+ /// The IPolicyRequirement that corresponds to the policy you want to enforce.
+ Task GetAsync(Guid userId) where T : IPolicyRequirement;
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs
new file mode 100644
index 000000000000..585d2348ef9e
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs
@@ -0,0 +1,28 @@
+#nullable enable
+
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+using Bit.Core.AdminConsole.Repositories;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
+
+public class PolicyRequirementQuery(
+ IPolicyRepository policyRepository,
+ IEnumerable> factories)
+ : IPolicyRequirementQuery
+{
+ public async Task GetAsync(Guid userId) where T : IPolicyRequirement
+ {
+ var factory = factories.OfType>().SingleOrDefault();
+ if (factory is null)
+ {
+ throw new NotImplementedException("No Policy Requirement found for " + typeof(T));
+ }
+
+ return factory(await GetPolicyDetails(userId));
+ }
+
+ private Task> GetPolicyDetails(Guid userId) =>
+ policyRepository.GetPolicyDetailsByUserId(userId);
+}
+
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/IPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/IPolicyRequirement.cs
new file mode 100644
index 000000000000..3f331b1130dc
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/IPolicyRequirement.cs
@@ -0,0 +1,24 @@
+#nullable enable
+
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+
+///
+/// Represents the business requirements of how one or more enterprise policies will be enforced against a user.
+/// The implementation of this interface will depend on how the policies are enforced in the relevant domain.
+///
+public interface IPolicyRequirement;
+
+///
+/// A factory function that takes a sequence of and transforms them into a single
+/// for consumption by the relevant domain. This will receive *all* policy types
+/// that may be enforced against a user; when implementing this delegate, you must filter out irrelevant policy types
+/// as well as policies that should not be enforced against a user (e.g. due to the user's role or status).
+///
+///
+/// See for extension methods to handle common requirements when implementing
+/// this delegate.
+///
+public delegate T RequirementFactory(IEnumerable policyDetails)
+ where T : IPolicyRequirement;
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PolicyRequirementHelpers.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PolicyRequirementHelpers.cs
new file mode 100644
index 000000000000..fc4cd91a3dcc
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PolicyRequirementHelpers.cs
@@ -0,0 +1,41 @@
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.Enums;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+
+public static class PolicyRequirementHelpers
+{
+ ///
+ /// Filters the PolicyDetails by PolicyType. This is generally required to only get the PolicyDetails that your
+ /// IPolicyRequirement relates to.
+ ///
+ public static IEnumerable GetPolicyType(
+ this IEnumerable policyDetails,
+ PolicyType type)
+ => policyDetails.Where(x => x.PolicyType == type);
+
+ ///
+ /// Filters the PolicyDetails to remove the specified user roles. This can be used to exempt
+ /// owners and admins from policy enforcement.
+ ///
+ public static IEnumerable ExemptRoles(
+ this IEnumerable policyDetails,
+ IEnumerable roles)
+ => policyDetails.Where(x => !roles.Contains(x.OrganizationUserType));
+
+ ///
+ /// Filters the PolicyDetails to remove organization users who are also provider users for the organization.
+ /// This can be used to exempt provider users from policy enforcement.
+ ///
+ public static IEnumerable ExemptProviders(this IEnumerable policyDetails)
+ => policyDetails.Where(x => !x.IsProvider);
+
+ ///
+ /// Filters the PolicyDetails to remove the specified organization user statuses. For example, this can be used
+ /// to exempt users in the invited and revoked statuses from policy enforcement.
+ ///
+ public static IEnumerable ExemptStatus(
+ this IEnumerable policyDetails, IEnumerable status)
+ => policyDetails.Where(x => !status.Contains(x.OrganizationUserStatus));
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
index 4e88976c1001..6616552d59b8 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
@@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Services.Implementations;
@@ -12,7 +13,14 @@ public static void AddPolicyServices(this IServiceCollection services)
{
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddPolicyValidators();
+ services.AddPolicyRequirements();
+ }
+
+ private static void AddPolicyValidators(this IServiceCollection services)
+ {
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -20,4 +28,31 @@ public static void AddPolicyServices(this IServiceCollection services)
services.AddScoped();
services.AddScoped();
}
+
+ private static void AddPolicyRequirements(this IServiceCollection services)
+ {
+ // Register policy requirement factories here
+ }
+
+ ///
+ /// Used to register simple policy requirements where its factory method implements CreateRequirement.
+ /// This MUST be used rather than calling AddScoped directly, because it will ensure the factory method has
+ /// the correct type to be injected and then identified by at runtime.
+ ///
+ /// The specific PolicyRequirement being registered.
+ private static void AddPolicyRequirement(this IServiceCollection serviceCollection, RequirementFactory factory)
+ where T : class, IPolicyRequirement
+ => serviceCollection.AddPolicyRequirement(_ => factory);
+
+ ///
+ /// Used to register policy requirements where you need to access additional dependencies (usually to return a
+ /// curried factory method).
+ /// This MUST be used rather than calling AddScoped directly, because it will ensure the factory method has
+ /// the correct type to be injected and then identified by at runtime.
+ ///
+ /// The specific PolicyRequirement being registered.
+ private static void AddPolicyRequirement(this IServiceCollection serviceCollection,
+ Func> factory)
+ where T : class, IPolicyRequirement
+ => serviceCollection.AddScoped>(factory);
}
diff --git a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs
index ad0654dd3c55..6ac9b06e04bd 100644
--- a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs
+++ b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs
@@ -1,5 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Repositories;
#nullable enable
@@ -8,7 +10,15 @@ namespace Bit.Core.AdminConsole.Repositories;
public interface IPolicyRepository : IRepository
{
+ ///
+ /// Gets all policies of a given type for an organization.
+ ///
+ ///
+ /// WARNING: do not use this to enforce policies against a user! It returns raw data and does not take into account
+ /// various business rules. Use instead.
+ ///
Task GetByOrganizationIdTypeAsync(Guid organizationId, PolicyType type);
Task> GetManyByOrganizationIdAsync(Guid organizationId);
Task> GetManyByUserIdAsync(Guid userId);
+ Task> GetPolicyDetailsByUserId(Guid userId);
}
diff --git a/src/Core/AdminConsole/Services/IPolicyService.cs b/src/Core/AdminConsole/Services/IPolicyService.cs
index 4f9a25f90455..f1ac681d6ff3 100644
--- a/src/Core/AdminConsole/Services/IPolicyService.cs
+++ b/src/Core/AdminConsole/Services/IPolicyService.cs
@@ -12,6 +12,8 @@ public interface IPolicyService
/// Get the combined master password policy options for the specified user.
///
Task GetMasterPasswordPolicyForUserAsync(User user);
+ [Obsolete("Use IPolicyRequirementQuery.GetAsync instead. You may have to add a new IPolicyRequirement for that query to return.")]
Task> GetPoliciesApplicableToUserAsync(Guid userId, PolicyType policyType, OrganizationUserStatusType minStatus = OrganizationUserStatusType.Accepted);
+ [Obsolete("Use IPolicyRequirementQuery.GetAsync instead. You may have to add a new IPolicyRequirement for that query to return.")]
Task AnyPoliciesApplicableToUserAsync(Guid userId, PolicyType policyType, OrganizationUserStatusType minStatus = OrganizationUserStatusType.Accepted);
}
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index e41cf62024ca..86e8bc688bc3 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -109,6 +109,7 @@ public static class FeatureFlagKeys
public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications";
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore";
+ public const string PolicyRequirements = "pm-14439-policy-requirements";
/* Tools Team */
public const string ItemShare = "item-share";
diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs
index 196f3e3733f0..071ff3153a4e 100644
--- a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs
+++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs
@@ -1,6 +1,7 @@
using System.Data;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Settings;
using Bit.Infrastructure.Dapper.Repositories;
@@ -59,4 +60,17 @@ public async Task> GetManyByUserIdAsync(Guid userId)
return results.ToList();
}
}
+
+ public async Task> GetPolicyDetailsByUserId(Guid userId)
+ {
+ using (var connection = new SqlConnection(ConnectionString))
+ {
+ var results = await connection.QueryAsync(
+ $"[{Schema}].[PolicyDetails_ReadByUserId]",
+ new { UserId = userId },
+ commandType: CommandType.StoredProcedure);
+
+ return results.ToList();
+ }
+ }
}
diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs
index 3eb4ac934bf9..05646813410f 100644
--- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs
+++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs
@@ -1,6 +1,8 @@
using AutoMapper;
using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries;
using Bit.Infrastructure.EntityFramework.Repositories;
@@ -50,4 +52,43 @@ public PolicyRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper
return Mapper.Map>(results);
}
}
+
+ public async Task> GetPolicyDetailsByUserId(Guid userId)
+ {
+ using var scope = ServiceScopeFactory.CreateScope();
+ var dbContext = GetDatabaseContext(scope);
+
+ var providerOrganizations = from pu in dbContext.ProviderUsers
+ where pu.UserId == userId
+ join po in dbContext.ProviderOrganizations
+ on pu.ProviderId equals po.ProviderId
+ select po;
+
+ var query = 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 &&
+ o.Enabled &&
+ o.UsePolicies &&
+ (
+ (ou.Status != OrganizationUserStatusType.Invited && ou.UserId == userId) ||
+ // Invited orgUsers do not have a UserId associated with them, so we have to match up their email
+ (ou.Status == OrganizationUserStatusType.Invited && ou.Email == dbContext.Users.Find(userId).Email)
+ )
+ select new PolicyDetails
+ {
+ OrganizationUserId = ou.Id,
+ OrganizationId = p.OrganizationId,
+ PolicyType = p.Type,
+ PolicyData = p.Data,
+ OrganizationUserType = ou.Type,
+ OrganizationUserStatus = ou.Status,
+ OrganizationUserPermissionsData = ou.Permissions,
+ IsProvider = providerOrganizations.Any(po => po.OrganizationId == p.OrganizationId)
+ };
+ return await query.ToListAsync();
+ }
}
diff --git a/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserId.sql b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserId.sql
new file mode 100644
index 000000000000..bbee838a28bf
--- /dev/null
+++ b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserId.sql
@@ -0,0 +1,37 @@
+CREATE PROCEDURE [dbo].[PolicyDetails_ReadByUserId]
+ @UserId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+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,
+ CASE WHEN EXISTS (
+ SELECT 1
+ FROM [dbo].[ProviderUserView] PU
+ INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId]
+ WHERE PU.[UserId] = OU.[UserId] AND PO.[OrganizationId] = P.[OrganizationId]
+ ) THEN 1 ELSE 0 END AS IsProvider
+FROM [dbo].[PolicyView] P
+INNER JOIN [dbo].[OrganizationUserView] OU
+ ON P.[OrganizationId] = OU.[OrganizationId]
+INNER JOIN [dbo].[OrganizationView] O
+ ON P.[OrganizationId] = O.[Id]
+WHERE
+ P.Enabled = 1
+ AND O.Enabled = 1
+ AND O.UsePolicies = 1
+ AND (
+ (OU.[Status] != 0 AND OU.[UserId] = @UserId) -- OrgUsers who have accepted their invite and are linked to a UserId
+ OR EXISTS (
+ SELECT 1
+ FROM [dbo].[UserView] U
+ WHERE U.[Id] = @UserId AND OU.[Email] = U.[Email] AND OU.[Status] = 0 -- 'Invited' OrgUsers are not linked to a UserId yet, so we have to look up their email
+ )
+ )
+END
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirementQueryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirementQueryTests.cs
new file mode 100644
index 000000000000..4c983537741a
--- /dev/null
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirementQueryTests.cs
@@ -0,0 +1,60 @@
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+using Bit.Core.AdminConsole.Repositories;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
+
+[SutProviderCustomize]
+public class PolicyRequirementQueryTests
+{
+ ///
+ /// Tests that the query correctly registers, retrieves and instantiates arbitrary IPolicyRequirements
+ /// according to their provided CreateRequirement delegate.
+ ///
+ [Theory, BitAutoData]
+ public async Task GetAsync_Works(Guid userId, Guid organizationId)
+ {
+ var policyRepository = Substitute.For();
+ var factories = new List>
+ {
+ // In prod this cast is handled when the CreateRequirement delegate is registered in DI
+ (RequirementFactory)TestPolicyRequirement.Create
+ };
+
+ var sut = new PolicyRequirementQuery(policyRepository, factories);
+ policyRepository.GetPolicyDetailsByUserId(userId).Returns([
+ new PolicyDetails
+ {
+ OrganizationId = organizationId
+ }
+ ]);
+
+ var requirement = await sut.GetAsync(userId);
+ Assert.Equal(organizationId, requirement.OrganizationId);
+ }
+
+ [Theory, BitAutoData]
+ public async Task GetAsync_ThrowsIfNoRequirementRegistered(Guid userId)
+ {
+ var policyRepository = Substitute.For();
+ var sut = new PolicyRequirementQuery(policyRepository, []);
+
+ var exception = await Assert.ThrowsAsync(()
+ => sut.GetAsync(userId));
+ Assert.Contains("No Policy Requirement found", exception.Message);
+ }
+
+ ///
+ /// Intentionally simplified PolicyRequirement that just holds the Policy.OrganizationId for us to assert against.
+ ///
+ private class TestPolicyRequirement : IPolicyRequirement
+ {
+ public Guid OrganizationId { get; init; }
+ public static TestPolicyRequirement Create(IEnumerable policyDetails)
+ => new() { OrganizationId = policyDetails.Single().OrganizationId };
+ }
+}
diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/OrganizationTestHelpers.cs b/test/Infrastructure.IntegrationTest/AdminConsole/OrganizationTestHelpers.cs
new file mode 100644
index 000000000000..56beabd47027
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/AdminConsole/OrganizationTestHelpers.cs
@@ -0,0 +1,58 @@
+using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Repositories;
+
+namespace Bit.Infrastructure.IntegrationTest.AdminConsole;
+
+///
+/// A set of extension methods used to arrange simple test data.
+/// This should only be used for basic, repetitive data arrangement, not for anything complex or for
+/// the repository method under test.
+///
+public static class OrganizationTestHelpers
+{
+ public static Task CreateTestUserAsync(this IUserRepository userRepository, string identifier = "test")
+ {
+ var id = Guid.NewGuid();
+ return userRepository.CreateAsync(new User
+ {
+ Id = id,
+ Name = $"{identifier}-{id}",
+ Email = $"{id}@example.com",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ });
+ }
+
+ public static Task CreateTestOrganizationAsync(this IOrganizationRepository organizationRepository,
+ string identifier = "test")
+ => organizationRepository.CreateAsync(new Organization
+ {
+ Name = $"{identifier}-{Guid.NewGuid()}",
+ BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL
+ Plan = "Test", // TODO: EF does not enforce this being NOT NULl
+ });
+
+ public static Task CreateTestOrganizationUserAsync(
+ this IOrganizationUserRepository organizationUserRepository,
+ Organization organization,
+ User user,
+ OrganizationUserType role = OrganizationUserType.Owner)
+ => organizationUserRepository.CreateAsync(new OrganizationUser
+ {
+ OrganizationId = organization.Id,
+ UserId = user.Id,
+ Status = OrganizationUserStatusType.Confirmed,
+ Type = role
+ });
+
+ public static Task CreateTestGroupAsync(
+ this IGroupRepository groupRepository,
+ Organization organization,
+ string identifier = "test")
+ => groupRepository.CreateAsync(
+ new Group { OrganizationId = organization.Id, Name = $"{identifier} {Guid.NewGuid()}" }
+ );
+}
diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs
new file mode 100644
index 000000000000..b2226a19a05a
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs
@@ -0,0 +1,297 @@
+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.Models.Data.Organizations.Policies;
+using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.Billing.Enums;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Models.Data;
+using Bit.Core.Repositories;
+using Bit.Core.Utilities;
+using Xunit;
+
+namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.PolicyRepository;
+
+public class GetPolicyDetailsByUserIdTests
+{
+ [DatabaseTheory, DatabaseData]
+ public async Task GetPolicyDetailsByUserId_NonInvitedUsers_Works(
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationRepository organizationRepository,
+ IPolicyRepository policyRepository)
+ {
+ // Arrange
+ // OrgUser1 - owner of org1
+ var user = await userRepository.CreateTestUserAsync();
+ var org1 = await CreateEnterpriseOrg(organizationRepository);
+ var orgUser1 = await organizationUserRepository.CreateTestOrganizationUserAsync(org1, user, role: OrganizationUserType.Owner);
+ await policyRepository.CreateAsync(new Policy
+ {
+ OrganizationId = org1.Id,
+ Enabled = true,
+ Type = PolicyType.SingleOrg,
+ Data = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = true, IntSetting = 5 })
+ });
+
+ // OrgUser2 - custom user of org2
+ var org2 = await CreateEnterpriseOrg(organizationRepository);
+ var orgUser2 = new OrganizationUser
+ {
+ OrganizationId = org2.Id,
+ UserId = user.Id,
+ Status = OrganizationUserStatusType.Confirmed,
+ Type = OrganizationUserType.Custom,
+ };
+ orgUser2.SetPermissions(new Permissions
+ {
+ ManagePolicies = true
+ });
+ await organizationUserRepository.CreateAsync(orgUser2);
+ await policyRepository.CreateAsync(new Policy
+ {
+ OrganizationId = org2.Id,
+ Enabled = true,
+ Type = PolicyType.SingleOrg,
+ Data = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = false, IntSetting = 15 })
+ });
+
+ // Act
+ var policyDetails = (await policyRepository.GetPolicyDetailsByUserId(user.Id)).ToList();
+
+ // Assert
+ Assert.Equal(2, policyDetails.Count);
+
+ var actualPolicyDetails1 = policyDetails.Find(p => p.OrganizationUserId == orgUser1.Id);
+ var expectedPolicyDetails1 = new PolicyDetails
+ {
+ OrganizationUserId = orgUser1.Id,
+ OrganizationId = org1.Id,
+ PolicyType = PolicyType.SingleOrg,
+ PolicyData = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = true, IntSetting = 5 }),
+ OrganizationUserType = OrganizationUserType.Owner,
+ OrganizationUserStatus = OrganizationUserStatusType.Confirmed,
+ OrganizationUserPermissionsData = null,
+ IsProvider = false
+ };
+ Assert.Equivalent(expectedPolicyDetails1, actualPolicyDetails1);
+ Assert.Equivalent(expectedPolicyDetails1.GetDataModel(), new TestPolicyData { BoolSetting = true, IntSetting = 5 });
+
+ var actualPolicyDetails2 = policyDetails.Find(p => p.OrganizationUserId == orgUser2.Id);
+ var expectedPolicyDetails2 = new PolicyDetails
+ {
+ OrganizationUserId = orgUser2.Id,
+ OrganizationId = org2.Id,
+ PolicyType = PolicyType.SingleOrg,
+ PolicyData = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = false, IntSetting = 15 }),
+ OrganizationUserType = OrganizationUserType.Custom,
+ OrganizationUserStatus = OrganizationUserStatusType.Confirmed,
+ OrganizationUserPermissionsData = CoreHelpers.ClassToJsonData(new Permissions { ManagePolicies = true }),
+ IsProvider = false
+ };
+ Assert.Equivalent(expectedPolicyDetails2, actualPolicyDetails2);
+ Assert.Equivalent(expectedPolicyDetails2.GetDataModel(), new TestPolicyData { BoolSetting = false, IntSetting = 15 });
+ Assert.Equivalent(new Permissions { ManagePolicies = true }, actualPolicyDetails2.GetOrganizationUserCustomPermissions(), strict: true);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetPolicyDetailsByUserId_InvitedUsers_Works(
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationRepository organizationRepository,
+ IPolicyRepository policyRepository)
+ {
+ // Arrange
+ var user = await userRepository.CreateTestUserAsync();
+ var org = await CreateEnterpriseOrg(organizationRepository);
+ var orgUser = new OrganizationUser
+ {
+ OrganizationId = org.Id,
+ UserId = null, // invited users have null userId
+ Status = OrganizationUserStatusType.Invited,
+ Type = OrganizationUserType.Custom,
+ Email = user.Email // invited users have matching Email
+ };
+ await organizationUserRepository.CreateAsync(orgUser);
+ await policyRepository.CreateAsync(new Policy
+ {
+ OrganizationId = org.Id,
+ Enabled = true,
+ Type = PolicyType.SingleOrg,
+ });
+
+ // Act
+ var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
+
+ // Assert
+ var expectedPolicyDetails = new PolicyDetails
+ {
+ OrganizationUserId = orgUser.Id,
+ OrganizationId = org.Id,
+ PolicyType = PolicyType.SingleOrg,
+ OrganizationUserType = OrganizationUserType.Custom,
+ OrganizationUserStatus = OrganizationUserStatusType.Invited,
+ IsProvider = false
+ };
+
+ Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single());
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetPolicyDetailsByUserId_SetsIsProvider(
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationRepository organizationRepository,
+ IPolicyRepository policyRepository,
+ IProviderRepository providerRepository,
+ IProviderUserRepository providerUserRepository,
+ IProviderOrganizationRepository providerOrganizationRepository)
+ {
+ // Arrange
+ var user = await userRepository.CreateTestUserAsync();
+ var org = await CreateEnterpriseOrg(organizationRepository);
+ var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org, user);
+ await policyRepository.CreateAsync(new Policy
+ {
+ OrganizationId = org.Id,
+ Enabled = true,
+ Type = PolicyType.SingleOrg,
+ });
+
+ // Arrange provider
+ var provider = await providerRepository.CreateAsync(new Provider
+ {
+ Name = Guid.NewGuid().ToString(),
+ Enabled = true
+ });
+ await providerUserRepository.CreateAsync(new ProviderUser
+ {
+ ProviderId = provider.Id,
+ UserId = user.Id,
+ Status = ProviderUserStatusType.Confirmed
+ });
+ await providerOrganizationRepository.CreateAsync(new ProviderOrganization
+ {
+ OrganizationId = org.Id,
+ ProviderId = provider.Id
+ });
+
+ // Act
+ var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
+
+ // Assert
+ var expectedPolicyDetails = new PolicyDetails
+ {
+ OrganizationUserId = orgUser.Id,
+ OrganizationId = org.Id,
+ PolicyType = PolicyType.SingleOrg,
+ OrganizationUserType = OrganizationUserType.Owner,
+ OrganizationUserStatus = OrganizationUserStatusType.Confirmed,
+ IsProvider = true
+ };
+
+ Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single());
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetPolicyDetailsByUserId_IgnoresDisabledOrganizations(
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationRepository organizationRepository,
+ IPolicyRepository policyRepository)
+ {
+ // Arrange
+ var user = await userRepository.CreateTestUserAsync();
+ var org = await CreateEnterpriseOrg(organizationRepository);
+ await organizationUserRepository.CreateTestOrganizationUserAsync(org, user);
+ await policyRepository.CreateAsync(new Policy
+ {
+ OrganizationId = org.Id,
+ Enabled = true,
+ Type = PolicyType.SingleOrg,
+ });
+
+ // Org is disabled; its policies remain, but it is now inactive
+ org.Enabled = false;
+ await organizationRepository.ReplaceAsync(org);
+
+ // Act
+ var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
+
+ // Assert
+ Assert.Empty(actualPolicyDetails);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetPolicyDetailsByUserId_IgnoresDowngradedOrganizations(
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationRepository organizationRepository,
+ IPolicyRepository policyRepository)
+ {
+ // Arrange
+ var user = await userRepository.CreateTestUserAsync();
+ var org = await CreateEnterpriseOrg(organizationRepository);
+ await organizationUserRepository.CreateTestOrganizationUserAsync(org, user);
+ await policyRepository.CreateAsync(new Policy
+ {
+ OrganizationId = org.Id,
+ Enabled = true,
+ Type = PolicyType.SingleOrg,
+ });
+
+ // Org is downgraded; its policies remain but its plan no longer supports them
+ org.UsePolicies = false;
+ org.PlanType = PlanType.TeamsAnnually;
+ await organizationRepository.ReplaceAsync(org);
+
+ // Act
+ var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
+
+ // Assert
+ Assert.Empty(actualPolicyDetails);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetPolicyDetailsByUserId_IgnoresDisabledPolicies(
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationRepository organizationRepository,
+ IPolicyRepository policyRepository)
+ {
+ // Arrange
+ var user = await userRepository.CreateTestUserAsync();
+ var org = await CreateEnterpriseOrg(organizationRepository);
+ await organizationUserRepository.CreateTestOrganizationUserAsync(org, user);
+ await policyRepository.CreateAsync(new Policy
+ {
+ OrganizationId = org.Id,
+ Enabled = false,
+ Type = PolicyType.SingleOrg,
+ });
+
+ // Act
+ var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
+
+ // Assert
+ Assert.Empty(actualPolicyDetails);
+ }
+
+ private class TestPolicyData : IPolicyDataModel
+ {
+ public bool BoolSetting { get; set; }
+ public int IntSetting { get; set; }
+ }
+
+ private Task CreateEnterpriseOrg(IOrganizationRepository organizationRepository)
+ => organizationRepository.CreateAsync(new Organization
+ {
+ Name = Guid.NewGuid().ToString(),
+ BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL
+ Plan = "Test", // TODO: EF does not enforce this being NOT NULl
+ PlanType = PlanType.EnterpriseAnnually,
+ UsePolicies = true
+ });
+}
diff --git a/util/Migrator/DbScripts/2025-02-11_00_PolicyDetails_ReadByUserId.sql b/util/Migrator/DbScripts/2025-02-11_00_PolicyDetails_ReadByUserId.sql
new file mode 100644
index 000000000000..24154230be74
--- /dev/null
+++ b/util/Migrator/DbScripts/2025-02-11_00_PolicyDetails_ReadByUserId.sql
@@ -0,0 +1,37 @@
+CREATE OR ALTER PROCEDURE [dbo].[PolicyDetails_ReadByUserId]
+ @UserId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+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,
+ CASE WHEN EXISTS (
+ SELECT 1
+ FROM [dbo].[ProviderUserView] PU
+ INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId]
+ WHERE PU.[UserId] = OU.[UserId] AND PO.[OrganizationId] = P.[OrganizationId]
+ ) THEN 1 ELSE 0 END AS IsProvider
+FROM [dbo].[PolicyView] P
+INNER JOIN [dbo].[OrganizationUserView] OU
+ ON P.[OrganizationId] = OU.[OrganizationId]
+INNER JOIN [dbo].[OrganizationView] O
+ ON P.[OrganizationId] = O.[Id]
+WHERE
+ P.Enabled = 1
+ AND O.Enabled = 1
+ AND O.UsePolicies = 1
+ AND (
+ (OU.[Status] != 0 AND OU.[UserId] = @UserId) -- OrgUsers who have accepted their invite and are linked to a UserId
+ OR EXISTS (
+ SELECT 1
+ FROM [dbo].[UserView] U
+ WHERE U.[Id] = @UserId AND OU.[Email] = U.[Email] AND OU.[Status] = 0 -- 'Invited' OrgUsers are not linked to a UserId yet, so we have to look up their email
+ )
+ )
+END