1
0
mirror of https://github.com/bitwarden/server synced 2026-01-08 19:43:34 +00:00

Ac/pm 25823/vnext policy upsert pattern (#6426)

This commit is contained in:
Jimmy Vo
2025-10-10 11:23:02 -04:00
committed by GitHub
parent a565fd9ee4
commit 6072104153
14 changed files with 1020 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
public class VNextSavePolicyCommand(
IApplicationCacheService applicationCacheService,
IEventService eventService,
IPolicyRepository policyRepository,
IEnumerable<IEnforceDependentPoliciesEvent> policyValidationEventHandlers,
TimeProvider timeProvider,
IPolicyEventHandlerFactory policyEventHandlerFactory)
: IVNextSavePolicyCommand
{
private readonly IReadOnlyDictionary<PolicyType, IEnforceDependentPoliciesEvent> _policyValidationEvents = MapToDictionary(policyValidationEventHandlers);
private static Dictionary<PolicyType, IEnforceDependentPoliciesEvent> MapToDictionary(IEnumerable<IEnforceDependentPoliciesEvent> policyValidationEventHandlers)
{
var policyValidationEventsDict = new Dictionary<PolicyType, IEnforceDependentPoliciesEvent>();
foreach (var policyValidationEvent in policyValidationEventHandlers)
{
if (!policyValidationEventsDict.TryAdd(policyValidationEvent.Type, policyValidationEvent))
{
throw new Exception($"Duplicate PolicyValidationEvent for {policyValidationEvent.Type} policy.");
}
}
return policyValidationEventsDict;
}
public async Task<Policy> SaveAsync(SavePolicyModel policyRequest)
{
var policyUpdateRequest = policyRequest.PolicyUpdate;
var organizationId = policyUpdateRequest.OrganizationId;
await EnsureOrganizationCanUsePolicyAsync(organizationId);
var savedPoliciesDict = await GetCurrentPolicyStateAsync(organizationId);
var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdateRequest.Type);
ValidatePolicyDependencies(policyUpdateRequest, currentPolicy, savedPoliciesDict);
await ValidateTargetedPolicyAsync(policyRequest, currentPolicy);
await ExecutePreUpsertSideEffectAsync(policyRequest, currentPolicy);
var upsertedPolicy = await UpsertPolicyAsync(policyUpdateRequest);
await eventService.LogPolicyEventAsync(upsertedPolicy, EventType.Policy_Updated);
await ExecutePostUpsertSideEffectAsync(policyRequest, upsertedPolicy, currentPolicy);
return upsertedPolicy;
}
private async Task EnsureOrganizationCanUsePolicyAsync(Guid organizationId)
{
var org = await applicationCacheService.GetOrganizationAbilityAsync(organizationId);
if (org == null)
{
throw new BadRequestException("Organization not found");
}
if (!org.UsePolicies)
{
throw new BadRequestException("This organization cannot use policies.");
}
}
private async Task<Policy> UpsertPolicyAsync(PolicyUpdate policyUpdateRequest)
{
var policy = await policyRepository.GetByOrganizationIdTypeAsync(policyUpdateRequest.OrganizationId, policyUpdateRequest.Type)
?? new Policy
{
OrganizationId = policyUpdateRequest.OrganizationId,
Type = policyUpdateRequest.Type,
CreationDate = timeProvider.GetUtcNow().UtcDateTime
};
policy.Enabled = policyUpdateRequest.Enabled;
policy.Data = policyUpdateRequest.Data;
policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
await policyRepository.UpsertAsync(policy);
return policy;
}
private async Task ValidateTargetedPolicyAsync(SavePolicyModel policyRequest,
Policy? currentPolicy)
{
await ExecutePolicyEventAsync<IPolicyValidationEvent>(
policyRequest.PolicyUpdate.Type,
async validator =>
{
var validationError = await validator.ValidateAsync(policyRequest, currentPolicy);
if (!string.IsNullOrEmpty(validationError))
{
throw new BadRequestException(validationError);
}
});
}
private void ValidatePolicyDependencies(
PolicyUpdate policyUpdateRequest,
Policy? currentPolicy,
Dictionary<PolicyType, Policy> savedPoliciesDict)
{
var result = policyEventHandlerFactory.GetHandler<IEnforceDependentPoliciesEvent>(policyUpdateRequest.Type);
result.Switch(
validator =>
{
var isCurrentlyEnabled = currentPolicy?.Enabled == true;
switch (policyUpdateRequest.Enabled)
{
case true when !isCurrentlyEnabled:
ValidateEnablingRequirements(validator, savedPoliciesDict);
return;
case false when isCurrentlyEnabled:
ValidateDisablingRequirements(validator, policyUpdateRequest.Type, savedPoliciesDict);
break;
}
},
_ => { });
}
private void ValidateDisablingRequirements(
IEnforceDependentPoliciesEvent validator,
PolicyType policyType,
Dictionary<PolicyType, Policy> savedPoliciesDict)
{
var dependentPolicyTypes = _policyValidationEvents.Values
.Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyType))
.Select(otherValidator => otherValidator.Type)
.Where(otherPolicyType => savedPoliciesDict.TryGetValue(otherPolicyType, out var savedPolicy) &&
savedPolicy.Enabled)
.ToList();
switch (dependentPolicyTypes)
{
case { Count: 1 }:
throw new BadRequestException($"Turn off the {dependentPolicyTypes.First().GetName()} policy because it requires the {validator.Type.GetName()} policy.");
case { Count: > 1 }:
throw new BadRequestException($"Turn off all of the policies that require the {validator.Type.GetName()} policy.");
}
}
private static void ValidateEnablingRequirements(
IEnforceDependentPoliciesEvent validator,
Dictionary<PolicyType, Policy> savedPoliciesDict)
{
var missingRequiredPolicyTypes = validator.RequiredPolicies
.Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
.ToList();
if (missingRequiredPolicyTypes.Count != 0)
{
throw new BadRequestException($"Turn on the {missingRequiredPolicyTypes.First().GetName()} policy because it is required for the {validator.Type.GetName()} policy.");
}
}
private async Task ExecutePreUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy? currentPolicy)
{
await ExecutePolicyEventAsync<IOnPolicyPreUpdateEvent>(
policyRequest.PolicyUpdate.Type,
handler => handler.ExecutePreUpsertSideEffectAsync(policyRequest, currentPolicy));
}
private async Task ExecutePostUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy postUpsertedPolicyState,
Policy? previousPolicyState)
{
await ExecutePolicyEventAsync<IOnPolicyPostUpdateEvent>(
policyRequest.PolicyUpdate.Type,
handler => handler.ExecutePostUpsertSideEffectAsync(
policyRequest,
postUpsertedPolicyState,
previousPolicyState));
}
private async Task ExecutePolicyEventAsync<T>(PolicyType type, Func<T, Task> func) where T : IPolicyUpdateEvent
{
var handler = policyEventHandlerFactory.GetHandler<T>(type);
await handler.Match(
async h => await func(h),
_ => Task.CompletedTask
);
}
private async Task<Dictionary<PolicyType, Policy>> GetCurrentPolicyStateAsync(Guid organizationId)
{
var savedPolicies = await policyRepository.GetManyByOrganizationIdAsync(organizationId);
// Note: policies may be missing from this dict if they have never been enabled
var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type);
return savedPoliciesDict;
}
}

