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);
+ }
+}