1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

[PM-13322] [BEEEP] Add PolicyValidators and refactor policy save logic (#4877)

This commit is contained in:
Thomas Rittson 2024-10-22 09:18:34 +10:00 committed by GitHub
parent 75cc907785
commit dfa411131d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1564 additions and 6 deletions

View File

@ -16,3 +16,30 @@ public enum PolicyType : byte
ActivateAutofill = 11,
AutomaticAppLogIn = 12,
}
public static class PolicyTypeExtensions
{
/// <summary>
/// Returns the name of the policy for display to the user.
/// Do not include the word "policy" in the return value.
/// </summary>
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",
};
}
}

View File

@ -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;
/// <summary>
/// Defines behavior and functionality for a given PolicyType.
/// </summary>
public interface IPolicyValidator
{
/// <summary>
/// The PolicyType that this definition relates to.
/// </summary>
public PolicyType Type { get; }
/// <summary>
/// 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.
/// </summary>
public IEnumerable<PolicyType> RequiredPolicies { get; }
/// <summary>
/// Validates a policy before saving it.
/// Do not use this for simple dependencies between different policies - see <see cref="RequiredPolicies"/> instead.
/// Implementation is optional; by default it will not perform any validation.
/// </summary>
/// <param name="policyUpdate">The policy update request</param>
/// <param name="currentPolicy">The current policy, if any</param>
/// <returns>A validation error if validation was unsuccessful, otherwise an empty string</returns>
public Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy);
/// <summary>
/// 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.
/// </summary>
/// <param name="policyUpdate">The policy update request</param>
/// <param name="currentPolicy">The current policy, if any</param>
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy);
}

View File

@ -0,0 +1,8 @@
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
public interface ISavePolicyCommand
{
Task SaveAsync(PolicyUpdate policy);
}

View File

@ -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<PolicyType, IPolicyValidator> _policyValidators;
private readonly TimeProvider _timeProvider;
public SavePolicyCommand(
IApplicationCacheService applicationCacheService,
IEventService eventService,
IPolicyRepository policyRepository,
IEnumerable<IPolicyValidator> policyValidators,
TimeProvider timeProvider)
{
_applicationCacheService = applicationCacheService;
_eventService = eventService;
_policyRepository = policyRepository;
_timeProvider = timeProvider;
var policyValidatorsDict = new Dictionary<PolicyType, IPolicyValidator>();
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);
}
}

View File

@ -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;
/// <summary>
/// A request for SavePolicyCommand to update a policy
/// </summary>
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<T>() where T : IPolicyDataModel, new()
{
return CoreHelpers.LoadClassFromJsonData<T>(Data);
}
public void SetDataModel<T>(T dataModel) where T : IPolicyDataModel, new()
{
Data = CoreHelpers.ClassToJsonData(dataModel);
}
}

View File

@ -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<IPolicyService, PolicyService>();
services.AddScoped<ISavePolicyCommand, SavePolicyCommand>();
services.AddScoped<IPolicyValidator, TwoFactorAuthenticationPolicyValidator>();
services.AddScoped<IPolicyValidator, SingleOrgPolicyValidator>();
services.AddScoped<IPolicyValidator, RequireSsoPolicyValidator>();
services.AddScoped<IPolicyValidator, ResetPasswordPolicyValidator>();
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
}
}

View File

@ -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<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
public Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult("");
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0);
}

View File

@ -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
{
/// <summary>
/// Validate that given Member Decryption Options are not enabled.
/// Used for validation when disabling a policy that is required by certain Member Decryption Options.
/// </summary>
/// <param name="decryptionOptions">The Member Decryption Options that require the policy to be enabled.</param>
/// <returns>A validation error if validation was unsuccessful, otherwise an empty string</returns>
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.",
_ => ""
};
}
}

View File

@ -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<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
public async Task<string> 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);
}

View File

@ -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<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
public ResetPasswordPolicyValidator(ISsoConfigRepository ssoConfigRepository)
{
_ssoConfigRepository = ssoConfigRepository;
}
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{
if (policyUpdate is not { Enabled: true } ||
policyUpdate.GetDataModel<ResetPasswordDataModel>().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);
}

View File

@ -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<PolicyType> 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<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{
if (policyUpdate is not { Enabled: true })
{
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId);
return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
}
return "";
}
}

View File

@ -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<PolicyType> 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<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult("");
}

View File

