< Summary

Information
Class: Elsa.Identity.Services.RoleDeletionCoordinator
Assembly: Elsa.Identity
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Identity/Services/RoleDeletionCoordinator.cs
Line coverage
87%
Covered lines: 166
Uncovered lines: 23
Coverable lines: 189
Total lines: 326
Line coverage: 87.8%
Branch coverage
76%
Covered branches: 89
Total branches: 116
Branch coverage: 76.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
InspectAsync()83.33%6687.5%
DeleteAsync()66.66%6680%
RemediateAndDeleteAsync()73.68%523878.84%
DeleteRoleAsync()83.33%6690%
InspectContributorsAsync()66.66%6687.5%
CreateImpact(...)100%44100%
CalculateDependencyVersion(...)100%11100%
GetRequiredConfirmations(...)100%1818100%
SelectEditableDependencies(...)50%22100%
ValidateReplacementRoleAsync()58.33%221258.33%
IsSelected(...)75%44100%
ValidateSelectedReferences(...)77.77%181889.47%
GetCurrentImpactAsync()100%11100%
.cctor()100%11100%
HasPermission(...)100%11100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Identity/Services/RoleDeletionCoordinator.cs

#LineLine coverage
 1using System.Security.Claims;
 2using System.Security.Cryptography;
 3using System.Text;
 4using Elsa.Authorization;
 5using Elsa.Identity.Contracts;
 6using Elsa.Identity.Models;
 7
 8namespace Elsa.Identity.Services;
 9
 10/// <inheritdoc />
 2211public sealed class RoleDeletionCoordinator(
 2212    IRoleStore roleStore,
 2213    IRoleAuthorizationService roleAuthorizationService,
 2214    IEnumerable<IRoleDeletionDependencyContributor> contributors,
 2215    RoleSecurityNotifier securityNotifier) : IRoleDeletionCoordinator
 16{
 4117    private readonly IReadOnlyDictionary<string, IRoleDeletionDependencyContributor> _contributors = contributors.ToDict
 2218    private readonly IRoleStoreWithAtomicDelete? _atomicRoleStore = roleStore as IRoleStoreWithAtomicDelete;
 19
 20    /// <inheritdoc />
 21    public async ValueTask<RoleDeletionInspectionResult> InspectAsync(string roleId, ClaimsPrincipal actor, Cancellation
 22    {
 3223        var role = await roleStore.FindAsync(new() { Id = roleId }, cancellationToken);
 3224        if (role is null)
 025            return new RoleDeletionInspectionResult.NotFound();
 3226        if (!HasPermission(actor) || !roleAuthorizationService.CanMutateRole(actor, role))
 227            return new RoleDeletionInspectionResult.Forbidden();
 28
 3029        var snapshots = await InspectContributorsAsync(roleId, cancellationToken);
 3030        return new RoleDeletionInspectionResult.Success(CreateImpact(roleId, snapshots));
 3231    }
 32
 33    /// <inheritdoc />
 34    public async ValueTask<RoleDeletionOperationResult> DeleteAsync(string roleId, ClaimsPrincipal actor, CancellationTo
 35    {
 636        var inspection = await InspectAsync(roleId, actor, cancellationToken);
 637        if (inspection is RoleDeletionInspectionResult.NotFound)
 038            return new RoleDeletionOperationResult.NotFound();
 639        if (inspection is RoleDeletionInspectionResult.Forbidden)
 040            return new RoleDeletionOperationResult.Forbidden();
 41
 642        var impact = ((RoleDeletionInspectionResult.Success)inspection).Impact;
 643        if (!impact.CanDelete)
 144            return new RoleDeletionOperationResult.Blocked(impact);
 45
 546        return await DeleteRoleAsync(roleId, actor, [], cancellationToken);
 647    }
 48
 49    /// <inheritdoc />
 50    public async ValueTask<RoleDeletionOperationResult> RemediateAndDeleteAsync(RoleDeletionRemediationCommand command, 
 51    {
 952        var inspection = await InspectAsync(command.RoleId, command.Actor, cancellationToken);
 953        if (inspection is RoleDeletionInspectionResult.NotFound)
 054            return new RoleDeletionOperationResult.NotFound();
 955        if (inspection is RoleDeletionInspectionResult.Forbidden)
 056            return new RoleDeletionOperationResult.Forbidden();
 57
 958        var impact = ((RoleDeletionInspectionResult.Success)inspection).Impact;
 959        if (!string.Equals(impact.DependencyVersion, command.ExpectedDependencyVersion, StringComparison.Ordinal))
 060            return new RoleDeletionOperationResult.PreconditionFailed(impact);
 2061        if (impact.Dependencies.Any(x => x.Ownership == RoleDeletionDependencyOwnership.Configuration))
 162            return new RoleDeletionOperationResult.Blocked(impact);
 863        var selectionError = ValidateSelectedReferences(impact, command.SelectedReferences);
 864        if (selectionError is not null)
 265            return new RoleDeletionOperationResult.ValidationFailed(impact, selectionError);
 66
 667        if (impact.CanDelete)
 068            return await DeleteRoleAsync(command.RoleId, command.Actor, [], cancellationToken);
 69
 670        var selectedDependencies = SelectEditableDependencies(impact, command.SelectedReferences);
 671        var replacementValidation = await ValidateReplacementRoleAsync(impact, command, selectedDependencies, cancellati
 672        if (replacementValidation is not null)
 173            return replacementValidation;
 74
 575        var warnings = GetRequiredConfirmations(impact, command, selectedDependencies);
 576        if (warnings.Count != 0)
 177            return new RoleDeletionOperationResult.ConfirmationRequired(impact, warnings);
 78
 479        var snapshots = await InspectContributorsAsync(command.RoleId, cancellationToken);
 480        var currentImpact = CreateImpact(command.RoleId, snapshots);
 481        if (!string.Equals(currentImpact.DependencyVersion, command.ExpectedDependencyVersion, StringComparison.Ordinal)
 082            return new RoleDeletionOperationResult.PreconditionFailed(currentImpact);
 83
 484        var requests = snapshots
 885            .Where(x => x.Dependencies.Any(dependency => dependency.Ownership == RoleDeletionDependencyOwnership.Databas
 386            .Select(snapshot => new RoleReferenceRemovalRequest(
 387                command.RoleId,
 388                command.Actor,
 389                snapshot.Version,
 590                snapshot.Dependencies.Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database && IsSelected(x
 391            {
 392                SelectedReferences = command.SelectedReferences,
 393                ReplacementRoleId = command.ReplacementRoleId
 394            })
 495            .ToArray();
 96
 1497        foreach (var request in requests)
 98        {
 399            var validation = await _contributors[request.Dependencies.First().Source].ValidateRemovalAsync(request, canc
 3100            if (validation is RoleReferenceRemovalValidationResult.Forbidden)
 0101                return new RoleDeletionOperationResult.Forbidden();
 3102            if (validation is RoleReferenceRemovalValidationResult.Conflict)
 0103                return new RoleDeletionOperationResult.PreconditionFailed(await GetCurrentImpactAsync(command.RoleId, ca
 104        }
 105
 4106        var changedOwnerIds = new List<string>();
 13107        foreach (var request in requests)
 108        {
 3109            var removal = await _contributors[request.Dependencies.First().Source].RemoveEditableReferencesAsync(request
 110            switch (removal)
 111            {
 112                case RoleReferenceRemovalResult.Success success:
 2113                    changedOwnerIds.AddRange(success.ChangedOwnerIds);
 2114                    break;
 115                case RoleReferenceRemovalResult.Conflict conflict:
 0116                    changedOwnerIds.AddRange(conflict.ChangedOwnerIds);
 0117                    return new RoleDeletionOperationResult.Incomplete(await GetCurrentImpactAsync(command.RoleId, cancel
 118                case RoleReferenceRemovalResult.Failed failed:
 1119                    changedOwnerIds.AddRange(failed.ChangedOwnerIds);
 1120                    return new RoleDeletionOperationResult.Incomplete(await GetCurrentImpactAsync(command.RoleId, cancel
 121            }
 122        }
 123
 3124        var finalInspection = await InspectAsync(command.RoleId, command.Actor, cancellationToken);
 3125        if (finalInspection is RoleDeletionInspectionResult.NotFound)
 0126            return new RoleDeletionOperationResult.NotFound();
 3127        if (finalInspection is RoleDeletionInspectionResult.Forbidden)
 0128            return new RoleDeletionOperationResult.Forbidden();
 129
 3130        var finalImpact = ((RoleDeletionInspectionResult.Success)finalInspection).Impact;
 3131        if (!finalImpact.CanDelete)
 2132            return new RoleDeletionOperationResult.Incomplete(finalImpact, changedOwnerIds.Distinct(StringComparer.Ordin
 133
 1134        return await DeleteRoleAsync(command.RoleId, command.Actor, changedOwnerIds.Distinct(StringComparer.Ordinal).ToA
 9135    }
 136
 137    /// <summary>
 138    /// Deletes the role and publishes the deletion to security subscribers, reporting
 139    /// <see cref="RoleDeletionOperationResult.NotFound"/> when this call did not remove it.
 140    /// </summary>
 141    /// <remarks>
 142    /// The snapshot taken before the delete is what the notification carries, because the name and permissions a
 143    /// reviewer needs are gone once the row is. The snapshot alone cannot decide whether to publish: a concurrent
 144    /// request may remove the role between the read and the delete, and both callers would then report a deletion
 145    /// they did not perform. Where the store implements <see cref="IRoleStoreWithAtomicDelete"/> the store's own
 146    /// affected-row verdict decides instead, so exactly one racing caller publishes. Stores that do not implement
 147    /// that capability keep the legacy find-then-delete path and publish once the delete returns, which preserves
 148    /// the notification for third-party stores at the cost of not distinguishing concurrent callers.
 149    /// </remarks>
 150    private async ValueTask<RoleDeletionOperationResult> DeleteRoleAsync(
 151        string roleId,
 152        ClaimsPrincipal actor,
 153        IReadOnlyCollection<string> changedOwnerIds,
 154        CancellationToken cancellationToken)
 155    {
 6156        var role = await roleStore.FindAsync(new() { Id = roleId }, cancellationToken);
 6157        if (role is null)
 0158            return new RoleDeletionOperationResult.NotFound();
 159
 6160        if (_atomicRoleStore is not null)
 161        {
 5162            if (!await _atomicRoleStore.TryDeleteAsync(roleId, cancellationToken))
 2163                return new RoleDeletionOperationResult.NotFound();
 164        }
 165        else
 166        {
 1167            await roleStore.DeleteAsync(new() { Id = roleId }, cancellationToken);
 168        }
 169
 170        // The role is already gone, so the notification is published with a token the request cannot cancel:
 171        // a caller that walks away mid-request must not silence a deletion that has completed.
 4172        await securityNotifier.RoleChangedAsync(actor, "deleted", role.Id, role.Name, role.Permissions.ToArray(), Cancel
 4173        return new RoleDeletionOperationResult.Deleted(changedOwnerIds);
 6174    }
 175
 176    private async ValueTask<IReadOnlyCollection<RoleDeletionDependencySnapshot>> InspectContributorsAsync(string roleId,
 177    {
 35178        var snapshots = new List<RoleDeletionDependencySnapshot>(_contributors.Count);
 210179        foreach (var contributor in _contributors.OrderBy(x => x.Key, StringComparer.Ordinal).Select(x => x.Value))
 180        {
 35181            var snapshot = await contributor.InspectAsync(roleId, cancellationToken);
 35182            if (!string.Equals(snapshot.Source, contributor.Source, StringComparison.Ordinal) ||
 69183                snapshot.Dependencies.Any(x => !string.Equals(x.Source, contributor.Source, StringComparison.Ordinal)))
 0184                throw new InvalidOperationException($"Role-deletion contributor '{contributor.Source}' returned a mismat
 35185            snapshots.Add(snapshot);
 35186        }
 187
 35188        return snapshots;
 35189    }
 190
 191    private static RoleDeletionImpact CreateImpact(string roleId, IReadOnlyCollection<RoleDeletionDependencySnapshot> sn
 192    {
 35193        var dependencies = snapshots
 35194            .SelectMany(x => x.Dependencies)
 16195            .OrderBy(x => x.Source, StringComparer.Ordinal)
 16196            .ThenBy(x => x.Ownership)
 16197            .ThenBy(x => x.OwnerId, StringComparer.Ordinal)
 16198            .ThenBy(x => x.PolicyBranch, StringComparer.Ordinal)
 35199            .ToArray();
 200        // The current coordinator has no unit of work spanning contributor stores and IRoleStore.
 201        // Contributor-local atomicity alone cannot make the complete remove-then-delete command atomic.
 98202        var hasEditableDependencies = snapshots.Any(x => x.Dependencies.Any(dependency => dependency.Ownership == RoleDe
 35203        var executionMode = hasEditableDependencies ? RoleDeletionExecutionMode.BestEffort : RoleDeletionExecutionMode.A
 35204        return new RoleDeletionImpact(
 35205            roleId,
 35206            CalculateDependencyVersion(snapshots),
 35207            executionMode,
 35208            dependencies.Length == 0,
 32209            dependencies.Length != 0 && dependencies.All(x => x.Ownership == RoleDeletionDependencyOwnership.Database),
 35210            dependencies);
 211    }
 212
 213    private static string CalculateDependencyVersion(IEnumerable<RoleDeletionDependencySnapshot> snapshots)
 214    {
 35215        var payload = string.Join(
 35216            "\n",
 35217            snapshots
 35218                .OrderBy(x => x.Source, StringComparer.Ordinal)
 70219                .SelectMany(snapshot => new[] { $"{snapshot.Source}|{snapshot.Version}|{snapshot.SupportsAtomicRemoval}"
 70220                    .Concat(snapshot.Dependencies
 34221                        .OrderBy(x => x.Ownership)
 34222                        .ThenBy(x => x.OwnerId, StringComparer.Ordinal)
 34223                        .ThenBy(x => x.PolicyBranch, StringComparer.Ordinal)
 104224                        .Select(x => $"{x.Source}|{x.OwnerId}|{x.OwnerKey}|{x.PolicyBranch}|{x.Ownership}|{x.Configurati
 35225        return $"role-dependencies-{Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvaria
 226    }
 227
 228    private static IReadOnlyCollection<string> GetRequiredConfirmations(
 229        RoleDeletionImpact impact,
 230        RoleDeletionRemediationCommand command,
 231        IReadOnlyCollection<RoleDeletionDependency> selectedDependencies)
 232    {
 5233        var warnings = new List<string>();
 5234        var selective = command.SelectedReferences is not null;
 5235        var remediationRequested = !selective || selectedDependencies.Count != 0;
 5236        if (remediationRequested && !command.ConfirmRemoveFromEditablePolicies)
 1237            warnings.Add("confirm_remove_from_editable_jit_policies");
 5238        var removesLastDefaultRole = selective
 1239            ? selectedDependencies.Any(x => x.RemovesLastDefaultRole)
 9240            : impact.Dependencies.Any(x => x.RemovesLastDefaultRole);
 5241        if (!selective && removesLastDefaultRole && !command.ConfirmEmptyDefaultRoles)
 1242            warnings.Add("removes_last_default_role");
 5243        if (remediationRequested && impact.ExecutionMode == RoleDeletionExecutionMode.BestEffort && !command.ConfirmBest
 1244            warnings.Add("confirm_best_effort");
 5245        return warnings;
 246    }
 247
 248    private static IReadOnlyCollection<RoleDeletionDependency> SelectEditableDependencies(
 249        RoleDeletionImpact impact,
 250        IReadOnlyCollection<RoleDeletionReferenceSelection>? selectedReferences) =>
 6251        impact.Dependencies
 8252            .Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database && IsSelected(x, selectedReferences))
 6253            .ToArray();
 254
 255    private async ValueTask<RoleDeletionOperationResult?> ValidateReplacementRoleAsync(
 256        RoleDeletionImpact impact,
 257        RoleDeletionRemediationCommand command,
 258        IReadOnlyCollection<RoleDeletionDependency> selectedDependencies,
 259        CancellationToken cancellationToken)
 260    {
 8261        if (command.SelectedReferences is null || !selectedDependencies.Any(x => x.RemovesLastDefaultRole))
 5262            return null;
 263
 1264        if (string.IsNullOrWhiteSpace(command.ReplacementRoleId))
 0265            return new RoleDeletionOperationResult.ValidationFailed(impact, "replacement_role_required");
 1266        if (string.Equals(command.ReplacementRoleId, command.RoleId, StringComparison.Ordinal))
 0267            return new RoleDeletionOperationResult.ValidationFailed(impact, "replacement_role_must_differ");
 268
 1269        var replacement = await roleStore.FindAsync(new() { Id = command.ReplacementRoleId }, cancellationToken);
 1270        if (replacement is null)
 1271            return new RoleDeletionOperationResult.ValidationFailed(impact, "replacement_role_not_found");
 0272        if (!await roleAuthorizationService.CanAssignRolesAsync(command.Actor, [replacement.Id], cancellationToken))
 0273            return new RoleDeletionOperationResult.Forbidden();
 274
 0275        return null;
 6276    }
 277
 278    private static bool IsSelected(RoleDeletionDependency dependency, IReadOnlyCollection<RoleDeletionReferenceSelection
 17279        selectedReferences is null || selectedReferences.Any(x =>
 23280            string.Equals(x.Source, dependency.Source, StringComparison.Ordinal) &&
 23281            string.Equals(x.OwnerId, dependency.OwnerId, StringComparison.Ordinal));
 282
 283    private static string? ValidateSelectedReferences(
 284        RoleDeletionImpact impact,
 285        IReadOnlyCollection<RoleDeletionReferenceSelection>? selectedReferences)
 286    {
 8287        if (selectedReferences is null)
 3288            return null;
 289
 5290        var seen = new HashSet<string>(StringComparer.Ordinal);
 18291        foreach (var selection in selectedReferences)
 292        {
 5293            if (selection is null || string.IsNullOrWhiteSpace(selection.Source) || string.IsNullOrWhiteSpace(selection.
 0294                return "invalid_reference_selection";
 295
 5296            var key = $"{selection.Source}\n{selection.OwnerId}";
 5297            if (!seen.Add(key))
 1298                return "duplicate_reference";
 299
 4300            var matches = impact.Dependencies
 5301                .Where(x => string.Equals(x.Source, selection.Source, StringComparison.Ordinal) &&
 5302                            string.Equals(x.OwnerId, selection.OwnerId, StringComparison.Ordinal))
 4303                .ToArray();
 4304            if (matches.Length == 0)
 1305                return "unknown_reference";
 6306            if (matches.Any(x => x.Ownership != RoleDeletionDependencyOwnership.Database))
 0307                return "configuration_reference_not_editable";
 308        }
 309
 3310        return null;
 2311    }
 312
 313    private async ValueTask<RoleDeletionImpact> GetCurrentImpactAsync(string roleId, CancellationToken cancellationToken
 1314        CreateImpact(roleId, await InspectContributorsAsync(roleId, cancellationToken));
 315
 316    /// <summary>The permission this mid-handler check enforces, matching what the delete endpoints declare.</summary>
 1317    private static readonly Permission DeleteRoles = new(Permissions.IdentityPermissions.Roles, CoreVerbs.Delete);
 318
 319    // Evaluated through the shared evaluator rather than by claim-value equality. This previously compared
 320    // against the legacy string "delete:role", which nothing has granted since the vocabulary migration, so
 321    // every caller except a holder of "*" was refused here after already passing the endpoint's own
 322    // identity/roles:delete check. Going through the evaluator also lets a wildcard grant such as
 323    // identity/*:delete reach this path, as it already does at the endpoint.
 324    private static bool HasPermission(ClaimsPrincipal actor) =>
 32325        PermissionEvaluator.Shared.HasPermission(actor, DeleteRoles);
 326}