From dfa411131d49fb22ada6b86e62a4648be99a4286 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:18:34 +1000 Subject: [PATCH] [PM-13322] [BEEEP] Add PolicyValidators and refactor policy save logic (#4877) --- src/Core/AdminConsole/Enums/PolicyType.cs | 27 ++ .../Policies/IPolicyValidator.cs | 43 +++ .../Policies/ISavePolicyCommand.cs | 8 + .../Implementations/SavePolicyCommand.cs | 129 +++++++ .../Policies/Models/PolicyUpdate.cs | 28 ++ .../PolicyServiceCollectionExtensions.cs | 22 ++ .../MaximumVaultTimeoutPolicyValidator.cs | 15 + .../PolicyValidatorHelpers.cs | 33 ++ .../RequireSsoPolicyValidator.cs | 38 ++ .../ResetPasswordPolicyValidator.cs | 36 ++ .../SingleOrgPolicyValidator.cs | 101 ++++++ .../TwoFactorAuthenticationPolicyValidator.cs | 87 +++++ .../Services/Implementations/PolicyService.cs | 23 ++ src/Core/Constants.cs | 1 + .../Utilities/ServiceCollectionExtensions.cs | 3 +- test/Common/AutoFixture/SutProvider.cs | 1 - .../AutoFixture/SutProviderExtensions.cs | 16 + test/Common/Common.csproj | 1 + .../AutoFixture/PolicyFixtures.cs | 12 +- .../AutoFixture/PolicyUpdateFixtures.cs | 25 ++ .../Policies/PolicyValidatorFixtures.cs | 43 +++ .../Policies/PolicyValidatorHelpersTests.cs | 64 ++++ .../RequireSsoPolicyValidatorTests.cs | 75 ++++ .../ResetPasswordPolicyValidatorTests.cs | 71 ++++ .../SingleOrgPolicyValidatorTests.cs | 129 +++++++ ...actorAuthenticationPolicyValidatorTests.cs | 209 +++++++++++ .../Policies/SavePolicyCommandTests.cs | 330 ++++++++++++++++++ 27 files changed, 1564 insertions(+), 6 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/MaximumVaultTimeoutPolicyValidator.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/PolicyValidatorHelpers.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidator.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidator.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs create mode 100644 test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorFixtures.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorHelpersTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index 0e1786cf5..bdde3e424 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -16,3 +16,30 @@ public enum PolicyType : byte ActivateAutofill = 11, AutomaticAppLogIn = 12, } + +public static class PolicyTypeExtensions +{ + /// + /// Returns the name of the policy for display to the user. + /// Do not include the word "policy" in the return value. + /// + public static string GetName(this PolicyType type) + { + return type switch + { + PolicyType.TwoFactorAuthentication => "Require two-step login", + PolicyType.MasterPassword => "Master password requirements", + PolicyType.PasswordGenerator => "Password generator", + PolicyType.SingleOrg => "Single organization", + PolicyType.RequireSso => "Require single sign-on authentication", + PolicyType.PersonalOwnership => "Remove individual vault", + PolicyType.DisableSend => "Remove Send", + PolicyType.SendOptions => "Send options", + PolicyType.ResetPassword => "Account recovery administration", + PolicyType.MaximumVaultTimeout => "Vault timeout", + PolicyType.DisablePersonalVaultExport => "Remove individual vault export", + PolicyType.ActivateAutofill => "Active auto-fill", + PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications", + }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs new file mode 100644 index 000000000..6aef9f248 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs @@ -0,0 +1,43 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; + +/// +/// Defines behavior and functionality for a given PolicyType. +/// +public interface IPolicyValidator +{ + /// + /// The PolicyType that this definition relates to. + /// + public PolicyType Type { get; } + + /// + /// 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; } + + /// + /// Validates a policy before saving it. + /// Do not use this for simple dependencies between different policies - see instead. + /// Implementation is optional; by default it will not perform any validation. + /// + /// The policy update request + /// The current policy, if any + /// A validation error if validation was unsuccessful, otherwise an empty string + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy); + + /// + /// 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 update request + /// The current policy, if any + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs new file mode 100644 index 000000000..5bfdfc6aa --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; + +public interface ISavePolicyCommand +{ + Task SaveAsync(PolicyUpdate policy); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs new file mode 100644 index 000000000..01ffce2cc --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -0,0 +1,129 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +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 SavePolicyCommand : ISavePolicyCommand +{ + private readonly IApplicationCacheService _applicationCacheService; + private readonly IEventService _eventService; + private readonly IPolicyRepository _policyRepository; + private readonly IReadOnlyDictionary _policyValidators; + private readonly TimeProvider _timeProvider; + + public SavePolicyCommand( + IApplicationCacheService applicationCacheService, + IEventService eventService, + IPolicyRepository policyRepository, + IEnumerable policyValidators, + TimeProvider timeProvider) + { + _applicationCacheService = applicationCacheService; + _eventService = eventService; + _policyRepository = policyRepository; + _timeProvider = timeProvider; + + var policyValidatorsDict = new Dictionary(); + foreach (var policyValidator in policyValidators) + { + if (!policyValidatorsDict.TryAdd(policyValidator.Type, policyValidator)) + { + throw new Exception($"Duplicate PolicyValidator for {policyValidator.Type} policy."); + } + } + + _policyValidators = policyValidatorsDict; + } + + public async Task SaveAsync(PolicyUpdate policyUpdate) + { + var org = await _applicationCacheService.GetOrganizationAbilityAsync(policyUpdate.OrganizationId); + if (org == null) + { + throw new BadRequestException("Organization not found"); + } + + if (!org.UsePolicies) + { + throw new BadRequestException("This organization cannot use policies."); + } + + if (_policyValidators.TryGetValue(policyUpdate.Type, out var validator)) + { + await RunValidatorAsync(validator, policyUpdate); + } + + var policy = await _policyRepository.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + ?? new Policy + { + OrganizationId = policyUpdate.OrganizationId, + Type = policyUpdate.Type, + CreationDate = _timeProvider.GetUtcNow().UtcDateTime + }; + + policy.Enabled = policyUpdate.Enabled; + policy.Data = policyUpdate.Data; + policy.RevisionDate = _timeProvider.GetUtcNow().UtcDateTime; + + await _policyRepository.UpsertAsync(policy); + await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated); + } + + private async Task RunValidatorAsync(IPolicyValidator validator, PolicyUpdate policyUpdate) + { + var savedPolicies = await _policyRepository.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId); + // Note: policies may be missing from this dict if they have never been enabled + var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); + var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type); + + // If enabling this policy - check that all policy requirements are satisfied + if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled) + { + 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."); + } + } + + // If disabling this policy - ensure it's not required by any other policy + if (currentPolicy is { Enabled: true } && !policyUpdate.Enabled) + { + var dependentPolicyTypes = _policyValidators.Values + .Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyUpdate.Type)) + .Select(otherValidator => otherValidator.Type) + .Where(otherPolicyType => savedPoliciesDict.ContainsKey(otherPolicyType) && + savedPoliciesDict[otherPolicyType].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."); + } + } + + // Run other validation + var validationError = await validator.ValidateAsync(policyUpdate, currentPolicy); + if (!string.IsNullOrEmpty(validationError)) + { + throw new BadRequestException(validationError); + } + + // Run side effects + await validator.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs new file mode 100644 index 000000000..117a7ec73 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs @@ -0,0 +1,28 @@ +#nullable enable + +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +/// +/// A request for SavePolicyCommand to update a policy +/// +public record PolicyUpdate +{ + public Guid OrganizationId { get; set; } + public PolicyType Type { get; set; } + public string? Data { get; set; } + public bool Enabled { get; set; } + + public T GetDataModel() where T : IPolicyDataModel, new() + { + return CoreHelpers.LoadClassFromJsonData(Data); + } + + public void SetDataModel(T dataModel) where T : IPolicyDataModel, new() + { + Data = CoreHelpers.ClassToJsonData(dataModel); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs new file mode 100644 index 000000000..81096ef60 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Services; +using Bit.Core.AdminConsole.Services.Implementations; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; + +public static class PolicyServiceCollectionExtensions +{ + public static void AddPolicyServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/MaximumVaultTimeoutPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/MaximumVaultTimeoutPolicyValidator.cs new file mode 100644 index 000000000..bfd4dcfe0 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/MaximumVaultTimeoutPolicyValidator.cs @@ -0,0 +1,15 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class MaximumVaultTimeoutPolicyValidator : IPolicyValidator +{ + public PolicyType Type => PolicyType.MaximumVaultTimeout; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/PolicyValidatorHelpers.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/PolicyValidatorHelpers.cs new file mode 100644 index 000000000..1bbaf1aa1 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/PolicyValidatorHelpers.cs @@ -0,0 +1,33 @@ +#nullable enable + +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public static class PolicyValidatorHelpers +{ + /// + /// Validate that given Member Decryption Options are not enabled. + /// Used for validation when disabling a policy that is required by certain Member Decryption Options. + /// + /// The Member Decryption Options that require the policy to be enabled. + /// A validation error if validation was unsuccessful, otherwise an empty string + public static string ValidateDecryptionOptionsNotEnabled(this SsoConfig? ssoConfig, + MemberDecryptionType[] decryptionOptions) + { + if (ssoConfig is not { Enabled: true }) + { + return ""; + } + + return ssoConfig.GetData().MemberDecryptionType switch + { + MemberDecryptionType.KeyConnector when decryptionOptions.Contains(MemberDecryptionType.KeyConnector) + => "Key Connector is enabled and requires this policy.", + MemberDecryptionType.TrustedDeviceEncryption when decryptionOptions.Contains(MemberDecryptionType + .TrustedDeviceEncryption) => "Trusted device encryption is on and requires this policy.", + _ => "" + }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidator.cs new file mode 100644 index 000000000..2082d4305 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidator.cs @@ -0,0 +1,38 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class RequireSsoPolicyValidator : IPolicyValidator +{ + private readonly ISsoConfigRepository _ssoConfigRepository; + + public RequireSsoPolicyValidator(ISsoConfigRepository ssoConfigRepository) + { + _ssoConfigRepository = ssoConfigRepository; + } + + public PolicyType Type => PolicyType.RequireSso; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (policyUpdate is not { Enabled: true }) + { + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId); + return ssoConfig.ValidateDecryptionOptionsNotEnabled([ + MemberDecryptionType.KeyConnector, + MemberDecryptionType.TrustedDeviceEncryption + ]); + } + + return ""; + } + + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidator.cs new file mode 100644 index 000000000..1126c4b92 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidator.cs @@ -0,0 +1,36 @@ +#nullable enable + +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.Auth.Enums; +using Bit.Core.Auth.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class ResetPasswordPolicyValidator : IPolicyValidator +{ + private readonly ISsoConfigRepository _ssoConfigRepository; + public PolicyType Type => PolicyType.ResetPassword; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + + public ResetPasswordPolicyValidator(ISsoConfigRepository ssoConfigRepository) + { + _ssoConfigRepository = ssoConfigRepository; + } + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (policyUpdate is not { Enabled: true } || + policyUpdate.GetDataModel().AutoEnrollEnabled == false) + { + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId); + return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.TrustedDeviceEncryption]); + } + + return ""; + } + + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs new file mode 100644 index 000000000..3e1f8d26c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs @@ -0,0 +1,101 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Repositories; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class SingleOrgPolicyValidator : IPolicyValidator +{ + public PolicyType Type => PolicyType.SingleOrg; + + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IMailService _mailService; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly ICurrentContext _currentContext; + private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + + public SingleOrgPolicyValidator( + IOrganizationUserRepository organizationUserRepository, + IMailService mailService, + IOrganizationRepository organizationRepository, + ISsoConfigRepository ssoConfigRepository, + ICurrentContext currentContext, + IRemoveOrganizationUserCommand removeOrganizationUserCommand) + { + _organizationUserRepository = organizationUserRepository; + _mailService = mailService; + _organizationRepository = organizationRepository; + _ssoConfigRepository = ssoConfigRepository; + _currentContext = currentContext; + _removeOrganizationUserCommand = removeOrganizationUserCommand; + } + + public IEnumerable RequiredPolicies => []; + + public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) + { + await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); + } + } + + private async Task RemoveNonCompliantUsersAsync(Guid organizationId) + { + // Remove non-compliant users + var savingUserId = _currentContext.UserId; + // Note: must get OrganizationUserUserDetails so that Email is always populated from the User object + var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var org = await _organizationRepository.GetByIdAsync(organizationId); + if (org == null) + { + throw new NotFoundException("Organization not found."); + } + + var removableOrgUsers = orgUsers.Where(ou => + ou.Status != OrganizationUserStatusType.Invited && + ou.Status != OrganizationUserStatusType.Revoked && + ou.Type != OrganizationUserType.Owner && + ou.Type != OrganizationUserType.Admin && + ou.UserId != savingUserId + ).ToList(); + + var userOrgs = await _organizationUserRepository.GetManyByManyUsersAsync( + removableOrgUsers.Select(ou => ou.UserId!.Value)); + foreach (var orgUser in removableOrgUsers) + { + if (userOrgs.Any(ou => ou.UserId == orgUser.UserId + && ou.OrganizationId != org.Id + && ou.Status != OrganizationUserStatusType.Invited)) + { + await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, + savingUserId); + + await _mailService.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync( + org.DisplayName(), orgUser.Email); + } + } + } + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (policyUpdate is not { Enabled: true }) + { + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId); + return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]); + } + + return ""; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs new file mode 100644 index 000000000..ef896bbb9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs @@ -0,0 +1,87 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IMailService _mailService; + private readonly IOrganizationRepository _organizationRepository; + private readonly ICurrentContext _currentContext; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + + public PolicyType Type => PolicyType.TwoFactorAuthentication; + public IEnumerable RequiredPolicies => []; + + public TwoFactorAuthenticationPolicyValidator( + IOrganizationUserRepository organizationUserRepository, + IMailService mailService, + IOrganizationRepository organizationRepository, + ICurrentContext currentContext, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IRemoveOrganizationUserCommand removeOrganizationUserCommand) + { + _organizationUserRepository = organizationUserRepository; + _mailService = mailService; + _organizationRepository = organizationRepository; + _currentContext = currentContext; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _removeOrganizationUserCommand = removeOrganizationUserCommand; + } + + public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) + { + await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); + } + } + + private async Task RemoveNonCompliantUsersAsync(Guid organizationId) + { + var org = await _organizationRepository.GetByIdAsync(organizationId); + var savingUserId = _currentContext.UserId; + + var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); + var removableOrgUsers = orgUsers.Where(ou => + ou.Status != OrganizationUserStatusType.Invited && ou.Status != OrganizationUserStatusType.Revoked && + ou.Type != OrganizationUserType.Owner && ou.Type != OrganizationUserType.Admin && + ou.UserId != savingUserId); + + // Reorder by HasMasterPassword to prioritize checking users without a master if they have 2FA enabled + foreach (var orgUser in removableOrgUsers.OrderBy(ou => ou.HasMasterPassword)) + { + var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == orgUser.Id) + .twoFactorIsEnabled; + if (!userTwoFactorEnabled) + { + if (!orgUser.HasMasterPassword) + { + throw new BadRequestException( + "Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page."); + } + + await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, + savingUserId); + + await _mailService.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync( + org!.DisplayName(), orgUser.Email); + } + } + } + + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); +} diff --git a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs index 7e689f034..4e3a7bb89 100644 --- a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs +++ b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs @@ -2,6 +2,8 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -27,6 +29,8 @@ public class PolicyService : IPolicyService private readonly IMailService _mailService; private readonly GlobalSettings _globalSettings; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IFeatureService _featureService; + private readonly ISavePolicyCommand _savePolicyCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; public PolicyService( @@ -39,6 +43,8 @@ public class PolicyService : IPolicyService IMailService mailService, GlobalSettings globalSettings, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IFeatureService featureService, + ISavePolicyCommand savePolicyCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand) { _applicationCacheService = applicationCacheService; @@ -50,11 +56,28 @@ public class PolicyService : IPolicyService _mailService = mailService; _globalSettings = globalSettings; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _featureService = featureService; + _savePolicyCommand = savePolicyCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand; } public async Task SaveAsync(Policy policy, IOrganizationService organizationService, Guid? savingUserId) { + if (_featureService.IsEnabled(FeatureFlagKeys.Pm13322AddPolicyDefinitions)) + { + // Transitional mapping - this will be moved to callers once the feature flag is removed + var policyUpdate = new PolicyUpdate + { + OrganizationId = policy.OrganizationId, + Type = policy.Type, + Enabled = policy.Enabled, + Data = policy.Data + }; + + await _savePolicyCommand.SaveAsync(policyUpdate); + return; + } + var org = await _organizationRepository.GetByIdAsync(policy.OrganizationId); if (org == null) { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 6b4cf7e97..ecbe190cc 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -146,6 +146,7 @@ public static class FeatureFlagKeys public const string RemoveServerVersionHeader = "remove-server-version-header"; public const string AccessIntelligence = "pm-13227-access-intelligence"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; + public const string Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions"; public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split"; public static List GetAllKeys() diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index bd3aecf2f..040a49baa 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services.Implementations; using Bit.Core.AdminConsole.Services.NoopImplementations; @@ -102,9 +103,9 @@ public static class ServiceCollectionExtensions services.AddUserServices(globalSettings); services.AddTrialInitiationServices(); services.AddOrganizationServices(globalSettings); + services.AddPolicyServices(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); diff --git a/test/Common/AutoFixture/SutProvider.cs b/test/Common/AutoFixture/SutProvider.cs index ac953965b..fefe6c3eb 100644 --- a/test/Common/AutoFixture/SutProvider.cs +++ b/test/Common/AutoFixture/SutProvider.cs @@ -127,7 +127,6 @@ public class SutProvider : ISutProvider return _sutProvider.GetDependency(parameterInfo.ParameterType, ""); } - // This is the equivalent of _fixture.Create, but no overload for // Create(Type type) exists. var dependency = new SpecimenContext(_fixture).Resolve(new SeededRequest(parameterInfo.ParameterType, diff --git a/test/Common/AutoFixture/SutProviderExtensions.cs b/test/Common/AutoFixture/SutProviderExtensions.cs index 1fdf22653..bdc860416 100644 --- a/test/Common/AutoFixture/SutProviderExtensions.cs +++ b/test/Common/AutoFixture/SutProviderExtensions.cs @@ -1,6 +1,7 @@ using AutoFixture; using Bit.Core.Services; using Bit.Core.Settings; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using RichardSzalay.MockHttp; @@ -47,4 +48,19 @@ public static class SutProviderExtensions .SetDependency(mockHttpClientFactory) .Create(); } + + /// + /// Configures SutProvider to use FakeTimeProvider. + /// It is registered under both the TimeProvider type and the FakeTimeProvider type + /// so that it can be retrieved in a type-safe manner with GetDependency. + /// This can be chained with other builder methods; make sure to call + /// before use. + /// + public static SutProvider WithFakeTimeProvider(this SutProvider sutProvider) + { + var fakeTimeProvider = new FakeTimeProvider(); + return sutProvider + .SetDependency((TimeProvider)fakeTimeProvider) + .SetDependency(fakeTimeProvider); + } } diff --git a/test/Common/Common.csproj b/test/Common/Common.csproj index 7b9fbe42d..2f11798ce 100644 --- a/test/Common/Common.csproj +++ b/test/Common/Common.csproj @@ -5,6 +5,7 @@ + diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs index f70fd579e..09b112c43 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs @@ -9,10 +9,12 @@ namespace Bit.Core.Test.AdminConsole.AutoFixture; internal class PolicyCustomization : ICustomization { public PolicyType Type { get; set; } + public bool Enabled { get; set; } - public PolicyCustomization(PolicyType type) + public PolicyCustomization(PolicyType type, bool enabled) { Type = type; + Enabled = enabled; } public void Customize(IFixture fixture) @@ -20,21 +22,23 @@ internal class PolicyCustomization : ICustomization fixture.Customize(composer => composer .With(o => o.OrganizationId, Guid.NewGuid()) .With(o => o.Type, Type) - .With(o => o.Enabled, true)); + .With(o => o.Enabled, Enabled)); } } public class PolicyAttribute : CustomizeAttribute { private readonly PolicyType _type; + private readonly bool _enabled; - public PolicyAttribute(PolicyType type) + public PolicyAttribute(PolicyType type, bool enabled = true) { _type = type; + _enabled = enabled; } public override ICustomization GetCustomization(ParameterInfo parameter) { - return new PolicyCustomization(_type); + return new PolicyCustomization(_type, _enabled); } } diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs new file mode 100644 index 000000000..dff9b5717 --- /dev/null +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.Test.AdminConsole.AutoFixture; + +internal class PolicyUpdateCustomization(PolicyType type, bool enabled) : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.Type, type) + .With(o => o.Enabled, enabled)); + } +} + +public class PolicyUpdateAttribute(PolicyType type, bool enabled = true) : CustomizeAttribute +{ + public override ICustomization GetCustomization(ParameterInfo parameter) + { + return new PolicyUpdateCustomization(type, enabled); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorFixtures.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorFixtures.cs new file mode 100644 index 000000000..ba4741d8b --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorFixtures.cs @@ -0,0 +1,43 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using NSubstitute; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class FakeSingleOrgPolicyValidator : IPolicyValidator +{ + public PolicyType Type => PolicyType.SingleOrg; + public IEnumerable RequiredPolicies => Array.Empty(); + + public readonly Func> ValidateAsyncMock = Substitute.For>>(); + public readonly Action OnSaveSideEffectsAsyncMock = Substitute.For>(); + + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + return ValidateAsyncMock(policyUpdate, currentPolicy); + } + + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + OnSaveSideEffectsAsyncMock(policyUpdate, currentPolicy); + return Task.FromResult(0); + } +} +public class FakeRequireSsoPolicyValidator : IPolicyValidator +{ + public PolicyType Type => PolicyType.RequireSso; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0); +} +public class FakeVaultTimeoutPolicyValidator : IPolicyValidator +{ + public PolicyType Type => PolicyType.MaximumVaultTimeout; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0); +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorHelpersTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorHelpersTests.cs new file mode 100644 index 000000000..99f99706f --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorHelpersTests.cs @@ -0,0 +1,64 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class PolicyValidatorHelpersTests +{ + [Fact] + public void ValidateDecryptionOptionsNotEnabled_RequiredByKeyConnector_ValidationError() + { + var ssoConfig = new SsoConfig(); + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); + + var result = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]); + + Assert.Contains("Key Connector is enabled", result); + } + + [Fact] + public void ValidateDecryptionOptionsNotEnabled_RequiredByTDE_ValidationError() + { + var ssoConfig = new SsoConfig(); + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption }); + + var result = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.TrustedDeviceEncryption]); + + Assert.Contains("Trusted device encryption is on", result); + } + + [Fact] + public void ValidateDecryptionOptionsNotEnabled_NullSsoConfig_NoValidationError() + { + var ssoConfig = new SsoConfig(); + var result = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]); + + Assert.True(string.IsNullOrEmpty(result)); + } + + [Fact] + public void ValidateDecryptionOptionsNotEnabled_RequiredOptionNotEnabled_NoValidationError() + { + var ssoConfig = new SsoConfig(); + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); + + var result = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.TrustedDeviceEncryption]); + + Assert.True(string.IsNullOrEmpty(result)); + } + + [Fact] + public void ValidateDecryptionOptionsNotEnabled_SsoConfigDisabled_NoValidationError() + { + var ssoConfig = new SsoConfig(); + ssoConfig.Enabled = false; + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); + + var result = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]); + + Assert.True(string.IsNullOrEmpty(result)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs new file mode 100644 index 000000000..d3af765f7 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs @@ -0,0 +1,75 @@ +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +[SutProviderCustomize] +public class RequireSsoPolicyValidatorTests +{ + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_KeyConnectorEnabled_ValidationError( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = true }; + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_TdeEnabled_ValidationError( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = true }; + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption }); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.Contains("Trusted device encryption is on", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_DecryptionOptionsNotEnabled_Success( + [PolicyUpdate(PolicyType.ResetPassword, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.ResetPassword)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = false }; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.True(string.IsNullOrEmpty(result)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs new file mode 100644 index 000000000..83939406b --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs @@ -0,0 +1,71 @@ +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.PolicyValidators; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Test.AdminConsole.AutoFixture; +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 ResetPasswordPolicyValidatorTests +{ + [Theory] + [BitAutoData(true, false)] + [BitAutoData(false, true)] + [BitAutoData(false, false)] + public async Task ValidateAsync_DisablingPolicy_TdeEnabled_ValidationError( + bool policyEnabled, + bool autoEnrollEnabled, + [PolicyUpdate(PolicyType.ResetPassword)] PolicyUpdate policyUpdate, + [Policy(PolicyType.ResetPassword)] Policy policy, + SutProvider sutProvider) + { + policyUpdate.Enabled = policyEnabled; + policyUpdate.SetDataModel(new ResetPasswordDataModel + { + AutoEnrollEnabled = autoEnrollEnabled + }); + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = true }; + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption }); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.Contains("Trusted device encryption is on and requires this policy.", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_TdeNotEnabled_Success( + [PolicyUpdate(PolicyType.ResetPassword, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.ResetPassword)] Policy policy, + SutProvider sutProvider) + { + policyUpdate.SetDataModel(new ResetPasswordDataModel + { + AutoEnrollEnabled = false + }); + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = false }; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.True(string.IsNullOrEmpty(result)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs new file mode 100644 index 000000000..76ee57484 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs @@ -0,0 +1,129 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +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 NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class SingleOrgPolicyValidatorTests +{ + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_KeyConnectorEnabled_ValidationError( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = true }; + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_KeyConnectorNotEnabled_Success( + [PolicyUpdate(PolicyType.ResetPassword, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.ResetPassword)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = false }; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( + [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy policy, + Guid savingUserId, + Guid nonCompliantUserId, + Organization organization, SutProvider sutProvider) + { + policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + + var compliantUser1 = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = new Guid(), + Email = "user1@example.com" + }; + + var compliantUser2 = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = new Guid(), + Email = "user2@example.com" + }; + + var nonCompliantUser = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = nonCompliantUserId, + Email = "user3@example.com" + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([compliantUser1, compliantUser2, nonCompliantUser]); + + var otherOrganizationUser = new OrganizationUser + { + OrganizationId = new Guid(), + UserId = nonCompliantUserId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Is>(ids => ids.Contains(nonCompliantUserId))) + .Returns([otherOrganizationUser]); + + sutProvider.GetDependency().UserId.Returns(savingUserId); + sutProvider.GetDependency().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization); + + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + + await sutProvider.GetDependency() + .Received(1) + .RemoveUserAsync(policyUpdate.OrganizationId, nonCompliantUser.Id, savingUserId); + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(), + "user3@example.com"); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs new file mode 100644 index 000000000..4dce13174 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs @@ -0,0 +1,209 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +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 NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class TwoFactorAuthenticationPolicyValidatorTests +{ + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( + Organization organization, + [PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate, + [Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + var orgUserDetailUserInvited = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user1@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = false + }; + var orgUserDetailUserAcceptedWith2FA = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user2@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = true + }; + var orgUserDetailUserAcceptedWithout2FA = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user3@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = true + }; + var orgUserDetailAdmin = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Admin, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "admin@test.com", + Name = "ADMIN", + UserId = Guid.NewGuid(), + HasMasterPassword = false + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns(new List + { + orgUserDetailUserInvited, + orgUserDetailUserAcceptedWith2FA, + orgUserDetailUserAcceptedWithout2FA, + orgUserDetailAdmin + }); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>() + { + (orgUserDetailUserInvited, false), + (orgUserDetailUserAcceptedWith2FA, true), + (orgUserDetailUserAcceptedWithout2FA, false), + (orgUserDetailAdmin, false), + }); + + var savingUserId = Guid.NewGuid(); + sutProvider.GetDependency().UserId.Returns(savingUserId); + + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + + var removeOrganizationUserCommand = sutProvider.GetDependency(); + + await removeOrganizationUserCommand.Received() + .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserAcceptedWithout2FA.Id, savingUserId); + await sutProvider.GetDependency().Received() + .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserAcceptedWithout2FA.Email); + + await removeOrganizationUserCommand.DidNotReceive() + .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserInvited.Id, savingUserId); + await sutProvider.GetDependency().DidNotReceive() + .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserInvited.Email); + await removeOrganizationUserCommand.DidNotReceive() + .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserAcceptedWith2FA.Id, savingUserId); + await sutProvider.GetDependency().DidNotReceive() + .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserAcceptedWith2FA.Email); + await removeOrganizationUserCommand.DidNotReceive() + .RemoveUserAsync(policy.OrganizationId, orgUserDetailAdmin.Id, savingUserId); + await sutProvider.GetDependency().DidNotReceive() + .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailAdmin.Email); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_UsersToBeRemovedDontHaveMasterPasswords_Throws( + Organization organization, + [PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate, + [Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + + var orgUserDetailUserWith2FAAndMP = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user1@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = true + }; + var orgUserDetailUserWith2FANoMP = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user2@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = false + }; + var orgUserDetailUserWithout2FA = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user3@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = false + }; + var orgUserDetailAdmin = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Admin, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "admin@test.com", + Name = "ADMIN", + UserId = Guid.NewGuid(), + HasMasterPassword = false + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policy.OrganizationId) + .Returns(new List + { + orgUserDetailUserWith2FAAndMP, + orgUserDetailUserWith2FANoMP, + orgUserDetailUserWithout2FA, + orgUserDetailAdmin + }); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(ids => + ids.Contains(orgUserDetailUserWith2FANoMP.UserId.Value) + && ids.Contains(orgUserDetailUserWithout2FA.UserId.Value) + && ids.Contains(orgUserDetailAdmin.UserId.Value))) + .Returns(new List<(Guid userId, bool hasTwoFactor)>() + { + (orgUserDetailUserWith2FANoMP.UserId.Value, true), + (orgUserDetailUserWithout2FA.UserId.Value, false), + (orgUserDetailAdmin.UserId.Value, false), + }); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy)); + + Assert.Contains("Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .RemoveUserAsync(organizationId: default, organizationUserId: default, deletingUserId: default); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs new file mode 100644 index 000000000..342ede9c8 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs @@ -0,0 +1,330 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +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 Xunit; +using EventType = Bit.Core.Enums.EventType; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class SavePolicyCommandTests +{ + [Theory, BitAutoData] + public async Task SaveAsync_NewPolicy_Success([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate) + { + var fakePolicyValidator = new FakeSingleOrgPolicyValidator(); + fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns(""); + var sutProvider = SutProviderFactory([fakePolicyValidator]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([]); + + var creationDate = sutProvider.GetDependency().Start; + + await sutProvider.Sut.SaveAsync(policyUpdate); + + await fakePolicyValidator.ValidateAsyncMock.Received(1).Invoke(policyUpdate, null); + fakePolicyValidator.OnSaveSideEffectsAsyncMock.Received(1).Invoke(policyUpdate, null); + + 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) + { + var fakePolicyValidator = new FakeSingleOrgPolicyValidator(); + fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns(""); + var sutProvider = SutProviderFactory([fakePolicyValidator]); + + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + .Returns(currentPolicy); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy]); + + // Store mutable properties separately to assert later + var id = currentPolicy.Id; + var organizationId = currentPolicy.OrganizationId; + var type = currentPolicy.Type; + var creationDate = currentPolicy.CreationDate; + var revisionDate = sutProvider.GetDependency().Start; + + await sutProvider.Sut.SaveAsync(policyUpdate); + + await fakePolicyValidator.ValidateAsyncMock.Received(1).Invoke(policyUpdate, currentPolicy); + fakePolicyValidator.OnSaveSideEffectsAsyncMock.Received(1).Invoke(policyUpdate, currentPolicy); + + await AssertPolicySavedAsync(sutProvider, policyUpdate); + // Additional assertions to ensure certain properties have or have not been updated + await sutProvider.GetDependency().Received(1).UpsertAsync(Arg.Is(p => + p.Id == id && + p.OrganizationId == organizationId && + p.Type == type && + p.CreationDate == creationDate && + p.RevisionDate == revisionDate)); + } + + [Fact] + public void Constructor_DuplicatePolicyValidators_Throws() + { + var exception = Assert.Throws(() => + new SavePolicyCommand( + Substitute.For(), + Substitute.For(), + Substitute.For(), + [new FakeSingleOrgPolicyValidator(), new FakeSingleOrgPolicyValidator()], + Substitute.For() + )); + Assert.Contains("Duplicate PolicyValidator for SingleOrg policy", exception.Message); + } + + [Theory, BitAutoData] + public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest(PolicyUpdate policyUpdate) + { + var sutProvider = SutProviderFactory(); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) + .Returns(Task.FromResult(null)); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + Assert.Contains("Organization not found", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest(PolicyUpdate policyUpdate) + { + var sutProvider = SutProviderFactory(); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) + .Returns(new OrganizationAbility + { + Id = policyUpdate.OrganizationId, + UsePolicies = false + }); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + 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) + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([]); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + 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) + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([singleOrgPolicy]); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + 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) + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([singleOrgPolicy]); + + await sutProvider.Sut.SaveAsync(policyUpdate); + 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) // depends on Single Org + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy]); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + 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, // depends on Single Org + [Policy(PolicyType.MaximumVaultTimeout)] Policy vaultTimeoutPolicy) // depends on Single Org + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator(), + new FakeVaultTimeoutPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy, vaultTimeoutPolicy]); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + 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) // depends on Single Org but is not enabled + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy]); + + await sutProvider.Sut.SaveAsync(policyUpdate); + + await AssertPolicySavedAsync(sutProvider, policyUpdate); + } + + [Theory, BitAutoData] + public async Task SaveAsync_ThrowsOnValidationError([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate) + { + var fakePolicyValidator = new FakeSingleOrgPolicyValidator(); + fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns("Validation error!"); + var sutProvider = SutProviderFactory([fakePolicyValidator]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([]); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + Assert.Contains("Validation error!", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + /// + /// Returns a new SutProvider with the PolicyValidators registered in the Sut. + /// + private static SutProvider SutProviderFactory(IEnumerable? policyValidators = null) + { + return new SutProvider() + .WithFakeTimeProvider() + .SetDependency(typeof(IEnumerable), policyValidators ?? []) + .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) + { + var expectedPolicy = () => Arg.Is(p => + p.Type == policyUpdate.Type && + p.OrganizationId == policyUpdate.OrganizationId && + p.Enabled == policyUpdate.Enabled && + p.Data == policyUpdate.Data); + + await sutProvider.GetDependency().Received(1).UpsertAsync(expectedPolicy()); + + await sutProvider.GetDependency().Received(1) + .LogPolicyEventAsync(expectedPolicy(), EventType.Policy_Updated); + } +}