@ -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)
{

View File

@ -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<string> GetAllKeys()

View File

@ -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<ICollectionService, CollectionService>();
services.AddScoped<IGroupService, GroupService>();
services.AddScoped<IPolicyService, PolicyService>();
services.AddScoped<IEventService, EventService>();
services.AddScoped<IEmergencyAccessService, EmergencyAccessService>();
services.AddSingleton<IDeviceService, DeviceService>();

View File

@ -127,7 +127,6 @@ public class SutProvider<TSut> : ISutProvider
return _sutProvider.GetDependency(parameterInfo.ParameterType, "");
}
// This is the equivalent of _fixture.Create<parameterInfo.ParameterType>, but no overload for
// Create(Type type) exists.
var dependency = new SpecimenContext(_fixture).Resolve(new SeededRequest(parameterInfo.ParameterType,

View File

@ -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();
}
/// <summary>
/// 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
/// <see cref="ISutProvider.Create"/> before use.
/// </summary>
public static SutProvider<T> WithFakeTimeProvider<T>(this SutProvider<T> sutProvider)
{
var fakeTimeProvider = new FakeTimeProvider();
return sutProvider
.SetDependency((TimeProvider)fakeTimeProvider)
.SetDependency(fakeTimeProvider);
}
}

View File

@ -5,6 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.10.0" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">

View File

@ -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<Policy>(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);
}
}

View File

@ -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<PolicyUpdate>(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);
}
}

View File

@ -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<PolicyType> RequiredPolicies => Array.Empty<PolicyType>();
public readonly Func<PolicyUpdate, Policy?, Task<string>> ValidateAsyncMock = Substitute.For<Func<PolicyUpdate, Policy?, Task<string>>>();
public readonly Action<PolicyUpdate, Policy?> OnSaveSideEffectsAsyncMock = Substitute.For<Action<PolicyUpdate, Policy?>>();
public Task<string> 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<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
public Task<string> 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<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
public Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult("");
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0);
}

View File

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

View File

@ -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<RequireSsoPolicyValidator> sutProvider)
{
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = true };
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector });
sutProvider.GetDependency<ISsoConfigRepository>()
.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<RequireSsoPolicyValidator> sutProvider)
{
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = true };
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption });
sutProvider.GetDependency<ISsoConfigRepository>()
.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<RequireSsoPolicyValidator> sutProvider)
{
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = false };
sutProvider.GetDependency<ISsoConfigRepository>()
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns(ssoConfig);
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy);
Assert.True(string.IsNullOrEmpty(result));
}
}

View File

@ -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<ResetPasswordPolicyValidator> 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<ISsoConfigRepository>()
.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<ResetPasswordPolicyValidator> sutProvider)
{
policyUpdate.SetDataModel(new ResetPasswordDataModel
{
AutoEnrollEnabled = false
});
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = false };
sutProvider.GetDependency<ISsoConfigRepository>()
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns(ssoConfig);
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy);
Assert.True(string.IsNullOrEmpty(result));
}
}

View File

@ -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<SingleOrgPolicyValidator> sutProvider)
{
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = true };
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector });
sutProvider.GetDependency<ISsoConfigRepository>()
.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<SingleOrgPolicyValidator> sutProvider)
{
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = false };
sutProvider.GetDependency<ISsoConfigRepository>()
.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<SingleOrgPolicyValidator> 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<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([compliantUser1, compliantUser2, nonCompliantUser]);
var otherOrganizationUser = new OrganizationUser
{
OrganizationId = new Guid(),
UserId = nonCompliantUserId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(nonCompliantUserId)))
.Returns([otherOrganizationUser]);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(savingUserId);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization);
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy);
await sutProvider.GetDependency<IRemoveOrganizationUserCommand>()
.Received(1)
.RemoveUserAsync(policyUpdate.OrganizationId, nonCompliantUser.Id, savingUserId);
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(),
"user3@example.com");
}
}

View File

