| | | 1 | | using System.Security.Claims; |
| | | 2 | | using System.Security.Cryptography; |
| | | 3 | | using System.Text; |
| | | 4 | | using System.Text.Json; |
| | | 5 | | using System.Text.Json.Nodes; |
| | | 6 | | using Elsa.ExternalAuthentication.Contracts; |
| | | 7 | | using Elsa.ExternalAuthentication.Models; |
| | | 8 | | using Elsa.ExternalAuthentication.Notifications; |
| | | 9 | | using Elsa.ExternalAuthentication.Options; |
| | | 10 | | using Elsa.ExternalAuthentication.Permissions; |
| | | 11 | | using Elsa.ExternalAuthentication.Policies; |
| | | 12 | | using Elsa.Identity.Contracts; |
| | | 13 | | using Elsa.Identity.Models; |
| | | 14 | | using Microsoft.Extensions.Options; |
| | | 15 | | |
| | | 16 | | namespace Elsa.ExternalAuthentication.Services; |
| | | 17 | | |
| | | 18 | | /// <summary>Guards Elsa Role deletion against JIT-policy default-role references.</summary> |
| | 7 | 19 | | public sealed class ExternalAuthenticationRoleDeletionDependencyContributor( |
| | 7 | 20 | | IIdentityProviderConnectionStore store, |
| | 7 | 21 | | IOptionsMonitor<ExternalAuthenticationOptions> options, |
| | 7 | 22 | | Elsa.Identity.Contracts.IRoleAuthorizationService roleAuthorizationService, |
| | 7 | 23 | | IConnectionRegistryVersionStore registryVersions, |
| | 7 | 24 | | ConnectionRevisionCalculator revisionCalculator, |
| | 7 | 25 | | ExternalAuthenticationSecurityNotifier notifier) : IRoleDeletionDependencyContributor |
| | | 26 | | { |
| | | 27 | | public const string SourceName = "external-authentication"; |
| | 29 | 28 | | public string Source => SourceName; |
| | | 29 | | |
| | | 30 | | public async ValueTask<RoleDeletionDependencySnapshot> InspectAsync(string roleId, CancellationToken cancellationTok |
| | | 31 | | { |
| | 11 | 32 | | var dependencies = new List<RoleDeletionDependency>(); |
| | 11 | 33 | | var configuredConnections = options.CurrentValue.ConfigurationConnections ?? []; |
| | 11 | 34 | | var configurationIndex = 0; |
| | 28 | 35 | | foreach (var connection in configuredConnections) |
| | | 36 | | { |
| | 3 | 37 | | dependencies.AddRange(GetConfigurationDependencies(connection, configurationIndex, roleId)); |
| | 3 | 38 | | configurationIndex++; |
| | | 39 | | } |
| | | 40 | | |
| | 11 | 41 | | var databaseConnections = await store.FindAsync(new ConnectionFilter(), cancellationToken); |
| | 44 | 42 | | foreach (var connection in databaseConnections.Items) |
| | | 43 | | { |
| | 11 | 44 | | if (!TryGetRoleReference(connection.UnlinkedPolicy, roleId, out var policyBranch, out _, out var removesLast |
| | | 45 | | continue; |
| | 11 | 46 | | dependencies.Add(new RoleDeletionDependency( |
| | 11 | 47 | | Source, |
| | 11 | 48 | | connection.Id, |
| | 11 | 49 | | connection.Key, |
| | 11 | 50 | | policyBranch, |
| | 11 | 51 | | RoleDeletionDependencyOwnership.Database, |
| | 11 | 52 | | null, |
| | 11 | 53 | | connection.Revision, |
| | 11 | 54 | | removesLastDefaultRole)); |
| | | 55 | | } |
| | | 56 | | |
| | 11 | 57 | | var ordered = dependencies |
| | 6 | 58 | | .OrderBy(x => x.Ownership) |
| | 6 | 59 | | .ThenBy(x => x.OwnerId, StringComparer.Ordinal) |
| | 6 | 60 | | .ThenBy(x => x.ConfigurationPath, StringComparer.Ordinal) |
| | 11 | 61 | | .ToArray(); |
| | 11 | 62 | | return new RoleDeletionDependencySnapshot(Source, CalculateVersion(ordered), false, ordered); |
| | 11 | 63 | | } |
| | | 64 | | |
| | | 65 | | public async ValueTask<RoleReferenceRemovalValidationResult> ValidateRemovalAsync(RoleReferenceRemovalRequest reques |
| | | 66 | | { |
| | 7 | 67 | | if (!HasPermission(request.Actor, ExternalAuthenticationPermissions.ConnectionsUpdate) || |
| | 7 | 68 | | !HasPermission(request.Actor, ExternalAuthenticationPermissions.PoliciesManage) || |
| | 7 | 69 | | !HasPermission(request.Actor, ExternalAuthenticationPermissions.RolesAssign)) |
| | 3 | 70 | | return new RoleReferenceRemovalValidationResult.Forbidden("missing_policy_permissions"); |
| | 4 | 71 | | if (request.Dependencies.Count == 0 || |
| | 8 | 72 | | request.Dependencies.Any(x => x.Ownership != RoleDeletionDependencyOwnership.Database || !string.Equals(x.So |
| | 0 | 73 | | return new RoleReferenceRemovalValidationResult.Conflict("invalid_dependency_set"); |
| | | 74 | | |
| | 4 | 75 | | var current = await InspectAsync(request.RoleId, cancellationToken); |
| | 4 | 76 | | if (!string.Equals(current.Version, request.ExpectedContributorVersion, StringComparison.Ordinal) || |
| | 7 | 77 | | current.Dependencies.Any(x => x.Ownership == RoleDeletionDependencyOwnership.Configuration)) |
| | 2 | 78 | | return new RoleReferenceRemovalValidationResult.Conflict("dependency_changed"); |
| | | 79 | | |
| | 4 | 80 | | var expectedOwners = request.Dependencies.Select(x => x.OwnerId).ToHashSet(StringComparer.Ordinal); |
| | 2 | 81 | | var currentOwners = current.Dependencies |
| | 2 | 82 | | .Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database) |
| | 2 | 83 | | .Select(x => x.OwnerId) |
| | 2 | 84 | | .ToHashSet(StringComparer.Ordinal); |
| | 2 | 85 | | if (!expectedOwners.SetEquals(currentOwners)) |
| | 0 | 86 | | return new RoleReferenceRemovalValidationResult.Conflict("dependency_changed"); |
| | | 87 | | |
| | 8 | 88 | | foreach (var dependency in request.Dependencies) |
| | | 89 | | { |
| | 2 | 90 | | var connection = await store.FindByIdAsync(dependency.OwnerId, cancellationToken); |
| | 2 | 91 | | if (connection is null || connection.Revision != dependency.ExpectedRevision || |
| | 2 | 92 | | !TryGetRoleReference(connection.UnlinkedPolicy, request.RoleId, out _, out var roleIds, out _)) |
| | 0 | 93 | | return new RoleReferenceRemovalValidationResult.Conflict("connection_revision_changed"); |
| | 4 | 94 | | var remainingRoleIds = roleIds.Where(x => !string.Equals(x, request.RoleId, StringComparison.Ordinal)).ToArr |
| | 2 | 95 | | if (!await roleAuthorizationService.CanAssignRolesAsync(request.Actor, remainingRoleIds, cancellationToken)) |
| | 0 | 96 | | return new RoleReferenceRemovalValidationResult.Forbidden("role_assignment_denied"); |
| | 2 | 97 | | } |
| | | 98 | | |
| | 2 | 99 | | return new RoleReferenceRemovalValidationResult.Valid(); |
| | 7 | 100 | | } |
| | | 101 | | |
| | | 102 | | public async ValueTask<RoleReferenceRemovalResult> RemoveEditableReferencesAsync(RoleReferenceRemovalRequest request |
| | | 103 | | { |
| | 1 | 104 | | var validation = await ValidateRemovalAsync(request, cancellationToken); |
| | 1 | 105 | | if (validation is RoleReferenceRemovalValidationResult.Forbidden forbidden) |
| | 0 | 106 | | return new RoleReferenceRemovalResult.Failed(forbidden.Code, []); |
| | 1 | 107 | | if (validation is RoleReferenceRemovalValidationResult.Conflict conflict) |
| | 0 | 108 | | return new RoleReferenceRemovalResult.Conflict(conflict.Code, []); |
| | | 109 | | |
| | 1 | 110 | | var changedOwnerIds = new List<string>(); |
| | | 111 | | try |
| | | 112 | | { |
| | 5 | 113 | | foreach (var dependency in request.Dependencies.OrderBy(x => x.OwnerId, StringComparer.Ordinal)) |
| | | 114 | | { |
| | 1 | 115 | | var connection = await store.FindByIdAsync(dependency.OwnerId, cancellationToken); |
| | 1 | 116 | | if (connection is null || connection.Revision != dependency.ExpectedRevision || |
| | 1 | 117 | | !TryGetRoleReference(connection.UnlinkedPolicy, request.RoleId, out _, out _, out _)) |
| | 0 | 118 | | return new RoleReferenceRemovalResult.Conflict("connection_revision_changed", changedOwnerIds); |
| | | 119 | | |
| | 1 | 120 | | var candidate = IdentityProviderConnectionCloner.Clone(connection); |
| | 1 | 121 | | candidate.UnlinkedPolicy = candidate.UnlinkedPolicy is { } policy |
| | 1 | 122 | | ? policy with { Settings = RemoveRole(policy.Settings, request.RoleId) } |
| | 1 | 123 | | : null; |
| | 1 | 124 | | candidate.UpdatedAt = DateTimeOffset.UtcNow; |
| | 1 | 125 | | candidate.MaterialRevision = revisionCalculator.CalculateMaterialRevision(candidate); |
| | | 126 | | |
| | 1 | 127 | | var update = await store.UpdateAsync(candidate, connection.Revision, cancellationToken); |
| | 1 | 128 | | if (update is not ConnectionMutationResult.Updated updated) |
| | 0 | 129 | | return new RoleReferenceRemovalResult.Conflict("connection_revision_changed", changedOwnerIds); |
| | | 130 | | |
| | 1 | 131 | | changedOwnerIds.Add(updated.Connection.Id); |
| | 1 | 132 | | await registryVersions.AdvanceAsync(cancellationToken); |
| | 1 | 133 | | await notifier.PublishAsync( |
| | 1 | 134 | | new IdentityProviderConnectionChanged( |
| | 1 | 135 | | ExternalAuthenticationSecurityNotifier.Context( |
| | 1 | 136 | | request.Actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? request.Actor.FindFirstValue("sub |
| | 1 | 137 | | updated.Connection.TenantId, |
| | 1 | 138 | | updated.Connection.Id, |
| | 1 | 139 | | null, |
| | 1 | 140 | | SecurityEventOutcome.Succeeded, |
| | 1 | 141 | | "An Elsa Role reference was removed from an external authentication JIT policy."), |
| | 1 | 142 | | "default-role-removed", |
| | 1 | 143 | | updated.Connection.Revision, |
| | 1 | 144 | | updated.Connection.MaterialRevision), |
| | 1 | 145 | | cancellationToken); |
| | 1 | 146 | | } |
| | 1 | 147 | | } |
| | 0 | 148 | | catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) |
| | | 149 | | { |
| | 0 | 150 | | throw; |
| | | 151 | | } |
| | 0 | 152 | | catch |
| | | 153 | | { |
| | 0 | 154 | | return new RoleReferenceRemovalResult.Failed("storage_error", changedOwnerIds); |
| | | 155 | | } |
| | | 156 | | |
| | 1 | 157 | | return new RoleReferenceRemovalResult.Success(changedOwnerIds); |
| | 1 | 158 | | } |
| | | 159 | | |
| | | 160 | | private IEnumerable<RoleDeletionDependency> GetConfigurationDependencies(IdentityProviderConnection connection, int |
| | | 161 | | { |
| | 3 | 162 | | if (!TryGetRoleReference(connection.UnlinkedPolicy, roleId, out var policyBranch, out var roleIds, out var remov |
| | 0 | 163 | | yield break; |
| | | 164 | | |
| | 3 | 165 | | var roleIndex = 0; |
| | 14 | 166 | | foreach (var configuredRoleId in ReadRoleIdsWithDuplicates(connection.UnlinkedPolicy!.Settings)) |
| | | 167 | | { |
| | 4 | 168 | | if (string.Equals(configuredRoleId, roleId, StringComparison.Ordinal)) |
| | | 169 | | { |
| | 3 | 170 | | yield return new RoleDeletionDependency( |
| | 3 | 171 | | Source, |
| | 3 | 172 | | string.IsNullOrWhiteSpace(connection.Id) ? $"configuration:{connectionIndex}" : connection.Id, |
| | 3 | 173 | | connection.Key, |
| | 3 | 174 | | policyBranch, |
| | 3 | 175 | | RoleDeletionDependencyOwnership.Configuration, |
| | 3 | 176 | | $"ExternalAuthentication:Connections:{connectionIndex}:UnlinkedPolicy:Settings:defaultRoleIds:{roleI |
| | 3 | 177 | | null, |
| | 3 | 178 | | removesLastDefaultRole); |
| | | 179 | | } |
| | | 180 | | |
| | 4 | 181 | | roleIndex++; |
| | | 182 | | } |
| | 3 | 183 | | } |
| | | 184 | | |
| | | 185 | | private static bool TryGetRoleReference(PolicySelection? policy, string roleId, out string policyBranch, out IReadOn |
| | | 186 | | { |
| | 17 | 187 | | policyBranch = string.Empty; |
| | 17 | 188 | | roleIds = []; |
| | 17 | 189 | | removesLastDefaultRole = false; |
| | 17 | 190 | | if (policy is null) |
| | 0 | 191 | | return false; |
| | | 192 | | |
| | 17 | 193 | | if (string.Equals(policy.Type, CreateUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal)) |
| | 16 | 194 | | policyBranch = "create-user"; |
| | 1 | 195 | | else if (string.Equals(policy.Type, MatchExternalUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal |
| | 1 | 196 | | string.Equals(ReadString(policy.Settings, "noMatchAction"), "create-user", StringComparison.OrdinalIgno |
| | 1 | 197 | | policyBranch = "matcher-no-match-create-user"; |
| | | 198 | | else |
| | 0 | 199 | | return false; |
| | | 200 | | |
| | 17 | 201 | | roleIds = CreateUserUnlinkedIdentityPolicy.ReadRoleIds(policy.Settings); |
| | 17 | 202 | | if (!roleIds.Contains(roleId, StringComparer.Ordinal)) |
| | 0 | 203 | | return false; |
| | 17 | 204 | | removesLastDefaultRole = roleIds.Count == 1; |
| | 17 | 205 | | return true; |
| | | 206 | | } |
| | | 207 | | |
| | | 208 | | private static JsonElement RemoveRole(JsonElement settings, string roleId) |
| | | 209 | | { |
| | 1 | 210 | | var root = settings.ValueKind == JsonValueKind.Object |
| | 1 | 211 | | ? JsonNode.Parse(settings.GetRawText()) as JsonObject |
| | 1 | 212 | | : new JsonObject(); |
| | 1 | 213 | | root ??= new JsonObject(); |
| | 1 | 214 | | var remainingRoleIds = ReadRoleIdsWithDuplicates(settings) |
| | 1 | 215 | | .Where(x => !string.Equals(x, roleId, StringComparison.Ordinal)) |
| | 1 | 216 | | .Distinct(StringComparer.Ordinal) |
| | 1 | 217 | | .ToArray(); |
| | 1 | 218 | | var roleNodes = new JsonNode?[remainingRoleIds.Length]; |
| | 2 | 219 | | for (var index = 0; index < remainingRoleIds.Length; index++) |
| | 0 | 220 | | roleNodes[index] = JsonValue.Create(remainingRoleIds[index]); |
| | 1 | 221 | | root["defaultRoleIds"] = new JsonArray(roleNodes); |
| | 1 | 222 | | return JsonSerializer.SerializeToElement(root); |
| | | 223 | | } |
| | | 224 | | |
| | | 225 | | private static IReadOnlyCollection<string> ReadRoleIdsWithDuplicates(JsonElement settings) => |
| | 4 | 226 | | settings.ValueKind == JsonValueKind.Object && |
| | 4 | 227 | | settings.TryGetProperty("defaultRoleIds", out var values) && |
| | 4 | 228 | | values.ValueKind == JsonValueKind.Array |
| | 4 | 229 | | ? values.EnumerateArray() |
| | 5 | 230 | | .Where(x => x.ValueKind == JsonValueKind.String) |
| | 5 | 231 | | .Select(x => x.GetString()) |
| | 5 | 232 | | .Where(x => !string.IsNullOrWhiteSpace(x)) |
| | 4 | 233 | | .Cast<string>() |
| | 4 | 234 | | .ToArray() |
| | 4 | 235 | | : []; |
| | | 236 | | |
| | | 237 | | private static string? ReadString(JsonElement settings, string propertyName) => |
| | 1 | 238 | | settings.ValueKind == JsonValueKind.Object && |
| | 1 | 239 | | settings.TryGetProperty(propertyName, out var value) && |
| | 1 | 240 | | value.ValueKind == JsonValueKind.String |
| | 1 | 241 | | ? value.GetString() |
| | 1 | 242 | | : null; |
| | | 243 | | |
| | | 244 | | private static bool HasPermission(ClaimsPrincipal actor, string permission) => |
| | 70 | 245 | | actor.FindAll(PermissionNames.ClaimType).Any(x => x.Value == PermissionNames.All || string.Equals(x.Value, permi |
| | | 246 | | |
| | | 247 | | private static string CalculateVersion(IEnumerable<RoleDeletionDependency> dependencies) |
| | | 248 | | { |
| | 11 | 249 | | var payload = string.Join( |
| | 11 | 250 | | "\n", |
| | 25 | 251 | | dependencies.Select(x => $"{x.OwnerId}|{x.OwnerKey}|{x.PolicyBranch}|{x.Ownership}|{x.ConfigurationPath}|{x. |
| | 11 | 252 | | return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant(); |
| | | 253 | | } |
| | | 254 | | } |