View File

@@ -16,6 +16,8 @@ public record PolicyUpdate
public PolicyType Type { get; set; }
public string? Data { get; set; }
public bool Enabled { get; set; }
[Obsolete("Please use SavePolicyModel.PerformedBy instead.")]
public IActingUser? PerformedBy { get; set; }
public T GetDataModel<T>() where T : IPolicyDataModel, new()

View File

@@ -1,5 +1,7 @@
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Services.Implementations;
@@ -13,7 +15,9 @@ public static class PolicyServiceCollectionExtensions
{
services.AddScoped<IPolicyService, PolicyService>();
services.AddScoped<ISavePolicyCommand, SavePolicyCommand>();
services.AddScoped<IVNextSavePolicyCommand, VNextSavePolicyCommand>();
services.AddScoped<IPolicyRequirementQuery, PolicyRequirementQuery>();
services.AddScoped<IPolicyEventHandlerFactory, PolicyEventHandlerHandlerFactory>();
services.AddPolicyValidators();
services.AddPolicyRequirements();

View File

@@ -0,0 +1,12 @@
using Bit.Core.AdminConsole.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent
{
/// <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; }
}

View File

@@ -0,0 +1,18 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
public interface IOnPolicyPostUpdateEvent : IPolicyUpdateEvent
{
/// <summary>
/// Performs side effects after a policy has been upserted.
/// For example, this can be used for cleanup tasks or notifications.
/// </summary>
/// <param name="policyRequest">The policy save request</param>
/// <param name="postUpsertedPolicyState">The policy after it was upserted</param>
/// <param name="previousPolicyState">The policy state before it was updated, if any</param>
public Task ExecutePostUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy postUpsertedPolicyState,
Policy? previousPolicyState);
}

