Support selective role deletion remediation

This commit is contained in:
Sipke Schoorstra 2026-09-06 02:29:43 +02:00
parent e7def4f1e9
commit 4bfb0a1f14
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
9 changed files with 559 additions and 20 deletions

View file

@ -21,6 +21,7 @@ public sealed class ExternalAuthenticationRoleDeletionDependencyContributor(
IIdentityProviderConnectionStore store,
IOptionsMonitor<ExternalAuthenticationOptions> options,
IRoleAuthorizationService roleAuthorizationService,
IRoleStore roleStore,
IConnectionRegistryVersionStore registryVersions,
ConnectionRevisionCalculator revisionCalculator,
ExternalAuthenticationSecurityNotifier notifier,
@ -84,7 +85,7 @@ public sealed class ExternalAuthenticationRoleDeletionDependencyContributor(
.Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database)
.Select(x => x.OwnerId)
.ToHashSet(StringComparer.Ordinal);
if (!expectedOwners.SetEquals(currentOwners))
if (!expectedOwners.IsSubsetOf(currentOwners))
return new RoleReferenceRemovalValidationResult.Conflict("dependency_changed");
foreach (var dependency in request.Dependencies)
@ -94,8 +95,20 @@ public sealed class ExternalAuthenticationRoleDeletionDependencyContributor(
!TryGetRoleReference(connection.UnlinkedPolicy, request.RoleId, out _, out var roleIds, out _))
return new RoleReferenceRemovalValidationResult.Conflict("connection_revision_changed");
var remainingRoleIds = roleIds.Where(x => !string.Equals(x, request.RoleId, StringComparison.Ordinal)).ToArray();
if (!await roleAuthorizationService.CanAssignRolesAsync(request.Actor, remainingRoleIds, cancellationToken))
return new RoleReferenceRemovalValidationResult.Forbidden("role_assignment_denied");
var requiresReplacement = remainingRoleIds.Length == 0 && request.SelectedReferences is not null;
if (requiresReplacement &&
(string.IsNullOrWhiteSpace(request.ReplacementRoleId) ||
string.Equals(request.ReplacementRoleId, request.RoleId, StringComparison.Ordinal)))
return new RoleReferenceRemovalValidationResult.Forbidden("replacement_role_unavailable_or_unauthorized");
var rolesToAssign = requiresReplacement
? new[] { request.ReplacementRoleId! }
: remainingRoleIds;
if (!await roleAuthorizationService.CanAssignRolesAsync(request.Actor, rolesToAssign, cancellationToken))
{
var code = requiresReplacement ? "replacement_role_unavailable_or_unauthorized" : "role_assignment_denied";
return new RoleReferenceRemovalValidationResult.Forbidden(code);
}
}
return new RoleReferenceRemovalValidationResult.Valid();
@ -116,16 +129,24 @@ public sealed class ExternalAuthenticationRoleDeletionDependencyContributor(
{
var connection = await store.FindByIdAsync(dependency.OwnerId, cancellationToken);
if (connection is null || connection.Revision != dependency.ExpectedRevision ||
!TryGetRoleReference(connection.UnlinkedPolicy, request.RoleId, out _, out _, out _))
!TryGetRoleReference(connection.UnlinkedPolicy, request.RoleId, out _, out _, out var removesLastDefaultRole))
return new RoleReferenceRemovalResult.Conflict("connection_revision_changed", changedOwnerIds);
var candidate = IdentityProviderConnectionCloner.Clone(connection);
candidate.UnlinkedPolicy = candidate.UnlinkedPolicy is { } policy
? policy with { Settings = RemoveRole(policy.Settings, request.RoleId) }
? policy with { Settings = RemoveRole(policy.Settings, request.RoleId, request.SelectedReferences is not null ? request.ReplacementRoleId : null) }
: null;
candidate.UpdatedAt = DateTimeOffset.UtcNow;
candidate.MaterialRevision = revisionCalculator.CalculateMaterialRevision(candidate);
if (request.SelectedReferences is not null && removesLastDefaultRole)
{
var replacement = await roleStore.FindAsync(new() { Id = request.ReplacementRoleId }, cancellationToken);
if (replacement is null ||
!await roleAuthorizationService.CanAssignRolesAsync(request.Actor, [replacement.Id], cancellationToken))
return new RoleReferenceRemovalResult.Failed("replacement_role_unavailable_or_unauthorized", changedOwnerIds);
}
var update = await store.UpdateAsync(candidate, connection.Revision, cancellationToken);
if (update is not ConnectionMutationResult.Updated updated)
return new RoleReferenceRemovalResult.Conflict("connection_revision_changed", changedOwnerIds);
@ -207,7 +228,7 @@ public sealed class ExternalAuthenticationRoleDeletionDependencyContributor(
return true;
}
private static JsonElement RemoveRole(JsonElement settings, string roleId)
private static JsonElement RemoveRole(JsonElement settings, string roleId, string? replacementRoleId = null)
{
var root = settings.ValueKind == JsonValueKind.Object
? JsonNode.Parse(settings.GetRawText()) as JsonObject
@ -217,6 +238,8 @@ public sealed class ExternalAuthenticationRoleDeletionDependencyContributor(
.Where(x => !string.Equals(x, roleId, StringComparison.Ordinal))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (remainingRoleIds.Length == 0 && !string.IsNullOrWhiteSpace(replacementRoleId))
remainingRoleIds = [replacementRoleId];
var roleNodes = new JsonNode?[remainingRoleIds.Length];
for (var index = 0; index < remainingRoleIds.Length; index++)
roleNodes[index] = JsonValue.Create(remainingRoleIds[index]);

View file

@ -8,6 +8,8 @@ internal sealed class RemediateRoleDeletionRequest
public bool ConfirmRemoveFromEditableJitPolicies { get; set; }
public bool ConfirmEmptyDefaultRoles { get; set; }
public bool ConfirmBestEffort { get; set; }
public IReadOnlyCollection<RoleDeletionReferenceSelection>? SelectedReferences { get; set; }
public string? ReplacementRoleId { get; set; }
}
internal sealed record RoleDeletionErrorResponse(string Error, string Message, object? Details = null);

View file

@ -35,7 +35,11 @@ internal sealed class RemediateAndDelete(IRoleDeletionCoordinator coordinator) :
request.ExpectedDependencyVersion,
request.ConfirmRemoveFromEditableJitPolicies,
request.ConfirmEmptyDefaultRoles,
request.ConfirmBestEffort),
request.ConfirmBestEffort)
{
SelectedReferences = request.SelectedReferences,
ReplacementRoleId = request.ReplacementRoleId
},
cancellationToken);
await RoleDeletionEndpointSupport.SendOperationResultAsync(HttpContext, result, cancellationToken);
}

