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