| | | 1 | | using System.Security.Claims; |
| | | 2 | | using System.Security.Cryptography; |
| | | 3 | | using System.Text; |
| | | 4 | | using Elsa.Authorization; |
| | | 5 | | using Elsa.Identity.Contracts; |
| | | 6 | | using Elsa.Identity.Models; |
| | | 7 | | |
| | | 8 | | namespace Elsa.Identity.Services; |
| | | 9 | | |
| | | 10 | | /// <inheritdoc /> |
| | 22 | 11 | | public sealed class RoleDeletionCoordinator( |
| | 22 | 12 | | IRoleStore roleStore, |
| | 22 | 13 | | IRoleAuthorizationService roleAuthorizationService, |
| | 22 | 14 | | IEnumerable<IRoleDeletionDependencyContributor> contributors, |
| | 22 | 15 | | RoleSecurityNotifier securityNotifier) : IRoleDeletionCoordinator |
| | | 16 | | { |
| | 41 | 17 | | private readonly IReadOnlyDictionary<string, IRoleDeletionDependencyContributor> _contributors = contributors.ToDict |
| | 22 | 18 | | private readonly IRoleStoreWithAtomicDelete? _atomicRoleStore = roleStore as IRoleStoreWithAtomicDelete; |
| | | 19 | | |
| | | 20 | | /// <inheritdoc /> |
| | | 21 | | public async ValueTask<RoleDeletionInspectionResult> InspectAsync(string roleId, ClaimsPrincipal actor, Cancellation |
| | | 22 | | { |
| | 32 | 23 | | var role = await roleStore.FindAsync(new() { Id = roleId }, cancellationToken); |
| | 32 | 24 | | if (role is null) |
| | 0 | 25 | | return new RoleDeletionInspectionResult.NotFound(); |
| | 32 | 26 | | if (!HasPermission(actor) || !roleAuthorizationService.CanMutateRole(actor, role)) |
| | 2 | 27 | | return new RoleDeletionInspectionResult.Forbidden(); |
| | | 28 | | |
| | 30 | 29 | | var snapshots = await InspectContributorsAsync(roleId, cancellationToken); |
| | 30 | 30 | | return new RoleDeletionInspectionResult.Success(CreateImpact(roleId, snapshots)); |
| | 32 | 31 | | } |
| | | 32 | | |
| | | 33 | | /// <inheritdoc /> |
| | | 34 | | public async ValueTask<RoleDeletionOperationResult> DeleteAsync(string roleId, ClaimsPrincipal actor, CancellationTo |
| | | 35 | | { |
| | 6 | 36 | | var inspection = await InspectAsync(roleId, actor, cancellationToken); |
| | 6 | 37 | | if (inspection is RoleDeletionInspectionResult.NotFound) |
| | 0 | 38 | | return new RoleDeletionOperationResult.NotFound(); |
| | 6 | 39 | | if (inspection is RoleDeletionInspectionResult.Forbidden) |
| | 0 | 40 | | return new RoleDeletionOperationResult.Forbidden(); |
| | | 41 | | |
| | 6 | 42 | | var impact = ((RoleDeletionInspectionResult.Success)inspection).Impact; |
| | 6 | 43 | | if (!impact.CanDelete) |
| | 1 | 44 | | return new RoleDeletionOperationResult.Blocked(impact); |
| | | 45 | | |
| | 5 | 46 | | return await DeleteRoleAsync(roleId, actor, [], cancellationToken); |
| | 6 | 47 | | } |
| | | 48 | | |
| | | 49 | | /// <inheritdoc /> |
| | | 50 | | public async ValueTask<RoleDeletionOperationResult> RemediateAndDeleteAsync(RoleDeletionRemediationCommand command, |
| | | 51 | | { |
| | 9 | 52 | | var inspection = await InspectAsync(command.RoleId, command.Actor, cancellationToken); |
| | 9 | 53 | | if (inspection is RoleDeletionInspectionResult.NotFound) |
| | 0 | 54 | | return new RoleDeletionOperationResult.NotFound(); |
| | 9 | 55 | | if (inspection is RoleDeletionInspectionResult.Forbidden) |
| | 0 | 56 | | return new RoleDeletionOperationResult.Forbidden(); |
| | | 57 | | |
| | 9 | 58 | | var impact = ((RoleDeletionInspectionResult.Success)inspection).Impact; |
| | 9 | 59 | | if (!string.Equals(impact.DependencyVersion, command.ExpectedDependencyVersion, StringComparison.Ordinal)) |
| | 0 | 60 | | return new RoleDeletionOperationResult.PreconditionFailed(impact); |
| | 20 | 61 | | if (impact.Dependencies.Any(x => x.Ownership == RoleDeletionDependencyOwnership.Configuration)) |
| | 1 | 62 | | return new RoleDeletionOperationResult.Blocked(impact); |
| | 8 | 63 | | var selectionError = ValidateSelectedReferences(impact, command.SelectedReferences); |
| | 8 | 64 | | if (selectionError is not null) |
| | 2 | 65 | | return new RoleDeletionOperationResult.ValidationFailed(impact, selectionError); |
| | | 66 | | |
| | 6 | 67 | | if (impact.CanDelete) |
| | 0 | 68 | | return await DeleteRoleAsync(command.RoleId, command.Actor, [], cancellationToken); |
| | | 69 | | |
| | 6 | 70 | | var selectedDependencies = SelectEditableDependencies(impact, command.SelectedReferences); |
| | 6 | 71 | | var replacementValidation = await ValidateReplacementRoleAsync(impact, command, selectedDependencies, cancellati |
| | 6 | 72 | | if (replacementValidation is not null) |
| | 1 | 73 | | return replacementValidation; |
| | | 74 | | |
| | 5 | 75 | | var warnings = GetRequiredConfirmations(impact, command, selectedDependencies); |
| | 5 | 76 | | if (warnings.Count != 0) |
| | 1 | 77 | | return new RoleDeletionOperationResult.ConfirmationRequired(impact, warnings); |
| | | 78 | | |
| | 4 | 79 | | var snapshots = await InspectContributorsAsync(command.RoleId, cancellationToken); |
| | 4 | 80 | | var currentImpact = CreateImpact(command.RoleId, snapshots); |
| | 4 | 81 | | if (!string.Equals(currentImpact.DependencyVersion, command.ExpectedDependencyVersion, StringComparison.Ordinal) |
| | 0 | 82 | | return new RoleDeletionOperationResult.PreconditionFailed(currentImpact); |
| | | 83 | | |
| | 4 | 84 | | var requests = snapshots |
| | 8 | 85 | | .Where(x => x.Dependencies.Any(dependency => dependency.Ownership == RoleDeletionDependencyOwnership.Databas |
| | 3 | 86 | | .Select(snapshot => new RoleReferenceRemovalRequest( |
| | 3 | 87 | | command.RoleId, |
| | 3 | 88 | | command.Actor, |
| | 3 | 89 | | snapshot.Version, |
| | 5 | 90 | | snapshot.Dependencies.Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database && IsSelected(x |
| | 3 | 91 | | { |
| | 3 | 92 | | SelectedReferences = command.SelectedReferences, |
| | 3 | 93 | | ReplacementRoleId = command.ReplacementRoleId |
| | 3 | 94 | | }) |
| | 4 | 95 | | .ToArray(); |
| | | 96 | | |
| | 14 | 97 | | foreach (var request in requests) |
| | | 98 | | { |
| | 3 | 99 | | var validation = await _contributors[request.Dependencies.First().Source].ValidateRemovalAsync(request, canc |
| | 3 | 100 | | if (validation is RoleReferenceRemovalValidationResult.Forbidden) |
| | 0 | 101 | | return new RoleDeletionOperationResult.Forbidden(); |
| | 3 | 102 | | if (validation is RoleReferenceRemovalValidationResult.Conflict) |
| | 0 | 103 | | return new RoleDeletionOperationResult.PreconditionFailed(await GetCurrentImpactAsync(command.RoleId, ca |
| | | 104 | | } |
| | | 105 | | |
| | 4 | 106 | | var changedOwnerIds = new List<string>(); |
| | 13 | 107 | | foreach (var request in requests) |
| | | 108 | | { |
| | 3 | 109 | | var removal = await _contributors[request.Dependencies.First().Source].RemoveEditableReferencesAsync(request |
| | | 110 | | switch (removal) |
| | | 111 | | { |
| | | 112 | | case RoleReferenceRemovalResult.Success success: |
| | 2 | 113 | | changedOwnerIds.AddRange(success.ChangedOwnerIds); |
| | 2 | 114 | | break; |
| | | 115 | | case RoleReferenceRemovalResult.Conflict conflict: |
| | 0 | 116 | | changedOwnerIds.AddRange(conflict.ChangedOwnerIds); |
| | 0 | 117 | | return new RoleDeletionOperationResult.Incomplete(await GetCurrentImpactAsync(command.RoleId, cancel |
| | | 118 | | case RoleReferenceRemovalResult.Failed failed: |
| | 1 | 119 | | changedOwnerIds.AddRange(failed.ChangedOwnerIds); |
| | 1 | 120 | | return new RoleDeletionOperationResult.Incomplete(await GetCurrentImpactAsync(command.RoleId, cancel |
| | | 121 | | } |
| | | 122 | | } |
| | | 123 | | |
| | 3 | 124 | | var finalInspection = await InspectAsync(command.RoleId, command.Actor, cancellationToken); |
| | 3 | 125 | | if (finalInspection is RoleDeletionInspectionResult.NotFound) |
| | 0 | 126 | | return new RoleDeletionOperationResult.NotFound(); |
| | 3 | 127 | | if (finalInspection is RoleDeletionInspectionResult.Forbidden) |
| | 0 | 128 | | return new RoleDeletionOperationResult.Forbidden(); |
| | | 129 | | |
| | 3 | 130 | | var finalImpact = ((RoleDeletionInspectionResult.Success)finalInspection).Impact; |
| | 3 | 131 | | if (!finalImpact.CanDelete) |
| | 2 | 132 | | return new RoleDeletionOperationResult.Incomplete(finalImpact, changedOwnerIds.Distinct(StringComparer.Ordin |
| | | 133 | | |
| | 1 | 134 | | return await DeleteRoleAsync(command.RoleId, command.Actor, changedOwnerIds.Distinct(StringComparer.Ordinal).ToA |
| | 9 | 135 | | } |
| | | 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 | | { |
| | 6 | 156 | | var role = await roleStore.FindAsync(new() { Id = roleId }, cancellationToken); |
| | 6 | 157 | | if (role is null) |
| | 0 | 158 | | return new RoleDeletionOperationResult.NotFound(); |
| | | 159 | | |
| | 6 | 160 | | if (_atomicRoleStore is not null) |
| | | 161 | | { |
| | 5 | 162 | | if (!await _atomicRoleStore.TryDeleteAsync(roleId, cancellationToken)) |
| | 2 | 163 | | return new RoleDeletionOperationResult.NotFound(); |
| | | 164 | | } |
| | | 165 | | else |
| | | 166 | | { |
| | 1 | 167 | | 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. |
| | 4 | 172 | | await securityNotifier.RoleChangedAsync(actor, "deleted", role.Id, role.Name, role.Permissions.ToArray(), Cancel |
| | 4 | 173 | | return new RoleDeletionOperationResult.Deleted(changedOwnerIds); |
| | 6 | 174 | | } |
| | | 175 | | |
| | | 176 | | private async ValueTask<IReadOnlyCollection<RoleDeletionDependencySnapshot>> InspectContributorsAsync(string roleId, |
| | | 177 | | { |
| | 35 | 178 | | var snapshots = new List<RoleDeletionDependencySnapshot>(_contributors.Count); |
| | 210 | 179 | | foreach (var contributor in _contributors.OrderBy(x => x.Key, StringComparer.Ordinal).Select(x => x.Value)) |
| | | 180 | | { |
| | 35 | 181 | | var snapshot = await contributor.InspectAsync(roleId, cancellationToken); |
| | 35 | 182 | | if (!string.Equals(snapshot.Source, contributor.Source, StringComparison.Ordinal) || |
| | 69 | 183 | | snapshot.Dependencies.Any(x => !string.Equals(x.Source, contributor.Source, StringComparison.Ordinal))) |
| | 0 | 184 | | throw new InvalidOperationException($"Role-deletion contributor '{contributor.Source}' returned a mismat |
| | 35 | 185 | | snapshots.Add(snapshot); |
| | 35 | 186 | | } |
| | | 187 | | |
| | 35 | 188 | | return snapshots; |
| | 35 | 189 | | } |
| | | 190 | | |
| | | 191 | | private static RoleDeletionImpact CreateImpact(string roleId, IReadOnlyCollection<RoleDeletionDependencySnapshot> sn |
| | | 192 | | { |
| | 35 | 193 | | var dependencies = snapshots |
| | 35 | 194 | | .SelectMany(x => x.Dependencies) |
| | 16 | 195 | | .OrderBy(x => x.Source, StringComparer.Ordinal) |
| | 16 | 196 | | .ThenBy(x => x.Ownership) |
| | 16 | 197 | | .ThenBy(x => x.OwnerId, StringComparer.Ordinal) |
| | 16 | 198 | | .ThenBy(x => x.PolicyBranch, StringComparer.Ordinal) |
| | 35 | 199 | | .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. |
| | 98 | 202 | | var hasEditableDependencies = snapshots.Any(x => x.Dependencies.Any(dependency => dependency.Ownership == RoleDe |
| | 35 | 203 | | var executionMode = hasEditableDependencies ? RoleDeletionExecutionMode.BestEffort : RoleDeletionExecutionMode.A |
| | 35 | 204 | | return new RoleDeletionImpact( |
| | 35 | 205 | | roleId, |
| | 35 | 206 | | CalculateDependencyVersion(snapshots), |
| | 35 | 207 | | executionMode, |
| | 35 | 208 | | dependencies.Length == 0, |
| | 32 | 209 | | dependencies.Length != 0 && dependencies.All(x => x.Ownership == RoleDeletionDependencyOwnership.Database), |
| | 35 | 210 | | dependencies); |
| | | 211 | | } |
| | | 212 | | |
| | | 213 | | private static string CalculateDependencyVersion(IEnumerable<RoleDeletionDependencySnapshot> snapshots) |
| | | 214 | | { |
| | 35 | 215 | | var payload = string.Join( |
| | 35 | 216 | | "\n", |
| | 35 | 217 | | snapshots |
| | 35 | 218 | | .OrderBy(x => x.Source, StringComparer.Ordinal) |
| | 70 | 219 | | .SelectMany(snapshot => new[] { $"{snapshot.Source}|{snapshot.Version}|{snapshot.SupportsAtomicRemoval}" |
| | 70 | 220 | | .Concat(snapshot.Dependencies |
| | 34 | 221 | | .OrderBy(x => x.Ownership) |
| | 34 | 222 | | .ThenBy(x => x.OwnerId, StringComparer.Ordinal) |
| | 34 | 223 | | .ThenBy(x => x.PolicyBranch, StringComparer.Ordinal) |
| | 104 | 224 | | .Select(x => $"{x.Source}|{x.OwnerId}|{x.OwnerKey}|{x.PolicyBranch}|{x.Ownership}|{x.Configurati |
| | 35 | 225 | | 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 | | { |
| | 5 | 233 | | var warnings = new List<string>(); |
| | 5 | 234 | | var selective = command.SelectedReferences is not null; |
| | 5 | 235 | | var remediationRequested = !selective || selectedDependencies.Count != 0; |
| | 5 | 236 | | if (remediationRequested && !command.ConfirmRemoveFromEditablePolicies) |
| | 1 | 237 | | warnings.Add("confirm_remove_from_editable_jit_policies"); |
| | 5 | 238 | | var removesLastDefaultRole = selective |
| | 1 | 239 | | ? selectedDependencies.Any(x => x.RemovesLastDefaultRole) |
| | 9 | 240 | | : impact.Dependencies.Any(x => x.RemovesLastDefaultRole); |
| | 5 | 241 | | if (!selective && removesLastDefaultRole && !command.ConfirmEmptyDefaultRoles) |
| | 1 | 242 | | warnings.Add("removes_last_default_role"); |
| | 5 | 243 | | if (remediationRequested && impact.ExecutionMode == RoleDeletionExecutionMode.BestEffort && !command.ConfirmBest |
| | 1 | 244 | | warnings.Add("confirm_best_effort"); |
| | 5 | 245 | | return warnings; |
| | | 246 | | } |
| | | 247 | | |
| | | 248 | | private static IReadOnlyCollection<RoleDeletionDependency> SelectEditableDependencies( |
| | | 249 | | RoleDeletionImpact impact, |
| | | 250 | | IReadOnlyCollection<RoleDeletionReferenceSelection>? selectedReferences) => |
| | 6 | 251 | | impact.Dependencies |
| | 8 | 252 | | .Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database && IsSelected(x, selectedReferences)) |
| | 6 | 253 | | .ToArray(); |
| | | 254 | | |
| | | 255 | | private async ValueTask<RoleDeletionOperationResult?> ValidateReplacementRoleAsync( |
| | | 256 | | RoleDeletionImpact impact, |
| | | 257 | | RoleDeletionRemediationCommand command, |
| | | 258 | | IReadOnlyCollection<RoleDeletionDependency> selectedDependencies, |
| | | 259 | | CancellationToken cancellationToken) |
| | | 260 | | { |
| | 8 | 261 | | if (command.SelectedReferences is null || !selectedDependencies.Any(x => x.RemovesLastDefaultRole)) |
| | 5 | 262 | | return null; |
| | | 263 | | |
| | 1 | 264 | | if (string.IsNullOrWhiteSpace(command.ReplacementRoleId)) |
| | 0 | 265 | | return new RoleDeletionOperationResult.ValidationFailed(impact, "replacement_role_required"); |
| | 1 | 266 | | if (string.Equals(command.ReplacementRoleId, command.RoleId, StringComparison.Ordinal)) |
| | 0 | 267 | | return new RoleDeletionOperationResult.ValidationFailed(impact, "replacement_role_must_differ"); |
| | | 268 | | |
| | 1 | 269 | | var replacement = await roleStore.FindAsync(new() { Id = command.ReplacementRoleId }, cancellationToken); |
| | 1 | 270 | | if (replacement is null) |
| | 1 | 271 | | return new RoleDeletionOperationResult.ValidationFailed(impact, "replacement_role_not_found"); |
| | 0 | 272 | | if (!await roleAuthorizationService.CanAssignRolesAsync(command.Actor, [replacement.Id], cancellationToken)) |
| | 0 | 273 | | return new RoleDeletionOperationResult.Forbidden(); |
| | | 274 | | |
| | 0 | 275 | | return null; |
| | 6 | 276 | | } |
| | | 277 | | |
| | | 278 | | private static bool IsSelected(RoleDeletionDependency dependency, IReadOnlyCollection<RoleDeletionReferenceSelection |
| | 17 | 279 | | selectedReferences is null || selectedReferences.Any(x => |
| | 23 | 280 | | string.Equals(x.Source, dependency.Source, StringComparison.Ordinal) && |
| | 23 | 281 | | string.Equals(x.OwnerId, dependency.OwnerId, StringComparison.Ordinal)); |
| | | 282 | | |
| | | 283 | | private static string? ValidateSelectedReferences( |
| | | 284 | | RoleDeletionImpact impact, |
| | | 285 | | IReadOnlyCollection<RoleDeletionReferenceSelection>? selectedReferences) |
| | | 286 | | { |
| | 8 | 287 | | if (selectedReferences is null) |
| | 3 | 288 | | return null; |
| | | 289 | | |
| | 5 | 290 | | var seen = new HashSet<string>(StringComparer.Ordinal); |
| | 18 | 291 | | foreach (var selection in selectedReferences) |
| | | 292 | | { |
| | 5 | 293 | | if (selection is null || string.IsNullOrWhiteSpace(selection.Source) || string.IsNullOrWhiteSpace(selection. |
| | 0 | 294 | | return "invalid_reference_selection"; |
| | | 295 | | |
| | 5 | 296 | | var key = $"{selection.Source}\n{selection.OwnerId}"; |
| | 5 | 297 | | if (!seen.Add(key)) |
| | 1 | 298 | | return "duplicate_reference"; |
| | | 299 | | |
| | 4 | 300 | | var matches = impact.Dependencies |
| | 5 | 301 | | .Where(x => string.Equals(x.Source, selection.Source, StringComparison.Ordinal) && |
| | 5 | 302 | | string.Equals(x.OwnerId, selection.OwnerId, StringComparison.Ordinal)) |
| | 4 | 303 | | .ToArray(); |
| | 4 | 304 | | if (matches.Length == 0) |
| | 1 | 305 | | return "unknown_reference"; |
| | 6 | 306 | | if (matches.Any(x => x.Ownership != RoleDeletionDependencyOwnership.Database)) |
| | 0 | 307 | | return "configuration_reference_not_editable"; |
| | | 308 | | } |
| | | 309 | | |
| | 3 | 310 | | return null; |
| | 2 | 311 | | } |
| | | 312 | | |
| | | 313 | | private async ValueTask<RoleDeletionImpact> GetCurrentImpactAsync(string roleId, CancellationToken cancellationToken |
| | 1 | 314 | | CreateImpact(roleId, await InspectContributorsAsync(roleId, cancellationToken)); |
| | | 315 | | |
| | | 316 | | /// <summary>The permission this mid-handler check enforces, matching what the delete endpoints declare.</summary> |
| | 1 | 317 | | 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) => |
| | 32 | 325 | | PermissionEvaluator.Shared.HasPermission(actor, DeleteRoles); |
| | | 326 | | } |