View file

@ -12,6 +12,7 @@ internal static class RoleDeletionEndpointSupport
RoleDeletionOperationResult.Forbidden => SendErrorAsync(context, StatusCodes.Status403Forbidden, "forbidden", "The caller may not delete this role or update all affected policies.", null, cancellationToken),
RoleDeletionOperationResult.Blocked blocked => SendErrorAsync(context, StatusCodes.Status409Conflict, "conflict", "The role is referenced by one or more policies.", new { code = "role_referenced_by_jit_policy", deletionImpact = RoleDeletionImpactResponse.From(blocked.Impact) }, cancellationToken),
RoleDeletionOperationResult.PreconditionFailed conflict => SendErrorAsync(context, StatusCodes.Status409Conflict, "conflict", "The role dependencies changed. Inspect the current impact and retry.", new { code = "role_dependency_changed", deletionImpact = RoleDeletionImpactResponse.From(conflict.Impact) }, cancellationToken),
RoleDeletionOperationResult.ValidationFailed validation => SendErrorAsync(context, StatusCodes.Status400BadRequest, "validation_failed", "The role deletion request is invalid.", new { code = validation.Code, deletionImpact = RoleDeletionImpactResponse.From(validation.Impact) }, cancellationToken),
RoleDeletionOperationResult.ConfirmationRequired confirmation => SendErrorAsync(context, StatusCodes.Status400BadRequest, "confirmation_required", "Explicit confirmation is required.", new { warnings = confirmation.Warnings, deletionImpact = RoleDeletionImpactResponse.From(confirmation.Impact) }, cancellationToken),
RoleDeletionOperationResult.Incomplete incomplete => SendErrorAsync(context, StatusCodes.Status409Conflict, "conflict", "Role-policy remediation did not complete; the role was not deleted.", new { code = "role_remediation_incomplete", reason = incomplete.Code, changedOwnerIds = incomplete.ChangedOwnerIds, deletionImpact = RoleDeletionImpactResponse.From(incomplete.Impact) }, cancellationToken),
_ => throw new InvalidOperationException("Unknown role-deletion operation result.")

View file

@ -34,6 +34,9 @@ public sealed record RoleDeletionDependencySnapshot(
bool SupportsAtomicRemoval,
IReadOnlyCollection<RoleDeletionDependency> Dependencies);
/// <summary>Identifies one editable dependency reference selected for role remediation.</summary>
public sealed record RoleDeletionReferenceSelection(string Source, string OwnerId);
/// <summary>The aggregated impact of deleting a role.</summary>
public sealed record RoleDeletionImpact(
string RoleId,
@ -48,7 +51,11 @@ public sealed record RoleReferenceRemovalRequest(
string RoleId,
ClaimsPrincipal Actor,
string ExpectedContributorVersion,
IReadOnlyCollection<RoleDeletionDependency> Dependencies);
IReadOnlyCollection<RoleDeletionDependency> Dependencies)
{
public IReadOnlyCollection<RoleDeletionReferenceSelection>? SelectedReferences { get; init; }
public string? ReplacementRoleId { get; init; }
}
/// <summary>Inputs for the coordinated remediation command.</summary>
public sealed record RoleDeletionRemediationCommand(
@ -57,7 +64,11 @@ public sealed record RoleDeletionRemediationCommand(
string ExpectedDependencyVersion,
bool ConfirmRemoveFromEditablePolicies,
bool ConfirmEmptyDefaultRoles,
bool ConfirmBestEffort);
bool ConfirmBestEffort)
{
public IReadOnlyCollection<RoleDeletionReferenceSelection>? SelectedReferences { get; init; }
public string? ReplacementRoleId { get; init; }
}
public abstract record RoleReferenceRemovalValidationResult
{
@ -103,6 +114,7 @@ public abstract record RoleDeletionOperationResult
public sealed record Forbidden : RoleDeletionOperationResult;
public sealed record Blocked(RoleDeletionImpact Impact) : RoleDeletionOperationResult;
public sealed record PreconditionFailed(RoleDeletionImpact Impact) : RoleDeletionOperationResult;
public sealed record ValidationFailed(RoleDeletionImpact Impact, string Code) : RoleDeletionOperationResult;
public sealed record ConfirmationRequired(RoleDeletionImpact Impact, IReadOnlyCollection<string> Warnings) : RoleDeletionOperationResult;
public sealed record Incomplete(RoleDeletionImpact Impact, IReadOnlyCollection<string> ChangedOwnerIds, string Code) : RoleDeletionOperationResult;
}

