diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index cbedb6355d..83ec244c47 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -278,6 +278,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); + await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs index 7ccb3f7807..cb72a51850 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -24,19 +25,19 @@ public enum OrganizationDataOwnershipState /// public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement { - private readonly IEnumerable _organizationIdsWithPolicyEnabled; + private readonly IEnumerable _policyDetails; /// /// The organization data ownership state for the user. /// - /// - /// The collection of Organization IDs that have the Organization Data Ownership policy enabled. + /// + /// An enumerable collection of PolicyDetails for the organizations. /// public OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState organizationDataOwnershipState, - IEnumerable organizationIdsWithPolicyEnabled) + IEnumerable policyDetails) { - _organizationIdsWithPolicyEnabled = organizationIdsWithPolicyEnabled ?? []; + _policyDetails = policyDetails; State = organizationDataOwnershipState; } @@ -46,14 +47,34 @@ public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement public OrganizationDataOwnershipState State { get; } /// - /// Returns true if the Organization Data Ownership policy is enforced in that organization. + /// Gets a default collection request for enforcing the Organization Data Ownership policy. + /// Only confirmed users are applicable. + /// This indicates whether the user should have a default collection created for them when the policy is enabled, + /// and if so, the relevant OrganizationUserId to create the collection for. /// - public bool RequiresDefaultCollection(Guid organizationId) + /// The organization ID to create the request for. + /// A DefaultCollectionRequest containing the OrganizationUserId and a flag indicating whether to create a default collection. + public DefaultCollectionRequest GetDefaultCollectionRequestOnPolicyEnable(Guid organizationId) { - return _organizationIdsWithPolicyEnabled.Contains(organizationId); + var policyDetail = _policyDetails + .FirstOrDefault(p => p.OrganizationId == organizationId); + + if (policyDetail != null && policyDetail.HasStatus([OrganizationUserStatusType.Confirmed])) + { + return new DefaultCollectionRequest(policyDetail.OrganizationUserId, true); + } + + var noCollectionNeeded = new DefaultCollectionRequest(Guid.Empty, false); + return noCollectionNeeded; } } +public record DefaultCollectionRequest(Guid OrganizationUserId, bool ShouldCreateDefaultCollection) +{ + public readonly bool ShouldCreateDefaultCollection = ShouldCreateDefaultCollection; + public readonly Guid OrganizationUserId = OrganizationUserId; +} + public class OrganizationDataOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory { public override PolicyType PolicyType => PolicyType.OrganizationDataOwnership; @@ -63,10 +84,9 @@ public class OrganizationDataOwnershipPolicyRequirementFactory : BasePolicyRequi var organizationDataOwnershipState = policyDetails.Any() ? OrganizationDataOwnershipState.Enabled : OrganizationDataOwnershipState.Disabled; - var organizationIdsWithPolicyEnabled = policyDetails.Select(p => p.OrganizationId).ToHashSet(); return new OrganizationDataOwnershipPolicyRequirement( organizationDataOwnershipState, - organizationIdsWithPolicyEnabled); + policyDetails); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index e31e9d44c9..12dd3f973d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -27,6 +27,8 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + // This validator will be hooked up in https://bitwarden.atlassian.net/browse/PM-24279. + // services.AddScoped(); } private static void AddPolicyRequirements(this IServiceCollection services) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs new file mode 100644 index 0000000000..2471bda647 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs @@ -0,0 +1,68 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class OrganizationDataOwnershipPolicyValidator( + IPolicyRepository policyRepository, + ICollectionRepository collectionRepository, + IEnumerable> factories, + IFeatureService featureService, + ILogger logger) + : OrganizationPolicyValidator(policyRepository, factories) +{ + public override PolicyType Type => PolicyType.OrganizationDataOwnership; + + public override IEnumerable RequiredPolicies => []; + + public override Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); + + public override async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (!featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) + { + return; + } + + if (currentPolicy?.Enabled != true && policyUpdate.Enabled) + { + await UpsertDefaultCollectionsForUsersAsync(policyUpdate); + } + } + + private async Task UpsertDefaultCollectionsForUsersAsync(PolicyUpdate policyUpdate) + { + var requirements = await GetUserPolicyRequirementsByOrganizationIdAsync(policyUpdate.OrganizationId, policyUpdate.Type); + + var userOrgIds = requirements + .Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId)) + .Where(request => request.ShouldCreateDefaultCollection) + .Select(request => request.OrganizationUserId); + + if (!userOrgIds.Any()) + { + logger.LogError("No UserOrganizationIds found for {OrganizationId}", policyUpdate.OrganizationId); + return; + } + + await collectionRepository.UpsertDefaultCollectionsAsync( + policyUpdate.OrganizationId, + userOrgIds, + GetDefaultUserCollectionName()); + } + + private static string GetDefaultUserCollectionName() + { + // TODO: https://bitwarden.atlassian.net/browse/PM-24279 + const string temporaryPlaceHolderValue = "Default"; + return temporaryPlaceHolderValue; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs new file mode 100644 index 0000000000..33667b829c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs @@ -0,0 +1,49 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public abstract class OrganizationPolicyValidator(IPolicyRepository policyRepository, IEnumerable> factories) : IPolicyValidator +{ + public abstract PolicyType Type { get; } + + public abstract IEnumerable RequiredPolicies { get; } + + protected async Task> GetUserPolicyRequirementsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) where T : IPolicyRequirement + { + var factory = factories.OfType>().SingleOrDefault(); + if (factory is null) + { + throw new NotImplementedException("No Requirement Factory found for " + typeof(T)); + } + + var policyDetails = await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType); + var policyDetailGroups = policyDetails.GroupBy(policyDetail => policyDetail.UserId); + var requirements = new List(); + + foreach (var policyDetailGroup in policyDetailGroups) + { + var filteredPolicies = policyDetailGroup + .Where(factory.Enforce) + // Prevent deferred execution from causing inconsistent tests. + .ToList(); + + requirements.Add(factory.Create(filteredPolicies)); + } + + return requirements; + } + + public abstract Task OnSaveSideEffectsAsync( + PolicyUpdate policyUpdate, + Policy? currentPolicy + ); + + public abstract Task ValidateAsync( + PolicyUpdate policyUpdate, + Policy? currentPolicy + ); +} diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index 70bda3eb13..ca3e52751c 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -63,5 +63,5 @@ public interface ICollectionRepository : IRepository Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, IEnumerable users, IEnumerable groups); - Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName); + Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName); } diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index 77fbdff3ae..ad00ac7086 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -326,7 +326,7 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) { if (!affectedOrgUserIds.Any()) { diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 569e541163..021b5bcf16 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -793,7 +793,7 @@ public class CollectionRepository : Repository affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) { if (!affectedOrgUserIds.Any()) { diff --git a/test/Core.Test/AdminConsole/AutoFixture/OrganizationPolicyDetailsCustomization.cs b/test/Core.Test/AdminConsole/AutoFixture/OrganizationPolicyDetailsCustomization.cs new file mode 100644 index 0000000000..cf16563b8c --- /dev/null +++ b/test/Core.Test/AdminConsole/AutoFixture/OrganizationPolicyDetailsCustomization.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.Test.AdminConsole.AutoFixture; + +internal class OrganizationPolicyDetailsCustomization( + PolicyType policyType, + OrganizationUserType userType, + bool isProvider, + OrganizationUserStatusType userStatus) : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.PolicyType, policyType) + .With(o => o.OrganizationUserType, userType) + .With(o => o.IsProvider, isProvider) + .With(o => o.OrganizationUserStatus, userStatus) + .Without(o => o.PolicyData)); // avoid autogenerating invalid json data + } +} + +public class OrganizationPolicyDetailsAttribute( + PolicyType policyType, + OrganizationUserType userType = OrganizationUserType.User, + bool isProvider = false, + OrganizationUserStatusType userStatus = OrganizationUserStatusType.Confirmed) : CustomizeAttribute +{ + public override ICustomization GetCustomization(ParameterInfo parameter) + => new OrganizationPolicyDetailsCustomization(policyType, userType, isProvider, userStatus); +} diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs index 87ea390cb6..39d7732198 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs @@ -33,3 +33,5 @@ public class PolicyDetailsAttribute( public override ICustomization GetCustomization(ParameterInfo parameter) => new PolicyDetailsCustomization(policyType, userType, isProvider, userStatus); } + + diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index a8219ebcaa..31938fe4fc 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -479,7 +479,7 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .Received(1) - .CreateDefaultCollectionsAsync( + .UpsertDefaultCollectionsAsync( organization.Id, Arg.Is>(ids => ids.Contains(orgUser.Id)), collectionName); @@ -505,7 +505,7 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .DidNotReceive() - .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] @@ -531,6 +531,6 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .DidNotReceive() - .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs index 95037efb97..ab4788c808 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.Enums; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -30,24 +31,85 @@ public class OrganizationDataOwnershipPolicyRequirementFactoryTests } [Theory, BitAutoData] - public void RequiresDefaultCollection_WithNoPolicies_ReturnsFalse( - Guid organizationId, - SutProvider sutProvider) + public void PolicyType_ReturnsOrganizationDataOwnership(SutProvider sutProvider) { - var actual = sutProvider.Sut.Create([]); - - Assert.False(actual.RequiresDefaultCollection(organizationId)); + Assert.Equal(PolicyType.OrganizationDataOwnership, sutProvider.Sut.PolicyType); } [Theory, BitAutoData] - public void RequiresDefaultCollection_WithOrganizationDataOwnershipPolicies_ReturnsCorrectResult( - [PolicyDetails(PolicyType.OrganizationDataOwnership)] PolicyDetails[] policies, - Guid nonPolicyOrganizationId, + public void GetDefaultCollectionRequestOnPolicyEnable_WithConfirmedUser_ReturnsTrue( + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + var requirement = sutProvider.Sut.Create(policies); + var expectedOrganizationUserId = policies[0].OrganizationUserId; + var organizationId = policies[0].OrganizationId; + + // Act + var result = requirement.GetDefaultCollectionRequestOnPolicyEnable(organizationId); + + // Assert + Assert.Equal(expectedOrganizationUserId, result.OrganizationUserId); + Assert.True(result.ShouldCreateDefaultCollection); + } + + [Theory, BitAutoData] + public void GetDefaultCollectionRequestOnPolicyEnable_WithAcceptedUser_ReturnsFalse( + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Accepted)] PolicyDetails[] policies, SutProvider sutProvider) { - var actual = sutProvider.Sut.Create(policies); + // Arrange + var requirement = sutProvider.Sut.Create(policies); + var organizationId = policies[0].OrganizationId; - Assert.True(actual.RequiresDefaultCollection(policies[0].OrganizationId)); - Assert.False(actual.RequiresDefaultCollection(nonPolicyOrganizationId)); + // Act + var result = requirement.GetDefaultCollectionRequestOnPolicyEnable(organizationId); + + // Assert + Assert.Equal(Guid.Empty, result.OrganizationUserId); + Assert.False(result.ShouldCreateDefaultCollection); + } + + [Theory, BitAutoData] + public void GetDefaultCollectionRequestOnPolicyEnable_WithNoPolicies_ReturnsFalse( + SutProvider sutProvider) + { + // Arrange + var requirement = sutProvider.Sut.Create([]); + var organizationId = Guid.NewGuid(); + + // Act + var result = requirement.GetDefaultCollectionRequestOnPolicyEnable(organizationId); + + // Assert + Assert.Equal(Guid.Empty, result.OrganizationUserId); + Assert.False(result.ShouldCreateDefaultCollection); + } + + [Theory, BitAutoData] + public void GetDefaultCollectionRequestOnPolicyEnable_WithMixedStatuses( + [PolicyDetails(PolicyType.OrganizationDataOwnership)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + var requirement = sutProvider.Sut.Create(policies); + + var confirmedPolicy = policies[0]; + var acceptedPolicy = policies[1]; + + confirmedPolicy.OrganizationUserStatus = OrganizationUserStatusType.Confirmed; + acceptedPolicy.OrganizationUserStatus = OrganizationUserStatusType.Accepted; + + // Act + var confirmedResult = requirement.GetDefaultCollectionRequestOnPolicyEnable(confirmedPolicy.OrganizationId); + var acceptedResult = requirement.GetDefaultCollectionRequestOnPolicyEnable(acceptedPolicy.OrganizationId); + + // Assert + Assert.Equal(Guid.Empty, acceptedResult.OrganizationUserId); + Assert.False(acceptedResult.ShouldCreateDefaultCollection); + + Assert.Equal(confirmedPolicy.OrganizationUserId, confirmedResult.OrganizationUserId); + Assert.True(confirmedResult.ShouldCreateDefaultCollection); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs new file mode 100644 index 0000000000..2569bc6988 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs @@ -0,0 +1,239 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class OrganizationDataOwnershipPolicyValidatorTests +{ + private const string _defaultUserCollectionName = "Default"; + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_FeatureFlagDisabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(false); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_PolicyAlreadyEnabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + policyUpdate.Enabled = true; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_PolicyBeingDisabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_WhenNoUsersExist_ShouldLogError( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy, + OrganizationDataOwnershipPolicyRequirementFactory factory) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + policyUpdate.Enabled = true; + + var policyRepository = ArrangePolicyRepositoryWithOutUsers(); + var collectionRepository = Substitute.For(); + var logger = Substitute.For>(); + + var sut = ArrangeSut(factory, policyRepository, collectionRepository, logger); + + // Act + await sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await collectionRepository + .DidNotReceive() + .UpsertDefaultCollectionsAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()); + + const string expectedErrorMessage = "No UserOrganizationIds found for"; + + logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => (o.ToString() ?? "").Contains(expectedErrorMessage)), + Arg.Any(), + Arg.Any>()); + } + + public static IEnumerable ShouldUpsertDefaultCollectionsTestCases() + { + yield return WithExistingPolicy(); + + yield return WithNoExistingPolicy(); + yield break; + + object?[] WithExistingPolicy() + { + var organizationId = Guid.NewGuid(); + var policyUpdate = new PolicyUpdate + { + OrganizationId = organizationId, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true + }; + var currentPolicy = new Policy + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + Type = PolicyType.OrganizationDataOwnership, + Enabled = false + }; + + return new object?[] + { + policyUpdate, + currentPolicy + }; + } + + object?[] WithNoExistingPolicy() + { + var policyUpdate = new PolicyUpdate + { + OrganizationId = new Guid(), + Type = PolicyType.OrganizationDataOwnership, + Enabled = true + }; + + const Policy currentPolicy = null; + + return new object?[] + { + policyUpdate, + currentPolicy + }; + } + } + [Theory, BitAutoData] + [BitMemberAutoData(nameof(ShouldUpsertDefaultCollectionsTestCases))] + public async Task OnSaveSideEffectsAsync_WithRequirements_ShouldUpsertDefaultCollections( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy? currentPolicy, + [OrganizationPolicyDetails(PolicyType.OrganizationDataOwnership)] IEnumerable orgPolicyDetails, + OrganizationDataOwnershipPolicyRequirementFactory factory) + { + // Arrange + foreach (var policyDetail in orgPolicyDetails) + { + policyDetail.OrganizationId = policyUpdate.OrganizationId; + } + + var policyRepository = ArrangePolicyRepository(orgPolicyDetails); + var collectionRepository = Substitute.For(); + var logger = Substitute.For>(); + + var sut = ArrangeSut(factory, policyRepository, collectionRepository, logger); + + // Act + await sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await collectionRepository + .Received(1) + .UpsertDefaultCollectionsAsync( + policyUpdate.OrganizationId, + Arg.Is>(ids => ids.Count() == 3), + _defaultUserCollectionName); + } + + private static IPolicyRepository ArrangePolicyRepositoryWithOutUsers() + { + return ArrangePolicyRepository([]); + } + + private static IPolicyRepository ArrangePolicyRepository(IEnumerable policyDetails) + { + var policyRepository = Substitute.For(); + + policyRepository + .GetPolicyDetailsByOrganizationIdAsync(Arg.Any(), PolicyType.OrganizationDataOwnership) + .Returns(policyDetails); + return policyRepository; + } + + private static OrganizationDataOwnershipPolicyValidator ArrangeSut( + OrganizationDataOwnershipPolicyRequirementFactory factory, + IPolicyRepository policyRepository, + ICollectionRepository collectionRepository, + ILogger logger = null!) + { + logger ??= Substitute.For>(); + + var featureService = Substitute.For(); + featureService + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + + var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory], featureService, logger); + return sut; + } + +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs new file mode 100644 index 0000000000..aec1230423 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs @@ -0,0 +1,188 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class OrganizationPolicyValidatorTests +{ + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_WithNoFactory_ThrowsNotImplementedException( + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), []); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication)); + + Assert.Contains("No Requirement Factory found for", exception.Message); + } + + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_WithMultipleUsers_GroupsByUserId( + Guid organizationId, + Guid userId1, + Guid userId2, + SutProvider sutProvider) + { + // Arrange + var policyDetails = new List + { + new() { UserId = userId1, OrganizationId = organizationId }, + new() { UserId = userId1, OrganizationId = Guid.NewGuid() }, + new() { UserId = userId2, OrganizationId = organizationId } + }; + + var factory = Substitute.For>(); + factory.Create(Arg.Any>()).Returns(new TestPolicyRequirement()); + factory.Enforce(Arg.Any()).Returns(true); + + sutProvider.GetDependency() + .GetPolicyDetailsByOrganizationIdAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policyDetails); + + var factories = new List> { factory }; + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), factories); + + // Act + var result = await sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Equal(2, result.Count()); + + factory.Received(2).Create(Arg.Any>()); + factory.Received(1).Create(Arg.Is>( + results => results.Count() == 1 && results.First().UserId == userId2)); + factory.Received(1).Create(Arg.Is>( + results => results.Count() == 2 && results.First().UserId == userId1)); + } + + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_ShouldEnforceFilters( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange + var adminUser = new OrganizationPolicyDetails() + { + UserId = userId, + OrganizationId = organizationId, + OrganizationUserType = OrganizationUserType.Admin + }; + + var user = new OrganizationPolicyDetails() + { + UserId = userId, + OrganizationId = organizationId, + OrganizationUserType = OrganizationUserType.User + }; + + var policyDetails = new List + { + adminUser, + user + }; + sutProvider.GetDependency() + .GetPolicyDetailsByOrganizationIdAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policyDetails); + + var factory = Substitute.For>(); + factory.Create(Arg.Any>()).Returns(new TestPolicyRequirement()); + factory.Enforce(Arg.Is(p => p.OrganizationUserType == OrganizationUserType.Admin)) + .Returns(true); + factory.Enforce(Arg.Is(p => p.OrganizationUserType == OrganizationUserType.User)) + .Returns(false); + + var factories = new List> { factory }; + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), factories); + + // Act + var result = await sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Single(result); + + factory.Received(1).Create(Arg.Is>(policies => + policies.Count() == 1 && policies.First().OrganizationUserType == OrganizationUserType.Admin)); + + factory.Received(1).Enforce(Arg.Is(p => ReferenceEquals(p, adminUser))); + factory.Received(1).Enforce(Arg.Is(p => ReferenceEquals(p, user))); + factory.Received(2).Enforce(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_WithEmptyPolicyDetails_ReturnsEmptyCollection( + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + var factory = Substitute.For>(); + + sutProvider.GetDependency() + .GetPolicyDetailsByOrganizationIdAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(new List()); + + var factories = new List> { factory }; + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), factories); + + // Act + var result = await sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Empty(result); + factory.DidNotReceive().Create(Arg.Any>()); + } +} + +public class TestOrganizationPolicyValidator : OrganizationPolicyValidator +{ + public TestOrganizationPolicyValidator( + IPolicyRepository policyRepository, + IEnumerable>? factories = null) + : base(policyRepository, factories ?? []) + { + } + + public override PolicyType Type => PolicyType.TwoFactorAuthentication; + + public override IEnumerable RequiredPolicies => []; + + public override Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + return Task.FromResult(""); + } + + public override Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + return Task.CompletedTask; + } + + public async Task> TestGetUserPolicyRequirementsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) + where T : IPolicyRequirement + { + return await GetUserPolicyRequirementsByOrganizationIdAsync(organizationId, policyType); + } + +} + +public class TestPolicyRequirement : IPolicyRequirement +{ +} diff --git a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs index 1b50779c57..0cb0deaf52 100644 --- a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs +++ b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs @@ -1,5 +1,6 @@ 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.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; @@ -120,7 +121,7 @@ public class ImportCiphersAsyncCommandTests .GetAsync(userId) .Returns(new OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState.Enabled, - [Guid.NewGuid()])); + [new PolicyDetails()])); var folderRelationships = new List>(); diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 0cee6530c2..55db5a9143 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; 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.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; @@ -173,7 +174,7 @@ public class CipherServiceTests .GetAsync(savingUserId) .Returns(new OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState.Enabled, - [Guid.NewGuid()])); + [new PolicyDetails()])); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveDetailsAsync(cipher, savingUserId, null)); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs similarity index 91% rename from test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs rename to test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs index d85cc1e813..64dffa473f 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs @@ -6,10 +6,10 @@ using Xunit; namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository; -public class CreateDefaultCollectionsTests +public class UpsertDefaultCollectionsTests { - [DatabaseTheory, DatabaseData] - public async Task CreateDefaultCollectionsAsync_ShouldCreateDefaultCollection_WhenUsersDoNotHaveDefaultCollection( + [Theory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_ShouldCreateDefaultCollection_WhenUsersDoNotHaveDefaultCollection( IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -28,7 +28,7 @@ public class CreateDefaultCollectionsTests var defaultCollectionName = $"default-name-{organization.Id}"; // Act - await collectionRepository.CreateDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id); @@ -36,8 +36,8 @@ public class CreateDefaultCollectionsTests await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers); } - [DatabaseTheory, DatabaseData] - public async Task CreateDefaultCollectionsAsync_ShouldUpsertCreateDefaultCollection_ForUsersWithAndWithoutDefaultCollectionsExist( + [Theory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_ShouldUpsertCreateDefaultCollection_ForUsersWithAndWithoutDefaultCollectionsExist( IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -66,7 +66,7 @@ public class CreateDefaultCollectionsTests var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id); // Act - await collectionRepository.CreateDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, arrangedOrganizationUsers, organization.Id); @@ -74,8 +74,8 @@ public class CreateDefaultCollectionsTests await CleanupAsync(organizationRepository, userRepository, organization, affectedOrgUsers); } - [DatabaseTheory, DatabaseData] - public async Task CreateDefaultCollectionsAsync_ShouldNotCreateDefaultCollection_WhenUsersAlreadyHaveOne( + [Theory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_ShouldNotCreateDefaultCollection_WhenUsersAlreadyHaveOne( IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -96,7 +96,7 @@ public class CreateDefaultCollectionsTests await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers); // Act - await collectionRepository.CreateDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id); @@ -108,7 +108,7 @@ public class CreateDefaultCollectionsTests Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName, OrganizationUser[] resultOrganizationUsers) { - await collectionRepository.CreateDefaultCollectionsAsync(organizationId, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organizationId, affectedOrgUserIds, defaultCollectionName); await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organizationId); }