diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs new file mode 100644 index 000000000000..1a2b78fc8a60 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs @@ -0,0 +1,208 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; + +public class VNextSavePolicyCommand( + IApplicationCacheService applicationCacheService, + IEventService eventService, + IPolicyRepository policyRepository, + IEnumerable policyValidationEventHandlers, + TimeProvider timeProvider, + IPolicyEventHandlerFactory policyEventHandlerFactory) + : IVNextSavePolicyCommand +{ + private readonly IReadOnlyDictionary _policyValidationEvents = MapToDictionary(policyValidationEventHandlers); + + private static Dictionary MapToDictionary(IEnumerable policyValidationEventHandlers) + { + var policyValidationEventsDict = new Dictionary(); + foreach (var policyValidationEvent in policyValidationEventHandlers) + { + if (!policyValidationEventsDict.TryAdd(policyValidationEvent.Type, policyValidationEvent)) + { + throw new Exception($"Duplicate PolicyValidationEvent for {policyValidationEvent.Type} policy."); + } + } + return policyValidationEventsDict; + } + + public async Task SaveAsync(SavePolicyModel policyRequest) + { + var policyUpdateRequest = policyRequest.PolicyUpdate; + var organizationId = policyUpdateRequest.OrganizationId; + + await EnsureOrganizationCanUsePolicyAsync(organizationId); + + var savedPoliciesDict = await GetCurrentPolicyStateAsync(organizationId); + + var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdateRequest.Type); + + ValidatePolicyDependencies(policyUpdateRequest, currentPolicy, savedPoliciesDict); + + await ValidateTargetedPolicyAsync(policyRequest, currentPolicy); + + await ExecutePreUpsertSideEffectAsync(policyRequest, currentPolicy); + + var upsertedPolicy = await UpsertPolicyAsync(policyUpdateRequest); + + await eventService.LogPolicyEventAsync(upsertedPolicy, EventType.Policy_Updated); + + await ExecutePostUpsertSideEffectAsync(policyRequest, upsertedPolicy, currentPolicy); + + return upsertedPolicy; + } + + private async Task EnsureOrganizationCanUsePolicyAsync(Guid organizationId) + { + var org = await applicationCacheService.GetOrganizationAbilityAsync(organizationId); + if (org == null) + { + throw new BadRequestException("Organization not found"); + } + + if (!org.UsePolicies) + { + throw new BadRequestException("This organization cannot use policies."); + } + } + + private async Task UpsertPolicyAsync(PolicyUpdate policyUpdateRequest) + { + var policy = await policyRepository.GetByOrganizationIdTypeAsync(policyUpdateRequest.OrganizationId, policyUpdateRequest.Type) + ?? new Policy + { + OrganizationId = policyUpdateRequest.OrganizationId, + Type = policyUpdateRequest.Type, + CreationDate = timeProvider.GetUtcNow().UtcDateTime + }; + + policy.Enabled = policyUpdateRequest.Enabled; + policy.Data = policyUpdateRequest.Data; + policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; + + await policyRepository.UpsertAsync(policy); + + return policy; + } + + private async Task ValidateTargetedPolicyAsync(SavePolicyModel policyRequest, + Policy? currentPolicy) + { + await ExecutePolicyEventAsync( + policyRequest.PolicyUpdate.Type, + async validator => + { + var validationError = await validator.ValidateAsync(policyRequest, currentPolicy); + if (!string.IsNullOrEmpty(validationError)) + { + throw new BadRequestException(validationError); + } + }); + } + + private void ValidatePolicyDependencies( + PolicyUpdate policyUpdateRequest, + Policy? currentPolicy, + Dictionary savedPoliciesDict) + { + var result = policyEventHandlerFactory.GetHandler(policyUpdateRequest.Type); + + result.Switch( + validator => + { + var isCurrentlyEnabled = currentPolicy?.Enabled == true; + + switch (policyUpdateRequest.Enabled) + { + case true when !isCurrentlyEnabled: + ValidateEnablingRequirements(validator, savedPoliciesDict); + return; + case false when isCurrentlyEnabled: + ValidateDisablingRequirements(validator, policyUpdateRequest.Type, savedPoliciesDict); + break; + } + }, + _ => { }); + } + + private void ValidateDisablingRequirements( + IEnforceDependentPoliciesEvent validator, + PolicyType policyType, + Dictionary savedPoliciesDict) + { + var dependentPolicyTypes = _policyValidationEvents.Values + .Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyType)) + .Select(otherValidator => otherValidator.Type) + .Where(otherPolicyType => savedPoliciesDict.TryGetValue(otherPolicyType, out var savedPolicy) && + savedPolicy.Enabled) + .ToList(); + + switch (dependentPolicyTypes) + { + case { Count: 1 }: + throw new BadRequestException($"Turn off the {dependentPolicyTypes.First().GetName()} policy because it requires the {validator.Type.GetName()} policy."); + case { Count: > 1 }: + throw new BadRequestException($"Turn off all of the policies that require the {validator.Type.GetName()} policy."); + } + } + + private static void ValidateEnablingRequirements( + IEnforceDependentPoliciesEvent validator, + Dictionary savedPoliciesDict) + { + var missingRequiredPolicyTypes = validator.RequiredPolicies + .Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true }) + .ToList(); + + if (missingRequiredPolicyTypes.Count != 0) + { + throw new BadRequestException($"Turn on the {missingRequiredPolicyTypes.First().GetName()} policy because it is required for the {validator.Type.GetName()} policy."); + } + } + + private async Task ExecutePreUpsertSideEffectAsync( + SavePolicyModel policyRequest, + Policy? currentPolicy) + { + await ExecutePolicyEventAsync( + policyRequest.PolicyUpdate.Type, + handler => handler.ExecutePreUpsertSideEffectAsync(policyRequest, currentPolicy)); + } + private async Task ExecutePostUpsertSideEffectAsync( + SavePolicyModel policyRequest, + Policy postUpsertedPolicyState, + Policy? previousPolicyState) + { + await ExecutePolicyEventAsync( + policyRequest.PolicyUpdate.Type, + handler => handler.ExecutePostUpsertSideEffectAsync( + policyRequest, + postUpsertedPolicyState, + previousPolicyState)); + } + + private async Task ExecutePolicyEventAsync(PolicyType type, Func func) where T : IPolicyUpdateEvent + { + var handler = policyEventHandlerFactory.GetHandler(type); + + await handler.Match( + async h => await func(h), + _ => Task.CompletedTask + ); + } + + private async Task> GetCurrentPolicyStateAsync(Guid organizationId) + { + var savedPolicies = await policyRepository.GetManyByOrganizationIdAsync(organizationId); + // Note: policies may be missing from this dict if they have never been enabled + var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); + return savedPoliciesDict; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs index d1a52f008011..cad786234cd2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs @@ -16,6 +16,8 @@ public record PolicyUpdate public PolicyType Type { get; set; } public string? Data { get; set; } public bool Enabled { get; set; } + + [Obsolete("Please use SavePolicyModel.PerformedBy instead.")] public IActingUser? PerformedBy { get; set; } public T GetDataModel() where T : IPolicyDataModel, new() diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 5433d70410c6..f35ff8742496 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services.Implementations; @@ -13,7 +15,9 @@ public static void AddPolicyServices(this IServiceCollection services) { services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddPolicyValidators(); services.AddPolicyRequirements(); diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs new file mode 100644 index 000000000000..798417ae7c25 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs @@ -0,0 +1,12 @@ +using Bit.Core.AdminConsole.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent +{ + /// + /// PolicyTypes that must be enabled before this policy can be enabled, if any. + /// These dependencies will be checked when this policy is enabled and when any required policy is disabled. + /// + public IEnumerable RequiredPolicies { get; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPostUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPostUpdateEvent.cs new file mode 100644 index 000000000000..08295bf7fbdd --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPostUpdateEvent.cs @@ -0,0 +1,18 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +public interface IOnPolicyPostUpdateEvent : IPolicyUpdateEvent +{ + /// + /// Performs side effects after a policy has been upserted. + /// For example, this can be used for cleanup tasks or notifications. + /// + /// The policy save request + /// The policy after it was upserted + /// The policy state before it was updated, if any + public Task ExecutePostUpsertSideEffectAsync( + SavePolicyModel policyRequest, + Policy postUpsertedPolicyState, + Policy? previousPolicyState); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs new file mode 100644 index 000000000000..278a17f35eaf --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent +{ + /// + /// Performs side effects before a policy is upserted. + /// For example, this can be used to remove non-compliant users from the organization. + /// + /// The policy save request containing the policy update and metadata + /// The current policy, if any + public Task ExecutePreUpsertSideEffectAsync( + SavePolicyModel policyRequest, + Policy? currentPolicy); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyEventHandlerFactory.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyEventHandlerFactory.cs new file mode 100644 index 000000000000..f44ae867dd40 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyEventHandlerFactory.cs @@ -0,0 +1,30 @@ +#nullable enable + +using Bit.Core.AdminConsole.Enums; +using OneOf; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +/// +/// Provides policy-specific event handlers used during the save workflow in . +/// +/// +/// Supported handlers: +/// - for dependency checks +/// - for custom validation +/// - for pre-save logic +/// - for post-save logic +/// +public interface IPolicyEventHandlerFactory +{ + /// + /// Gets the event handler for the given policy type and handler interface. + /// + /// Handler type implementing . + /// The policy type to resolve. + /// + /// — the handler if available, or None if not implemented. + /// + OneOf GetHandler(PolicyType policyType) where T : IPolicyUpdateEvent; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs new file mode 100644 index 000000000000..ded1a14f1ab6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs @@ -0,0 +1,11 @@ +using Bit.Core.AdminConsole.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +public interface IPolicyUpdateEvent +{ + /// + /// The policy type that the associated handler will handle. + /// + public PolicyType Type { get; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs new file mode 100644 index 000000000000..6d486e1fa0d2 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +public interface IPolicyValidationEvent : IPolicyUpdateEvent +{ + /// + /// Performs side effects after a policy is validated but before it is saved. + /// For example, this can be used to remove non-compliant users from the organization. + /// Implementation is optional; by default, it will not perform any side effects. + /// + /// The policy save request containing the policy update and metadata + /// The current policy, if any + public Task ValidateAsync( + SavePolicyModel policyRequest, + Policy? currentPolicy); + +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IVNextSavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IVNextSavePolicyCommand.cs new file mode 100644 index 000000000000..93414539bb7e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IVNextSavePolicyCommand.cs @@ -0,0 +1,34 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Microsoft.Azure.NotificationHubs.Messaging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +/// +/// Handles creating or updating organization policies with validation and side effect execution. +/// +/// +/// Workflow: +/// 1. Validates organization can use policies +/// 2. Validates required and dependent policies +/// 3. Runs policy-specific validation () +/// 4. Executes pre-save logic () +/// 5. Saves the policy +/// 6. Logs the event +/// 7. Executes post-save logic () +/// +public interface IVNextSavePolicyCommand +{ + /// + /// Performs the necessary validations, saves the policy and any side effects + /// + /// Policy data, acting user, and metadata. + /// The saved policy with updated revision and applied changes. + /// + /// Thrown if: + /// - The organization can’t use policies + /// - Dependent policies are missing or block changes + /// - Custom validation fails + /// + Task SaveAsync(SavePolicyModel policyRequest); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/PolicyEventHandlerHandlerFactory.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/PolicyEventHandlerHandlerFactory.cs new file mode 100644 index 000000000000..b1abfb2aaf18 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/PolicyEventHandlerHandlerFactory.cs @@ -0,0 +1,33 @@ + +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using OneOf; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents; + +public class PolicyEventHandlerHandlerFactory( + IEnumerable allEventHandlers) : IPolicyEventHandlerFactory +{ + public OneOf GetHandler(PolicyType policyType) where T : IPolicyUpdateEvent + { + var tEventHandlers = allEventHandlers.OfType().ToList(); + + var matchingHandlers = tEventHandlers.Where(h => h.Type == policyType).ToList(); + + if (matchingHandlers.Count > 1) + { + throw new InvalidOperationException( + $"Multiple {nameof(IPolicyUpdateEvent)} handlers of type {typeof(T).Name} found for {nameof(PolicyType)} {policyType}. " + + $"Expected one {typeof(T).Name} handler per {nameof(PolicyType)}."); + } + + var policyTEventHandler = matchingHandlers.SingleOrDefault(); + if (policyTEventHandler is null) + { + return new None(); + } + + return policyTEventHandler; + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlerHandlerFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlerHandlerFactoryTests.cs new file mode 100644 index 000000000000..61d24735b630 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlerHandlerFactoryTests.cs @@ -0,0 +1,124 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using OneOf.Types; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class PolicyEventHandlerHandlerFactoryTests +{ + [Fact] + public void GetHandler_ReturnsHandler_WhenHandlerExists() + { + // Arrange + var expectedHandler = new FakeSingleOrgDependencyEvent(); + var factory = new PolicyEventHandlerHandlerFactory([expectedHandler]); + + // Act + var result = factory.GetHandler(PolicyType.SingleOrg); + + // Assert + Assert.True(result.IsT0); + Assert.Equal(expectedHandler, result.AsT0); + } + + [Fact] + public void GetHandler_ReturnsNone_WhenHandlerDoesNotExist() + { + // Arrange + var factory = new PolicyEventHandlerHandlerFactory([new FakeSingleOrgDependencyEvent()]); + + // Act + var result = factory.GetHandler(PolicyType.RequireSso); + + // Assert + Assert.True(result.IsT1); + Assert.IsType(result.AsT1); + } + + [Fact] + public void GetHandler_ReturnsNone_WhenHandlerTypeDoesNotMatch() + { + // Arrange + var factory = new PolicyEventHandlerHandlerFactory([new FakeSingleOrgDependencyEvent()]); + + // Act + var result = factory.GetHandler(PolicyType.SingleOrg); + + // Assert + Assert.True(result.IsT1); + Assert.IsType(result.AsT1); + } + + [Fact] + public void GetHandler_ReturnsCorrectHandler_WhenMultipleHandlerTypesExist() + { + // Arrange + var dependencyEvent = new FakeSingleOrgDependencyEvent(); + var validationEvent = new FakeSingleOrgValidationEvent(); + var factory = new PolicyEventHandlerHandlerFactory([dependencyEvent, validationEvent]); + + // Act + var dependencyResult = factory.GetHandler(PolicyType.SingleOrg); + var validationResult = factory.GetHandler(PolicyType.SingleOrg); + + // Assert + Assert.True(dependencyResult.IsT0); + Assert.Equal(dependencyEvent, dependencyResult.AsT0); + + Assert.True(validationResult.IsT0); + Assert.Equal(validationEvent, validationResult.AsT0); + } + + [Fact] + public void GetHandler_ReturnsCorrectHandler_WhenMultiplePolicyTypesExist() + { + // Arrange + var singleOrgEvent = new FakeSingleOrgDependencyEvent(); + var requireSsoEvent = new FakeRequireSsoDependencyEvent(); + var factory = new PolicyEventHandlerHandlerFactory([singleOrgEvent, requireSsoEvent]); + + // Act + var singleOrgResult = factory.GetHandler(PolicyType.SingleOrg); + var requireSsoResult = factory.GetHandler(PolicyType.RequireSso); + + // Assert + Assert.True(singleOrgResult.IsT0); + Assert.Equal(singleOrgEvent, singleOrgResult.AsT0); + + Assert.True(requireSsoResult.IsT0); + Assert.Equal(requireSsoEvent, requireSsoResult.AsT0); + } + + [Fact] + public void GetHandler_Throws_WhenDuplicateHandlersExist() + { + // Arrange + var factory = new PolicyEventHandlerHandlerFactory([ + new FakeSingleOrgDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + // Act & Assert + var exception = Assert.Throws(() => + factory.GetHandler(PolicyType.SingleOrg)); + + Assert.Contains("Multiple IPolicyUpdateEvent handlers of type IEnforceDependentPoliciesEvent found for PolicyType SingleOrg", exception.Message); + Assert.Contains("Expected one IEnforceDependentPoliciesEvent handler per PolicyType", exception.Message); + } + + [Fact] + public void GetHandler_ReturnsNone_WhenNoHandlersProvided() + { + // Arrange + var factory = new PolicyEventHandlerHandlerFactory([]); + + // Act + var result = factory.GetHandler(PolicyType.SingleOrg); + + // Assert + Assert.True(result.IsT1); + Assert.IsType(result.AsT1); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEventFixtures.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEventFixtures.cs new file mode 100644 index 000000000000..4c5b23d6e186 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEventFixtures.cs @@ -0,0 +1,37 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using NSubstitute; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class FakeSingleOrgDependencyEvent : IEnforceDependentPoliciesEvent +{ + public PolicyType Type => PolicyType.SingleOrg; + public IEnumerable RequiredPolicies => []; +} + +public class FakeRequireSsoDependencyEvent : IEnforceDependentPoliciesEvent +{ + public PolicyType Type => PolicyType.RequireSso; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; +} + +public class FakeVaultTimeoutDependencyEvent : IEnforceDependentPoliciesEvent +{ + public PolicyType Type => PolicyType.MaximumVaultTimeout; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; +} + +public class FakeSingleOrgValidationEvent : IPolicyValidationEvent +{ + public PolicyType Type => PolicyType.SingleOrg; + + public readonly Func> ValidateAsyncMock = Substitute.For>>(); + + public Task ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy) + { + return ValidateAsyncMock(policyRequest, currentPolicy); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs new file mode 100644 index 000000000000..1510042446d0 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs @@ -0,0 +1,471 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using OneOf.Types; +using Xunit; +using EventType = Bit.Core.Enums.EventType; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class VNextSavePolicyCommandTests +{ + [Theory, BitAutoData] + public async Task SaveAsync_NewPolicy_Success([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate) + { + // Arrange + var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent(); + fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any(), Arg.Any()).Returns(""); + var sutProvider = SutProviderFactory( + [new FakeSingleOrgDependencyEvent()], + [fakePolicyValidationEvent]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + var newPolicy = new Policy + { + Type = policyUpdate.Type, + OrganizationId = policyUpdate.OrganizationId, + Enabled = false + }; + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([newPolicy]); + + var creationDate = sutProvider.GetDependency().Start; + + // Act + await sutProvider.Sut.SaveAsync(savePolicyModel); + + // Assert + await fakePolicyValidationEvent.ValidateAsyncMock + .Received(1) + .Invoke(Arg.Any(), Arg.Any()); + + await AssertPolicySavedAsync(sutProvider, policyUpdate); + + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(p => + p.CreationDate == creationDate && + p.RevisionDate == creationDate)); + } + + [Theory, BitAutoData] + public async Task SaveAsync_ExistingPolicy_Success( + [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy currentPolicy) + { + // Arrange + var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent(); + fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any(), Arg.Any()).Returns(""); + var sutProvider = SutProviderFactory( + [new FakeSingleOrgDependencyEvent()], + [fakePolicyValidationEvent]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + .Returns(currentPolicy); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy]); + + // Act + await sutProvider.Sut.SaveAsync(savePolicyModel); + + // Assert + await fakePolicyValidationEvent.ValidateAsyncMock + .Received(1) + .Invoke(Arg.Any(), currentPolicy); + + await AssertPolicySavedAsync(sutProvider, policyUpdate); + + + var revisionDate = sutProvider.GetDependency().Start; + + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(p => + p.Id == currentPolicy.Id && + p.OrganizationId == currentPolicy.OrganizationId && + p.Type == currentPolicy.Type && + p.CreationDate == currentPolicy.CreationDate && + p.RevisionDate == revisionDate)); + } + + [Fact] + public void Constructor_DuplicatePolicyDependencyEvents_Throws() + { + // Arrange & Act + var exception = Assert.Throws(() => + new VNextSavePolicyCommand( + Substitute.For(), + Substitute.For(), + Substitute.For(), + [new FakeSingleOrgDependencyEvent(), new FakeSingleOrgDependencyEvent()], + Substitute.For(), + Substitute.For())); + + // Assert + Assert.Contains("Duplicate PolicyValidationEvent for SingleOrg policy", exception.Message); + } + + [Theory, BitAutoData] + public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate) + { + // Arrange + var sutProvider = SutProviderFactory(); + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) + .Returns(Task.FromResult(null)); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Organization not found", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate) + { + // Arrange + var sutProvider = SutProviderFactory(); + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) + .Returns(new OrganizationAbility + { + Id = policyUpdate.OrganizationId, + UsePolicies = false + }); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("cannot use policies", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_RequiredPolicyIsNull_Throws( + [PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + var requireSsoPolicy = new Policy + { + Type = PolicyType.RequireSso, + OrganizationId = policyUpdate.OrganizationId, + Enabled = false + }; + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([requireSsoPolicy]); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_RequiredPolicyNotEnabled_Throws( + [PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + var requireSsoPolicy = new Policy + { + Type = PolicyType.RequireSso, + OrganizationId = policyUpdate.OrganizationId, + Enabled = false + }; + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([singleOrgPolicy, requireSsoPolicy]); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_RequiredPolicyEnabled_Success( + [PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + var requireSsoPolicy = new Policy + { + Type = PolicyType.RequireSso, + OrganizationId = policyUpdate.OrganizationId, + Enabled = false + }; + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([singleOrgPolicy, requireSsoPolicy]); + + // Act + await sutProvider.Sut.SaveAsync(savePolicyModel); + + // Assert + await AssertPolicySavedAsync(sutProvider, policyUpdate); + } + + [Theory, BitAutoData] + public async Task SaveAsync_DependentPolicyIsEnabled_Throws( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy currentPolicy, + [Policy(PolicyType.RequireSso)] Policy requireSsoPolicy) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy]); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Turn off the Require single sign-on authentication policy because it requires the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_MultipleDependentPoliciesAreEnabled_Throws( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy currentPolicy, + [Policy(PolicyType.RequireSso)] Policy requireSsoPolicy, + [Policy(PolicyType.MaximumVaultTimeout)] Policy vaultTimeoutPolicy) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent(), + new FakeVaultTimeoutDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy, vaultTimeoutPolicy]); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Turn off all of the policies that require the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_DependentPolicyNotEnabled_Success( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy currentPolicy, + [Policy(PolicyType.RequireSso, false)] Policy requireSsoPolicy) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy]); + + // Act + await sutProvider.Sut.SaveAsync(savePolicyModel); + + // Assert + await AssertPolicySavedAsync(sutProvider, policyUpdate); + } + + [Theory, BitAutoData] + public async Task SaveAsync_ThrowsOnValidationError([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate) + { + // Arrange + var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent(); + fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any(), Arg.Any()).Returns("Validation error!"); + var sutProvider = SutProviderFactory( + [new FakeSingleOrgDependencyEvent()], + [fakePolicyValidationEvent]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + var singleOrgPolicy = new Policy + { + Type = PolicyType.SingleOrg, + OrganizationId = policyUpdate.OrganizationId, + Enabled = false + }; + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([singleOrgPolicy]); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Validation error!", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + /// + /// Returns a new SutProvider with the PolicyDependencyEvents registered in the Sut. + /// + private static SutProvider SutProviderFactory( + IEnumerable? policyDependencyEvents = null, + IEnumerable? policyValidationEvents = null) + { + var policyEventHandlerFactory = Substitute.For(); + + // Setup factory to return handlers based on type + policyEventHandlerFactory.GetHandler(Arg.Any()) + .Returns(callInfo => + { + var policyType = callInfo.Arg(); + var handler = policyDependencyEvents?.FirstOrDefault(e => e.Type == policyType); + return handler != null ? OneOf.OneOf.FromT0(handler) : OneOf.OneOf.FromT1(new None()); + }); + + policyEventHandlerFactory.GetHandler(Arg.Any()) + .Returns(callInfo => + { + var policyType = callInfo.Arg(); + var handler = policyValidationEvents?.FirstOrDefault(e => e.Type == policyType); + return handler != null ? OneOf.OneOf.FromT0(handler) : OneOf.OneOf.FromT1(new None()); + }); + + policyEventHandlerFactory.GetHandler(Arg.Any()) + .Returns(new None()); + + policyEventHandlerFactory.GetHandler(Arg.Any()) + .Returns(new None()); + + return new SutProvider() + .WithFakeTimeProvider() + .SetDependency(policyDependencyEvents ?? []) + .SetDependency(policyEventHandlerFactory) + .Create(); + } + + private static void ArrangeOrganization(SutProvider sutProvider, PolicyUpdate policyUpdate) + { + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) + .Returns(new OrganizationAbility + { + Id = policyUpdate.OrganizationId, + UsePolicies = true + }); + } + + private static async Task AssertPolicyNotSavedAsync(SutProvider sutProvider) + { + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpsertAsync(default!); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogPolicyEventAsync(default, default); + } + + private static async Task AssertPolicySavedAsync(SutProvider sutProvider, PolicyUpdate policyUpdate) + { + await sutProvider.GetDependency().Received(1).UpsertAsync(ExpectedPolicy()); + + await sutProvider.GetDependency().Received(1) + .LogPolicyEventAsync(ExpectedPolicy(), EventType.Policy_Updated); + + return; + + Policy ExpectedPolicy() => Arg.Is( + p => + p.Type == policyUpdate.Type + && p.OrganizationId == policyUpdate.OrganizationId + && p.Enabled == policyUpdate.Enabled + && p.Data == policyUpdate.Data); + } +}