@ -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<TwoFactorAuthenticationPolicyValidator> sutProvider)
{
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
sutProvider.GetDependency<IOrganizationRepository>().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<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns(new List<OrganizationUserUserDetails>
{
orgUserDetailUserInvited,
orgUserDetailUserAcceptedWith2FA,
orgUserDetailUserAcceptedWithout2FA,
orgUserDetailAdmin
});
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
.Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>()
{
(orgUserDetailUserInvited, false),
(orgUserDetailUserAcceptedWith2FA, true),
(orgUserDetailUserAcceptedWithout2FA, false),
(orgUserDetailAdmin, false),
});
var savingUserId = Guid.NewGuid();
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(savingUserId);
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy);
var removeOrganizationUserCommand = sutProvider.GetDependency<IRemoveOrganizationUserCommand>();
await removeOrganizationUserCommand.Received()
.RemoveUserAsync(policy.OrganizationId, orgUserDetailUserAcceptedWithout2FA.Id, savingUserId);
await sutProvider.GetDependency<IMailService>().Received()
.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserAcceptedWithout2FA.Email);
await removeOrganizationUserCommand.DidNotReceive()
.RemoveUserAsync(policy.OrganizationId, orgUserDetailUserInvited.Id, savingUserId);
await sutProvider.GetDependency<IMailService>().DidNotReceive()
.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserInvited.Email);
await removeOrganizationUserCommand.DidNotReceive()
.RemoveUserAsync(policy.OrganizationId, orgUserDetailUserAcceptedWith2FA.Id, savingUserId);
await sutProvider.GetDependency<IMailService>().DidNotReceive()
.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserAcceptedWith2FA.Email);
await removeOrganizationUserCommand.DidNotReceive()
.RemoveUserAsync(policy.OrganizationId, orgUserDetailAdmin.Id, savingUserId);
await sutProvider.GetDependency<IMailService>().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<TwoFactorAuthenticationPolicyValidator> 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<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policy.OrganizationId)
.Returns(new List<OrganizationUserUserDetails>
{
orgUserDetailUserWith2FAAndMP,
orgUserDetailUserWith2FANoMP,
orgUserDetailUserWithout2FA,
orgUserDetailAdmin
});
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Is<IEnumerable<Guid>>(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<BadRequestException>(
() => 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<IRemoveOrganizationUserCommand>().DidNotReceiveWithAnyArgs()
.RemoveUserAsync(organizationId: default, organizationUserId: default, deletingUserId: default);
}
}

View File

@ -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<IPolicyRepository>().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([]);
var creationDate = sutProvider.GetDependency<FakeTimeProvider>().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<IPolicyRepository>().Received(1).UpsertAsync(Arg.Is<Policy>(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<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type)
.Returns(currentPolicy);
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.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<FakeTimeProvider>().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<IPolicyRepository>().Received(1).UpsertAsync(Arg.Is<Policy>(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<Exception>(() =>
new SavePolicyCommand(
Substitute.For<IApplicationCacheService>(),
Substitute.For<IEventService>(),
Substitute.For<IPolicyRepository>(),
[new FakeSingleOrgPolicyValidator(), new FakeSingleOrgPolicyValidator()],
Substitute.For<TimeProvider>()
));
Assert.Contains("Duplicate PolicyValidator for SingleOrg policy", exception.Message);
}
[Theory, BitAutoData]
public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest(PolicyUpdate policyUpdate)
{
var sutProvider = SutProviderFactory();
sutProvider.GetDependency<IApplicationCacheService>()
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
.Returns(Task.FromResult<OrganizationAbility?>(null));
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IApplicationCacheService>()
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
.Returns(new OrganizationAbility
{
Id = policyUpdate.OrganizationId,
UsePolicies = false
});
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([]);
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([singleOrgPolicy]);
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IPolicyRepository>()
.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<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy, requireSsoPolicy]);
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy, requireSsoPolicy, vaultTimeoutPolicy]);
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => 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<IPolicyRepository>()
.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<IPolicyRepository>().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([]);
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(policyUpdate));
Assert.Contains("Validation error!", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
/// <summary>
/// Returns a new SutProvider with the PolicyValidators registered in the Sut.
/// </summary>
private static SutProvider<SavePolicyCommand> SutProviderFactory(IEnumerable<IPolicyValidator>? policyValidators = null)
{
return new SutProvider<SavePolicyCommand>()
.WithFakeTimeProvider()
.SetDependency(typeof(IEnumerable<IPolicyValidator>), policyValidators ?? [])
.Create();
}
private static void ArrangeOrganization(SutProvider<SavePolicyCommand> sutProvider, PolicyUpdate policyUpdate)
{
sutProvider.GetDependency<IApplicationCacheService>()
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
.Returns(new OrganizationAbility
{
Id = policyUpdate.OrganizationId,
UsePolicies = true
});
}
private static async Task AssertPolicyNotSavedAsync(SutProvider<SavePolicyCommand> sutProvider)
{
await sutProvider.GetDependency<IPolicyRepository>()
.DidNotReceiveWithAnyArgs()
.UpsertAsync(default!);
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogPolicyEventAsync(default, default);
}
private static async Task AssertPolicySavedAsync(SutProvider<SavePolicyCommand> sutProvider, PolicyUpdate policyUpdate)
{
var expectedPolicy = () => Arg.Is<Policy>(p =>
p.Type == policyUpdate.Type &&
p.OrganizationId == policyUpdate.OrganizationId &&
p.Enabled == policyUpdate.Enabled &&
p.Data == policyUpdate.Data);
await sutProvider.GetDependency<IPolicyRepository>().Received(1).UpsertAsync(expectedPolicy());
await sutProvider.GetDependency<IEventService>().Received(1)
.LogPolicyEventAsync(expectedPolicy(), EventType.Policy_Updated);
}
}