View file

@ -59,13 +59,22 @@ public sealed class RoleDeletionCoordinator(
return new RoleDeletionOperationResult.PreconditionFailed(impact);
if (impact.Dependencies.Any(x => x.Ownership == RoleDeletionDependencyOwnership.Configuration))
return new RoleDeletionOperationResult.Blocked(impact);
var selectionError = ValidateSelectedReferences(impact, command.SelectedReferences);
if (selectionError is not null)
return new RoleDeletionOperationResult.ValidationFailed(impact, selectionError);
if (impact.CanDelete)
{
await roleStore.DeleteAsync(new() { Id = command.RoleId }, cancellationToken);
return new RoleDeletionOperationResult.Deleted([]);
}
var warnings = GetRequiredConfirmations(impact, command);
var selectedDependencies = SelectEditableDependencies(impact, command.SelectedReferences);
var replacementValidation = await ValidateReplacementRoleAsync(impact, command, selectedDependencies, cancellationToken);
if (replacementValidation is not null)
return replacementValidation;
var warnings = GetRequiredConfirmations(impact, command, selectedDependencies);
if (warnings.Count != 0)
return new RoleDeletionOperationResult.ConfirmationRequired(impact, warnings);
@ -75,12 +84,16 @@ public sealed class RoleDeletionCoordinator(
return new RoleDeletionOperationResult.PreconditionFailed(currentImpact);
var requests = snapshots
.Where(x => x.Dependencies.Any(dependency => dependency.Ownership == RoleDeletionDependencyOwnership.Database))
.Where(x => x.Dependencies.Any(dependency => dependency.Ownership == RoleDeletionDependencyOwnership.Database && IsSelected(dependency, command.SelectedReferences)))
.Select(snapshot => new RoleReferenceRemovalRequest(
command.RoleId,
command.Actor,
snapshot.Version,
snapshot.Dependencies.Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database).ToArray()))
snapshot.Dependencies.Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database && IsSelected(x, command.SelectedReferences)).ToArray())
{
SelectedReferences = command.SelectedReferences,
ReplacementRoleId = command.ReplacementRoleId
})
.ToArray();
foreach (var request in requests)
@ -176,18 +189,91 @@ public sealed class RoleDeletionCoordinator(
return $"role-dependencies-{Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant()}";
}
private static IReadOnlyCollection<string> GetRequiredConfirmations(RoleDeletionImpact impact, RoleDeletionRemediationCommand command)
private static IReadOnlyCollection<string> GetRequiredConfirmations(
RoleDeletionImpact impact,
RoleDeletionRemediationCommand command,
IReadOnlyCollection<RoleDeletionDependency> selectedDependencies)
{
var warnings = new List<string>();
if (!command.ConfirmRemoveFromEditablePolicies)
var selective = command.SelectedReferences is not null;
var remediationRequested = !selective || selectedDependencies.Count != 0;
if (remediationRequested && !command.ConfirmRemoveFromEditablePolicies)
warnings.Add("confirm_remove_from_editable_jit_policies");
if (impact.Dependencies.Any(x => x.RemovesLastDefaultRole) && !command.ConfirmEmptyDefaultRoles)
var removesLastDefaultRole = selective
? selectedDependencies.Any(x => x.RemovesLastDefaultRole)
: impact.Dependencies.Any(x => x.RemovesLastDefaultRole);
if (!selective && removesLastDefaultRole && !command.ConfirmEmptyDefaultRoles)
warnings.Add("removes_last_default_role");
if (impact.ExecutionMode == RoleDeletionExecutionMode.BestEffort && !command.ConfirmBestEffort)
if (remediationRequested && impact.ExecutionMode == RoleDeletionExecutionMode.BestEffort && !command.ConfirmBestEffort)
warnings.Add("confirm_best_effort");
return warnings;
}
private static IReadOnlyCollection<RoleDeletionDependency> SelectEditableDependencies(
RoleDeletionImpact impact,
IReadOnlyCollection<RoleDeletionReferenceSelection>? selectedReferences) =>
impact.Dependencies
.Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database && IsSelected(x, selectedReferences))
.ToArray();
private async ValueTask<RoleDeletionOperationResult?> ValidateReplacementRoleAsync(
RoleDeletionImpact impact,
RoleDeletionRemediationCommand command,
IReadOnlyCollection<RoleDeletionDependency> selectedDependencies,
CancellationToken cancellationToken)
{
if (command.SelectedReferences is null || !selectedDependencies.Any(x => x.RemovesLastDefaultRole))
return null;
if (string.IsNullOrWhiteSpace(command.ReplacementRoleId))
return new RoleDeletionOperationResult.ValidationFailed(impact, "replacement_role_required");
if (string.Equals(command.ReplacementRoleId, command.RoleId, StringComparison.Ordinal))
return new RoleDeletionOperationResult.ValidationFailed(impact, "replacement_role_must_differ");
var replacement = await roleStore.FindAsync(new() { Id = command.ReplacementRoleId }, cancellationToken);
if (replacement is null)
return new RoleDeletionOperationResult.ValidationFailed(impact, "replacement_role_not_found");
if (!await roleAuthorizationService.CanAssignRolesAsync(command.Actor, [replacement.Id], cancellationToken))
return new RoleDeletionOperationResult.Forbidden();
return null;
}
private static bool IsSelected(RoleDeletionDependency dependency, IReadOnlyCollection<RoleDeletionReferenceSelection>? selectedReferences) =>
selectedReferences is null || selectedReferences.Any(x =>
string.Equals(x.Source, dependency.Source, StringComparison.Ordinal) &&
string.Equals(x.OwnerId, dependency.OwnerId, StringComparison.Ordinal));
private static string? ValidateSelectedReferences(
RoleDeletionImpact impact,
IReadOnlyCollection<RoleDeletionReferenceSelection>? selectedReferences)
{
if (selectedReferences is null)
return null;
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var selection in selectedReferences)
{
if (selection is null || string.IsNullOrWhiteSpace(selection.Source) || string.IsNullOrWhiteSpace(selection.OwnerId))
return "invalid_reference_selection";
var key = $"{selection.Source}\n{selection.OwnerId}";
if (!seen.Add(key))
return "duplicate_reference";
var matches = impact.Dependencies
.Where(x => string.Equals(x.Source, selection.Source, StringComparison.Ordinal) &&
string.Equals(x.OwnerId, selection.OwnerId, StringComparison.Ordinal))
.ToArray();
if (matches.Length == 0)
return "unknown_reference";
if (matches.Any(x => x.Ownership != RoleDeletionDependencyOwnership.Database))
return "configuration_reference_not_editable";
}
return null;
}
private async ValueTask<RoleDeletionImpact> GetCurrentImpactAsync(string roleId, CancellationToken cancellationToken) =>
CreateImpact(roleId, await InspectContributorsAsync(roleId, cancellationToken));