View File

@@ -0,0 +1,17 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent
{
/// <summary>
/// Performs side effects before a policy is upserted.
/// For example, this can be used to remove non-compliant users from the organization.
/// </summary>
/// <param name="policyRequest">The policy save request containing the policy update and metadata</param>
/// <param name="currentPolicy">The current policy, if any</param>
public Task ExecutePreUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy? currentPolicy);
}

View File

@@ -0,0 +1,30 @@
#nullable enable
using Bit.Core.AdminConsole.Enums;
using OneOf;
using OneOf.Types;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Provides policy-specific event handlers used during the save workflow in <see cref="IVNextSavePolicyCommand"/>.
/// </summary>
/// <remarks>
/// Supported handlers:
/// - <see cref="IEnforceDependentPoliciesEvent"/> for dependency checks
/// - <see cref="IPolicyValidationEvent"/> for custom validation
/// - <see cref="IOnPolicyPreUpdateEvent"/> for pre-save logic
/// - <see cref="IOnPolicyPostUpdateEvent"/> for post-save logic
/// </remarks>
public interface IPolicyEventHandlerFactory
{
/// <summary>
/// Gets the event handler for the given policy type and handler interface.
/// </summary>
/// <typeparam name="T">Handler type implementing <see cref="IPolicyUpdateEvent"/>.</typeparam>
/// <param name="policyType">The policy type to resolve.</param>
/// <returns>
/// <see cref="OneOf{T, None}"/> — the handler if available, or None if not implemented.
/// </returns>
OneOf<T, None> GetHandler<T>(PolicyType policyType) where T : IPolicyUpdateEvent;
}

View File

@@ -0,0 +1,11 @@
using Bit.Core.AdminConsole.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
public interface IPolicyUpdateEvent
{
/// <summary>
/// The policy type that the associated handler will handle.
/// </summary>
public PolicyType Type { get; }
}

View File

@@ -0,0 +1,19 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
public interface IPolicyValidationEvent : IPolicyUpdateEvent
{
/// <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="policyRequest">The policy save request containing the policy update and metadata</param>
/// <param name="currentPolicy">The current policy, if any</param>
public Task<string> ValidateAsync(
SavePolicyModel policyRequest,
Policy? currentPolicy);
}

View File

@@ -0,0 +1,34 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Microsoft.Azure.NotificationHubs.Messaging;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Handles creating or updating organization policies with validation and side effect execution.
/// </summary>
/// <remarks>
/// Workflow:
/// 1. Validates organization can use policies
/// 2. Validates required and dependent policies
/// 3. Runs policy-specific validation (<see cref="IPolicyValidationEvent"/>)
/// 4. Executes pre-save logic (<see cref="IOnPolicyPreUpdateEvent"/>)
/// 5. Saves the policy
/// 6. Logs the event
/// 7. Executes post-save logic (<see cref="IOnPolicyPostUpdateEvent"/>)
/// </remarks>
public interface IVNextSavePolicyCommand
{
/// <summary>
/// Performs the necessary validations, saves the policy and any side effects
/// </summary>
/// <param name="policyRequest">Policy data, acting user, and metadata.</param>
/// <returns>The saved policy with updated revision and applied changes.</returns>
/// <exception cref="BadRequestException">
/// Thrown if:
/// - The organization cant use policies
/// - Dependent policies are missing or block changes
/// - Custom validation fails
/// </exception>
Task<Policy> SaveAsync(SavePolicyModel policyRequest);
}

View File

@@ -0,0 +1,33 @@

using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using OneOf;
using OneOf.Types;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents;
public class PolicyEventHandlerHandlerFactory(
IEnumerable<IPolicyUpdateEvent> allEventHandlers) : IPolicyEventHandlerFactory
{
public OneOf<T, None> GetHandler<T>(PolicyType policyType) where T : IPolicyUpdateEvent
{
var tEventHandlers = allEventHandlers.OfType<T>().ToList();
var matchingHandlers = tEventHandlers.Where(h => h.Type == policyType).ToList();
if (matchingHandlers.Count > 1)
{
throw new InvalidOperationException(
$"Multiple {nameof(IPolicyUpdateEvent)} handlers of type {typeof(T).Name} found for {nameof(PolicyType)} {policyType}. " +
$"Expected one {typeof(T).Name} handler per {nameof(PolicyType)}.");
}
var policyTEventHandler = matchingHandlers.SingleOrDefault();
if (policyTEventHandler is null)
{
return new None();
}
return policyTEventHandler;
}
}