View file

@ -0,0 +1,105 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using Elsa.Authorization;
using Elsa.Identity.Contracts;
using Elsa.Identity.Features;
using Elsa.Identity.Models;
using FastEndpoints;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Elsa.ExternalAuthentication.IntegrationTests.Fixtures;
namespace Elsa.ExternalAuthentication.IntegrationTests.Identity;
[Collection(nameof(EndpointSecurityCollection))]
public sealed class RoleDeletionEndpointContractTests : IAsyncLifetime
{
private WebApplication? _app;
private HttpClient? _client;
private bool _wasSecurityEnabled;
private readonly CapturingRoleDeletionCoordinator _coordinator = new();
public async Task InitializeAsync()
{
_wasSecurityEnabled = EndpointSecurityOptions.SecurityIsEnabled;
EndpointSecurityOptions.SecurityIsEnabled = false;
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddFastEndpoints(options =>
{
options.Assemblies = [typeof(IdentityFeature).Assembly];
options.Filter = endpoint => endpoint.Namespace == "Elsa.Identity.Endpoints.Roles.Delete";
});
builder.Services.AddAuthorization();
builder.Services.AddSingleton<IRoleDeletionCoordinator>(_coordinator);
_app = builder.Build();
_app.UseAuthorization();
_app.UseFastEndpoints();
await _app.StartAsync();
_client = _app.GetTestClient();
}
public async Task DisposeAsync()
{
EndpointSecurityOptions.SecurityIsEnabled = _wasSecurityEnabled;
_client?.Dispose();
if (_app is not null)
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}
[Fact]
public async Task RemediationBindsSelectedReferencesAndReplacementRole()
{
var response = await _client!.PostAsJsonAsync(
"/identity/roles/target-role/remove-from-jit-policies-and-delete",
new
{
expectedDependencyVersion = "dependency-version",
confirmRemoveFromEditableJitPolicies = true,
confirmEmptyDefaultRoles = true,
confirmBestEffort = true,
selectedReferences = new[] { new { source = "external-authentication", ownerId = "connection-a" } },
replacementRoleId = "replacement-role"
});
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.NotNull(_coordinator.Command);
Assert.Equal("target-role", _coordinator.Command.RoleId);
Assert.Equal("dependency-version", _coordinator.Command.ExpectedDependencyVersion);
Assert.Equal(
new RoleDeletionReferenceSelection("external-authentication", "connection-a"),
Assert.Single(_coordinator.Command.SelectedReferences!));
Assert.Equal("replacement-role", _coordinator.Command.ReplacementRoleId);
}
private sealed class CapturingRoleDeletionCoordinator : IRoleDeletionCoordinator
{
public RoleDeletionRemediationCommand? Command { get; private set; }
public ValueTask<RoleDeletionInspectionResult> InspectAsync(string roleId, ClaimsPrincipal actor, CancellationToken cancellationToken = default) =>
ValueTask.FromResult<RoleDeletionInspectionResult>(new RoleDeletionInspectionResult.NotFound());
public ValueTask<RoleDeletionOperationResult> DeleteAsync(string roleId, ClaimsPrincipal actor, CancellationToken cancellationToken = default) =>
ValueTask.FromResult<RoleDeletionOperationResult>(new RoleDeletionOperationResult.NotFound());
public ValueTask<RoleDeletionOperationResult> RemediateAndDeleteAsync(RoleDeletionRemediationCommand command, CancellationToken cancellationToken = default)
{
Command = command;
var impact = new RoleDeletionImpact(
command.RoleId,
command.ExpectedDependencyVersion,
RoleDeletionExecutionMode.BestEffort,
false,
true,
[]);
return ValueTask.FromResult<RoleDeletionOperationResult>(new RoleDeletionOperationResult.Incomplete(impact, [], "role_dependencies_remain"));
}
}
}

View file

@ -9,6 +9,7 @@ using Elsa.ExternalAuthentication.Permissions;
using Elsa.ExternalAuthentication.Policies;
using Elsa.ExternalAuthentication.Services;
using Elsa.ExternalAuthentication.Stores.InMemory;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using Elsa.Identity.Providers;
@ -108,6 +109,114 @@ public class ExternalAuthenticationRoleDeletionDependencyContributorTests
Assert.True(await versions.GetVersionAsync() > 0);
}
[Fact]
public async Task ReplacesFinalDefaultRoleWhenSelectedForRemediation()
{
var databaseConnection = Connection(
"database",
new PolicySelection(
CreateUserUnlinkedIdentityPolicy.PolicyType,
1,
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user" } })));
var (contributor, store, _) = await CreateContributorAsync([], [databaseConnection], [new Role { Id = "replacement-role", Name = "Replacement role", Permissions = [] }]);
var snapshot = await contributor.InspectAsync("workflow-user");
var request = new RoleReferenceRemovalRequest(
"workflow-user",
Administrator(),
snapshot.Version,
snapshot.Dependencies)
{
SelectedReferences = [new RoleDeletionReferenceSelection(ExternalAuthenticationRoleDeletionDependencyContributor.SourceName, databaseConnection.Id)],
ReplacementRoleId = "replacement-role"
};
Assert.IsType<RoleReferenceRemovalValidationResult.Valid>(await contributor.ValidateRemovalAsync(request));
var result = Assert.IsType<RoleReferenceRemovalResult.Success>(await contributor.RemoveEditableReferencesAsync(request));
Assert.Equal([databaseConnection.Id], result.ChangedOwnerIds);
var updated = Assert.IsType<IdentityProviderConnection>(await store.FindByIdAsync(databaseConnection.Id));
Assert.Equal(["replacement-role"], updated.UnlinkedPolicy!.Settings.GetProperty("defaultRoleIds").EnumerateArray().Select(x => x.GetString()!).ToArray());
}
[Fact]
public async Task RejectsMissingReplacementForSelectedFinalDefaultRole()
{
var databaseConnection = Connection(
"database",
new PolicySelection(
CreateUserUnlinkedIdentityPolicy.PolicyType,
1,
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user" } })));
var (contributor, store, _) = await CreateContributorAsync([], databaseConnection);
var snapshot = await contributor.InspectAsync("workflow-user");
var request = new RoleReferenceRemovalRequest(
"workflow-user",
Administrator(),
snapshot.Version,
snapshot.Dependencies)
{
SelectedReferences = [new RoleDeletionReferenceSelection(ExternalAuthenticationRoleDeletionDependencyContributor.SourceName, databaseConnection.Id)]
};
var result = await contributor.ValidateRemovalAsync(request);
var forbidden = Assert.IsType<RoleReferenceRemovalValidationResult.Forbidden>(result);
Assert.Equal("replacement_role_unavailable_or_unauthorized", forbidden.Code);
var current = Assert.IsType<IdentityProviderConnection>(await store.FindByIdAsync(databaseConnection.Id));
Assert.Equal(["workflow-user"], current.UnlinkedPolicy!.Settings.GetProperty("defaultRoleIds").EnumerateArray().Select(x => x.GetString()!).ToArray());
}
[Fact]
public async Task ReplacementRemovedAfterCoordinatorValidationFailsClosedBeforePolicyUpdate()
{
var databaseConnection = Connection(
"database",
new PolicySelection(
CreateUserUnlinkedIdentityPolicy.PolicyType,
1,
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user" } })));
var connectionStore = new InMemoryIdentityProviderConnectionStore();
Assert.IsType<ConnectionMutationResult.Created>(await connectionStore.CreateAsync(databaseConnection));
var backingRoleStore = new MemoryRoleStore(new MemoryStore<Role>(), TestTenantAccessor.Default);
await backingRoleStore.SaveAsync(new Role { Id = "workflow-user", Name = "Workflow user", Permissions = [] });
await backingRoleStore.SaveAsync(new Role { Id = "replacement-role", Name = "Replacement role", Permissions = [] });
var roleStore = new RoleStoreThatRemovesReplacementAfterContributorValidation(backingRoleStore, "replacement-role");
var roleAuthorizationService = new RoleAuthorizationService(new StoreBasedRoleProvider(roleStore), new PermissionEvaluator());
var versions = new InMemoryConnectionRegistryVersionStore();
var services = new ServiceCollection().BuildServiceProvider();
var contributor = new ExternalAuthenticationRoleDeletionDependencyContributor(
connectionStore,
new MutableOptionsMonitor<ExternalAuthenticationOptions>(new ExternalAuthenticationOptions()),
roleAuthorizationService,
roleStore,
versions,
new ConnectionRevisionCalculator(),
new ExternalAuthenticationSecurityNotifier(services),
new PermissionEvaluator());
var coordinator = new RoleDeletionCoordinator(roleStore, roleAuthorizationService, [contributor]);
var impact = Assert.IsType<RoleDeletionInspectionResult.Success>(await coordinator.InspectAsync("workflow-user", Administrator())).Impact;
var result = await coordinator.RemediateAndDeleteAsync(new RoleDeletionRemediationCommand(
"workflow-user",
Administrator(),
impact.DependencyVersion,
true,
true,
true)
{
SelectedReferences = [new RoleDeletionReferenceSelection(ExternalAuthenticationRoleDeletionDependencyContributor.SourceName, databaseConnection.Id)],
ReplacementRoleId = "replacement-role"
});
var incomplete = Assert.IsType<RoleDeletionOperationResult.Incomplete>(result);
Assert.Equal("replacement_role_unavailable_or_unauthorized", incomplete.Code);
Assert.True(roleStore.ReplacementRemoved);
Assert.NotNull(await roleStore.FindAsync(new() { Id = "workflow-user" }));
var current = Assert.IsType<IdentityProviderConnection>(await connectionStore.FindByIdAsync(databaseConnection.Id));
Assert.Equal(["workflow-user"], current.UnlinkedPolicy!.Settings.GetProperty("defaultRoleIds").EnumerateArray().Select(x => x.GetString()!).ToArray());
}
[Fact]
public async Task StaleConnectionRevisionFailsPrevalidationWithoutMutation()
{
@ -158,9 +267,15 @@ public class ExternalAuthenticationRoleDeletionDependencyContributorTests
Assert.IsType<RoleReferenceRemovalValidationResult.Forbidden>(result);
}
private static Task<(ExternalAuthenticationRoleDeletionDependencyContributor Contributor, InMemoryIdentityProviderConnectionStore Store, InMemoryConnectionRegistryVersionStore Versions)> CreateContributorAsync(
IReadOnlyCollection<IdentityProviderConnection> configuredConnections,
params IdentityProviderConnection[] databaseConnections) =>
CreateContributorAsync(configuredConnections, databaseConnections, null);
private static async Task<(ExternalAuthenticationRoleDeletionDependencyContributor Contributor, InMemoryIdentityProviderConnectionStore Store, InMemoryConnectionRegistryVersionStore Versions)> CreateContributorAsync(
IReadOnlyCollection<IdentityProviderConnection> configuredConnections,
params IdentityProviderConnection[] databaseConnections)
IdentityProviderConnection[] databaseConnections,
IReadOnlyCollection<Role>? additionalRoles = null)
{
var store = new InMemoryIdentityProviderConnectionStore();
foreach (var connection in databaseConnections)
@ -169,12 +284,15 @@ public class ExternalAuthenticationRoleDeletionDependencyContributorTests
var roleStore = new MemoryRoleStore(new MemoryStore<Role>(), TestTenantAccessor.Default);
await roleStore.SaveAsync(new Role { Id = "workflow-user", Name = "Workflow user", Permissions = [] });
await roleStore.SaveAsync(new Role { Id = "other-role", Name = "Other role", Permissions = [] });
foreach (var role in additionalRoles ?? [])
await roleStore.SaveAsync(role);
var versions = new InMemoryConnectionRegistryVersionStore();
var services = new ServiceCollection().BuildServiceProvider();
var contributor = new ExternalAuthenticationRoleDeletionDependencyContributor(
store,
new MutableOptionsMonitor<ExternalAuthenticationOptions>(new ExternalAuthenticationOptions { ConfigurationConnections = configuredConnections.ToList() }),
new RoleAuthorizationService(new StoreBasedRoleProvider(roleStore), new PermissionEvaluator()),
roleStore,
versions,
new ConnectionRevisionCalculator(),
new ExternalAuthenticationSecurityNotifier(services),
@ -215,4 +333,33 @@ public class ExternalAuthenticationRoleDeletionDependencyContributorTests
.Where(x => !string.Equals(x, omittedPermission, StringComparison.Ordinal))
.Select(x => new Claim(PermissionNames.ClaimType, x))));
}
private sealed class RoleStoreThatRemovesReplacementAfterContributorValidation(
MemoryRoleStore inner,
string replacementRoleId) : IRoleStore
{
private int _replacementChecks;
public bool ReplacementRemoved { get; private set; }
public Task AddAsync(Role role, CancellationToken cancellationToken = default) => inner.AddAsync(role, cancellationToken);
public Task DeleteAsync(RoleFilter filter, CancellationToken cancellationToken = default) => inner.DeleteAsync(filter, cancellationToken);
public Task SaveAsync(Role role, CancellationToken cancellationToken = default) => inner.SaveAsync(role, cancellationToken);
public Task<Role?> FindAsync(RoleFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
public async Task<IEnumerable<Role>> FindManyAsync(RoleFilter filter, CancellationToken cancellationToken = default)
{
var roles = (await inner.FindManyAsync(filter, cancellationToken)).ToArray();
if (!ReplacementRemoved && filter.Ids?.Contains(replacementRoleId) == true && Interlocked.Increment(ref _replacementChecks) == 2)
{
await inner.DeleteAsync(new() { Id = replacementRoleId }, cancellationToken);
ReplacementRemoved = true;
}
return roles;
}
}
}

View file

@ -12,6 +12,32 @@ namespace Elsa.Identity.UnitTests.Services;
public class RoleDeletionCoordinatorTests
{
[Fact]
public void RoleRemediationContractsRetainTheirLegacyConstructors()
{
var removalConstructor = typeof(RoleReferenceRemovalRequest).GetConstructor([
typeof(string),
typeof(ClaimsPrincipal),
typeof(string),
typeof(IReadOnlyCollection<RoleDeletionDependency>)]);
var commandConstructor = typeof(RoleDeletionRemediationCommand).GetConstructor([
typeof(string),
typeof(ClaimsPrincipal),
typeof(string),
typeof(bool),
typeof(bool),
typeof(bool)]);
Assert.NotNull(removalConstructor);
Assert.NotNull(commandConstructor);
Assert.Single(typeof(RoleReferenceRemovalRequest).GetConstructors());
Assert.Single(typeof(RoleDeletionRemediationCommand).GetConstructors());
Assert.NotNull(typeof(RoleReferenceRemovalRequest).GetProperty(nameof(RoleReferenceRemovalRequest.SelectedReferences)));
Assert.NotNull(typeof(RoleReferenceRemovalRequest).GetProperty(nameof(RoleReferenceRemovalRequest.ReplacementRoleId)));
Assert.NotNull(typeof(RoleDeletionRemediationCommand).GetProperty(nameof(RoleDeletionRemediationCommand.SelectedReferences)));
Assert.NotNull(typeof(RoleDeletionRemediationCommand).GetProperty(nameof(RoleDeletionRemediationCommand.ReplacementRoleId)));
}
[Fact]
public async Task InspectionRequiresDeleteRolePermission()
{
@ -130,6 +156,138 @@ public class RoleDeletionCoordinatorTests
Assert.Single(contributor.Dependencies);
}
[Fact]
public async Task SelectiveRemediationChangesOnlySelectedDependenciesAndRetainsRoleWhenOthersRemain()
{
var contributor = new StubContributor([Dependency("connection-a"), Dependency("connection-b")]);
var (store, coordinator) = await CreateCoordinatorAsync(contributor);
var impact = Assert.IsType<RoleDeletionInspectionResult.Success>(await coordinator.InspectAsync("workflow-user", Administrator())).Impact;
var result = await coordinator.RemediateAndDeleteAsync(new(
"workflow-user",
Administrator(),
impact.DependencyVersion,
true,
true,
true)
{
SelectedReferences = [new RoleDeletionReferenceSelection(StubContributor.SourceName, "connection-a")]
});
var incomplete = Assert.IsType<RoleDeletionOperationResult.Incomplete>(result);
Assert.Equal(["connection-a"], incomplete.ChangedOwnerIds);
Assert.NotNull(await store.FindAsync(new() { Id = "workflow-user" }));
Assert.Equal(["connection-b"], contributor.Dependencies.Select(x => x.OwnerId).ToArray());
}
[Fact]
public async Task ExplicitEmptySelectionDoesNotMutateAndReturnsIncomplete()
{
var contributor = new StubContributor([Dependency("connection-a")]);
var (store, coordinator) = await CreateCoordinatorAsync(contributor);
var impact = Assert.IsType<RoleDeletionInspectionResult.Success>(await coordinator.InspectAsync("workflow-user", Administrator())).Impact;
var result = await coordinator.RemediateAndDeleteAsync(new(
"workflow-user",
Administrator(),
impact.DependencyVersion,
true,
true,
true)
{
SelectedReferences = []
});
var incomplete = Assert.IsType<RoleDeletionOperationResult.Incomplete>(result);
Assert.Empty(incomplete.ChangedOwnerIds);
Assert.NotNull(await store.FindAsync(new() { Id = "workflow-user" }));
Assert.Single(contributor.Dependencies);
}
[Fact]
public async Task SelectedFinalDefaultRequiresCoordinatorValidatedReplacementBeforeMutation()
{
var contributor = new StubContributor([Dependency("connection-a", removesLastDefaultRole: true)]);
var (store, coordinator) = await CreateCoordinatorAsync(contributor);
var impact = Assert.IsType<RoleDeletionInspectionResult.Success>(await coordinator.InspectAsync("workflow-user", Administrator())).Impact;
var result = await coordinator.RemediateAndDeleteAsync(new(
"workflow-user",
Administrator(),
impact.DependencyVersion,
true,
true,
true)
{
SelectedReferences = [new RoleDeletionReferenceSelection(StubContributor.SourceName, "connection-a")],
ReplacementRoleId = "replacement-role"
});
var validation = Assert.IsType<RoleDeletionOperationResult.ValidationFailed>(result);
Assert.Equal("replacement_role_not_found", validation.Code);
Assert.NotNull(await store.FindAsync(new() { Id = "workflow-user" }));
Assert.Single(contributor.Dependencies);
}
[Fact]
public async Task ConfigurationDependencyBlocksSelectiveDatabaseRemediation()
{
var contributor = new StubContributor([
Dependency("configuration", RoleDeletionDependencyOwnership.Configuration, configurationPath: "ExternalAuthentication:Connections:0"),
Dependency("connection-a")
]);
var (store, coordinator) = await CreateCoordinatorAsync(contributor);
var impact = Assert.IsType<RoleDeletionInspectionResult.Success>(await coordinator.InspectAsync("workflow-user", Administrator())).Impact;
var result = await coordinator.RemediateAndDeleteAsync(new(
"workflow-user",
Administrator(),
impact.DependencyVersion,
true,
true,
true)
{
SelectedReferences = [new RoleDeletionReferenceSelection(StubContributor.SourceName, "connection-a")]
});
Assert.IsType<RoleDeletionOperationResult.Blocked>(result);
Assert.NotNull(await store.FindAsync(new() { Id = "workflow-user" }));
Assert.Equal(2, contributor.Dependencies.Count);
}
[Theory]
[InlineData("unknown", "connection-a", "unknown_reference")]
[InlineData(StubContributor.SourceName, "connection-a", "duplicate_reference")]
public async Task InvalidSelectionFailsClosedWithoutMutation(string source, string ownerId, string expectedCode)
{
var contributor = new StubContributor([Dependency("connection-a")]);
var (store, coordinator) = await CreateCoordinatorAsync(contributor);
var impact = Assert.IsType<RoleDeletionInspectionResult.Success>(await coordinator.InspectAsync("workflow-user", Administrator())).Impact;
var selections = expectedCode == "duplicate_reference"
? new[]
{
new RoleDeletionReferenceSelection(source, ownerId),
new RoleDeletionReferenceSelection(source, ownerId)
}
: new[] { new RoleDeletionReferenceSelection(source, ownerId) };
var result = await coordinator.RemediateAndDeleteAsync(new(
"workflow-user",
Administrator(),
impact.DependencyVersion,
true,
true,
true)
{
SelectedReferences = selections
});
var validation = Assert.IsType<RoleDeletionOperationResult.ValidationFailed>(result);
Assert.Equal(expectedCode, validation.Code);
Assert.NotNull(await store.FindAsync(new() { Id = "workflow-user" }));
Assert.Single(contributor.Dependencies);
}
private static async Task<(MemoryRoleStore Store, RoleDeletionCoordinator Coordinator)> CreateCoordinatorAsync(IRoleDeletionDependencyContributor contributor)
{
var store = new MemoryRoleStore(new MemoryStore<Role>(), TestTenantAccessor.Default);
@ -177,15 +335,16 @@ public class RoleDeletionCoordinatorTests
public ValueTask<RoleReferenceRemovalResult> RemoveEditableReferencesAsync(RoleReferenceRemovalRequest request, CancellationToken cancellationToken = default)
{
var selectedOwnerIds = request.Dependencies.Select(x => x.OwnerId).ToHashSet(StringComparer.Ordinal);
if (failAfterFirst)
{
var changed = Dependencies.OrderBy(x => x.OwnerId, StringComparer.Ordinal).First();
var changed = Dependencies.Where(x => selectedOwnerIds.Contains(x.OwnerId)).OrderBy(x => x.OwnerId, StringComparer.Ordinal).First();
Dependencies = Dependencies.Where(x => !string.Equals(x.OwnerId, changed.OwnerId, StringComparison.Ordinal)).ToArray();
return ValueTask.FromResult<RoleReferenceRemovalResult>(new RoleReferenceRemovalResult.Failed("simulated", [changed.OwnerId]));
}
var changedOwnerIds = Dependencies.Select(x => x.OwnerId).ToArray();
Dependencies = [];
var changedOwnerIds = Dependencies.Where(x => selectedOwnerIds.Contains(x.OwnerId)).Select(x => x.OwnerId).ToArray();
Dependencies = Dependencies.Where(x => !selectedOwnerIds.Contains(x.OwnerId)).ToArray();
return ValueTask.FromResult<RoleReferenceRemovalResult>(new RoleReferenceRemovalResult.Success(changedOwnerIds));
}