< Summary

Information
Class: Elsa.ExternalAuthentication.Services.ExternalAuthenticationRoleDeletionDependencyContributor
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationRoleDeletionDependencyContributor.cs
Line coverage
90%
Covered lines: 153
Uncovered lines: 17
Coverable lines: 170
Total lines: 254
Line coverage: 90%
Branch coverage
66%
Covered branches: 64
Total branches: 96
Branch coverage: 66.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Source()100%11100%
InspectAsync()87.5%88100%
ValidateRemovalAsync()76.66%353082.6%
RemoveEditableReferencesAsync()68.18%252280.95%
GetConfigurationDependencies()75%8893.75%
TryGetRoleReference(...)60%111081.25%
RemoveRole(...)50%6692.3%
ReadRoleIdsWithDuplicates(...)50%66100%
ReadString(...)50%66100%
HasPermission(...)50%22100%
CalculateVersion(...)100%11100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationRoleDeletionDependencyContributor.cs

#LineLine coverage
 1using System.Security.Claims;
 2using System.Security.Cryptography;
 3using System.Text;
 4using System.Text.Json;
 5using System.Text.Json.Nodes;
 6using Elsa.ExternalAuthentication.Contracts;
 7using Elsa.ExternalAuthentication.Models;
 8using Elsa.ExternalAuthentication.Notifications;
 9using Elsa.ExternalAuthentication.Options;
 10using Elsa.ExternalAuthentication.Permissions;
 11using Elsa.ExternalAuthentication.Policies;
 12using Elsa.Identity.Contracts;
 13using Elsa.Identity.Models;
 14using Microsoft.Extensions.Options;
 15
 16namespace Elsa.ExternalAuthentication.Services;
 17
 18/// <summary>Guards Elsa Role deletion against JIT-policy default-role references.</summary>
 719public sealed class ExternalAuthenticationRoleDeletionDependencyContributor(
 720    IIdentityProviderConnectionStore store,
 721    IOptionsMonitor<ExternalAuthenticationOptions> options,
 722    Elsa.Identity.Contracts.IRoleAuthorizationService roleAuthorizationService,
 723    IConnectionRegistryVersionStore registryVersions,
 724    ConnectionRevisionCalculator revisionCalculator,
 725    ExternalAuthenticationSecurityNotifier notifier) : IRoleDeletionDependencyContributor
 26{
 27    public const string SourceName = "external-authentication";
 2928    public string Source => SourceName;
 29
 30    public async ValueTask<RoleDeletionDependencySnapshot> InspectAsync(string roleId, CancellationToken cancellationTok
 31    {
 1132        var dependencies = new List<RoleDeletionDependency>();
 1133        var configuredConnections = options.CurrentValue.ConfigurationConnections ?? [];
 1134        var configurationIndex = 0;
 2835        foreach (var connection in configuredConnections)
 36        {
 337            dependencies.AddRange(GetConfigurationDependencies(connection, configurationIndex, roleId));
 338            configurationIndex++;
 39        }
 40
 1141        var databaseConnections = await store.FindAsync(new ConnectionFilter(), cancellationToken);
 4442        foreach (var connection in databaseConnections.Items)
 43        {
 1144            if (!TryGetRoleReference(connection.UnlinkedPolicy, roleId, out var policyBranch, out _, out var removesLast
 45                continue;
 1146            dependencies.Add(new RoleDeletionDependency(
 1147                Source,
 1148                connection.Id,
 1149                connection.Key,
 1150                policyBranch,
 1151                RoleDeletionDependencyOwnership.Database,
 1152                null,
 1153                connection.Revision,
 1154                removesLastDefaultRole));
 55        }
 56
 1157        var ordered = dependencies
 658            .OrderBy(x => x.Ownership)
 659            .ThenBy(x => x.OwnerId, StringComparer.Ordinal)
 660            .ThenBy(x => x.ConfigurationPath, StringComparer.Ordinal)
 1161            .ToArray();
 1162        return new RoleDeletionDependencySnapshot(Source, CalculateVersion(ordered), false, ordered);
 1163    }
 64
 65    public async ValueTask<RoleReferenceRemovalValidationResult> ValidateRemovalAsync(RoleReferenceRemovalRequest reques
 66    {
 767        if (!HasPermission(request.Actor, ExternalAuthenticationPermissions.ConnectionsUpdate) ||
 768            !HasPermission(request.Actor, ExternalAuthenticationPermissions.PoliciesManage) ||
 769            !HasPermission(request.Actor, ExternalAuthenticationPermissions.RolesAssign))
 370            return new RoleReferenceRemovalValidationResult.Forbidden("missing_policy_permissions");
 471        if (request.Dependencies.Count == 0 ||
 872            request.Dependencies.Any(x => x.Ownership != RoleDeletionDependencyOwnership.Database || !string.Equals(x.So
 073            return new RoleReferenceRemovalValidationResult.Conflict("invalid_dependency_set");
 74
 475        var current = await InspectAsync(request.RoleId, cancellationToken);
 476        if (!string.Equals(current.Version, request.ExpectedContributorVersion, StringComparison.Ordinal) ||
 777            current.Dependencies.Any(x => x.Ownership == RoleDeletionDependencyOwnership.Configuration))
 278            return new RoleReferenceRemovalValidationResult.Conflict("dependency_changed");
 79
 480        var expectedOwners = request.Dependencies.Select(x => x.OwnerId).ToHashSet(StringComparer.Ordinal);
 281        var currentOwners = current.Dependencies
 282            .Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database)
 283            .Select(x => x.OwnerId)
 284            .ToHashSet(StringComparer.Ordinal);
 285        if (!expectedOwners.SetEquals(currentOwners))
 086            return new RoleReferenceRemovalValidationResult.Conflict("dependency_changed");
 87
 888        foreach (var dependency in request.Dependencies)
 89        {
 290            var connection = await store.FindByIdAsync(dependency.OwnerId, cancellationToken);
 291            if (connection is null || connection.Revision != dependency.ExpectedRevision ||
 292                !TryGetRoleReference(connection.UnlinkedPolicy, request.RoleId, out _, out var roleIds, out _))
 093                return new RoleReferenceRemovalValidationResult.Conflict("connection_revision_changed");
 494            var remainingRoleIds = roleIds.Where(x => !string.Equals(x, request.RoleId, StringComparison.Ordinal)).ToArr
 295            if (!await roleAuthorizationService.CanAssignRolesAsync(request.Actor, remainingRoleIds, cancellationToken))
 096                return new RoleReferenceRemovalValidationResult.Forbidden("role_assignment_denied");
 297        }
 98
 299        return new RoleReferenceRemovalValidationResult.Valid();
 7100    }
 101
 102    public async ValueTask<RoleReferenceRemovalResult> RemoveEditableReferencesAsync(RoleReferenceRemovalRequest request
 103    {
 1104        var validation = await ValidateRemovalAsync(request, cancellationToken);
 1105        if (validation is RoleReferenceRemovalValidationResult.Forbidden forbidden)
 0106            return new RoleReferenceRemovalResult.Failed(forbidden.Code, []);
 1107        if (validation is RoleReferenceRemovalValidationResult.Conflict conflict)
 0108            return new RoleReferenceRemovalResult.Conflict(conflict.Code, []);
 109
 1110        var changedOwnerIds = new List<string>();
 111        try
 112        {
 5113            foreach (var dependency in request.Dependencies.OrderBy(x => x.OwnerId, StringComparer.Ordinal))
 114            {
 1115                var connection = await store.FindByIdAsync(dependency.OwnerId, cancellationToken);
 1116                if (connection is null || connection.Revision != dependency.ExpectedRevision ||
 1117                    !TryGetRoleReference(connection.UnlinkedPolicy, request.RoleId, out _, out _, out _))
 0118                    return new RoleReferenceRemovalResult.Conflict("connection_revision_changed", changedOwnerIds);
 119
 1120                var candidate = IdentityProviderConnectionCloner.Clone(connection);
 1121                candidate.UnlinkedPolicy = candidate.UnlinkedPolicy is { } policy
 1122                    ? policy with { Settings = RemoveRole(policy.Settings, request.RoleId) }
 1123                    : null;
 1124                candidate.UpdatedAt = DateTimeOffset.UtcNow;
 1125                candidate.MaterialRevision = revisionCalculator.CalculateMaterialRevision(candidate);
 126
 1127                var update = await store.UpdateAsync(candidate, connection.Revision, cancellationToken);
 1128                if (update is not ConnectionMutationResult.Updated updated)
 0129                    return new RoleReferenceRemovalResult.Conflict("connection_revision_changed", changedOwnerIds);
 130
 1131                changedOwnerIds.Add(updated.Connection.Id);
 1132                await registryVersions.AdvanceAsync(cancellationToken);
 1133                await notifier.PublishAsync(
 1134                    new IdentityProviderConnectionChanged(
 1135                        ExternalAuthenticationSecurityNotifier.Context(
 1136                            request.Actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? request.Actor.FindFirstValue("sub
 1137                            updated.Connection.TenantId,
 1138                            updated.Connection.Id,
 1139                            null,
 1140                            SecurityEventOutcome.Succeeded,
 1141                            "An Elsa Role reference was removed from an external authentication JIT policy."),
 1142                        "default-role-removed",
 1143                        updated.Connection.Revision,
 1144                        updated.Connection.MaterialRevision),
 1145                    cancellationToken);
 1146            }
 1147        }
 0148        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 149        {
 0150            throw;
 151        }
 0152        catch
 153        {
 0154            return new RoleReferenceRemovalResult.Failed("storage_error", changedOwnerIds);
 155        }
 156
 1157        return new RoleReferenceRemovalResult.Success(changedOwnerIds);
 1158    }
 159
 160    private IEnumerable<RoleDeletionDependency> GetConfigurationDependencies(IdentityProviderConnection connection, int 
 161    {
 3162        if (!TryGetRoleReference(connection.UnlinkedPolicy, roleId, out var policyBranch, out var roleIds, out var remov
 0163            yield break;
 164
 3165        var roleIndex = 0;
 14166        foreach (var configuredRoleId in ReadRoleIdsWithDuplicates(connection.UnlinkedPolicy!.Settings))
 167        {
 4168            if (string.Equals(configuredRoleId, roleId, StringComparison.Ordinal))
 169            {
 3170                yield return new RoleDeletionDependency(
 3171                    Source,
 3172                    string.IsNullOrWhiteSpace(connection.Id) ? $"configuration:{connectionIndex}" : connection.Id,
 3173                    connection.Key,
 3174                    policyBranch,
 3175                    RoleDeletionDependencyOwnership.Configuration,
 3176                    $"ExternalAuthentication:Connections:{connectionIndex}:UnlinkedPolicy:Settings:defaultRoleIds:{roleI
 3177                    null,
 3178                    removesLastDefaultRole);
 179            }
 180
 4181            roleIndex++;
 182        }
 3183    }
 184
 185    private static bool TryGetRoleReference(PolicySelection? policy, string roleId, out string policyBranch, out IReadOn
 186    {
 17187        policyBranch = string.Empty;
 17188        roleIds = [];
 17189        removesLastDefaultRole = false;
 17190        if (policy is null)
 0191            return false;
 192
 17193        if (string.Equals(policy.Type, CreateUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal))
 16194            policyBranch = "create-user";
 1195        else if (string.Equals(policy.Type, MatchExternalUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal
 1196                 string.Equals(ReadString(policy.Settings, "noMatchAction"), "create-user", StringComparison.OrdinalIgno
 1197            policyBranch = "matcher-no-match-create-user";
 198        else
 0199            return false;
 200
 17201        roleIds = CreateUserUnlinkedIdentityPolicy.ReadRoleIds(policy.Settings);
 17202        if (!roleIds.Contains(roleId, StringComparer.Ordinal))
 0203            return false;
 17204        removesLastDefaultRole = roleIds.Count == 1;
 17205        return true;
 206    }
 207
 208    private static JsonElement RemoveRole(JsonElement settings, string roleId)
 209    {
 1210        var root = settings.ValueKind == JsonValueKind.Object
 1211            ? JsonNode.Parse(settings.GetRawText()) as JsonObject
 1212            : new JsonObject();
 1213        root ??= new JsonObject();
 1214        var remainingRoleIds = ReadRoleIdsWithDuplicates(settings)
 1215            .Where(x => !string.Equals(x, roleId, StringComparison.Ordinal))
 1216            .Distinct(StringComparer.Ordinal)
 1217            .ToArray();
 1218        var roleNodes = new JsonNode?[remainingRoleIds.Length];
 2219        for (var index = 0; index < remainingRoleIds.Length; index++)
 0220            roleNodes[index] = JsonValue.Create(remainingRoleIds[index]);
 1221        root["defaultRoleIds"] = new JsonArray(roleNodes);
 1222        return JsonSerializer.SerializeToElement(root);
 223    }
 224
 225    private static IReadOnlyCollection<string> ReadRoleIdsWithDuplicates(JsonElement settings) =>
 4226        settings.ValueKind == JsonValueKind.Object &&
 4227        settings.TryGetProperty("defaultRoleIds", out var values) &&
 4228        values.ValueKind == JsonValueKind.Array
 4229            ? values.EnumerateArray()
 5230                .Where(x => x.ValueKind == JsonValueKind.String)
 5231                .Select(x => x.GetString())
 5232                .Where(x => !string.IsNullOrWhiteSpace(x))
 4233                .Cast<string>()
 4234                .ToArray()
 4235            : [];
 236
 237    private static string? ReadString(JsonElement settings, string propertyName) =>
 1238        settings.ValueKind == JsonValueKind.Object &&
 1239        settings.TryGetProperty(propertyName, out var value) &&
 1240        value.ValueKind == JsonValueKind.String
 1241            ? value.GetString()
 1242            : null;
 243
 244    private static bool HasPermission(ClaimsPrincipal actor, string permission) =>
 70245        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    {
 11249        var payload = string.Join(
 11250            "\n",
 25251            dependencies.Select(x => $"{x.OwnerId}|{x.OwnerKey}|{x.PolicyBranch}|{x.Ownership}|{x.ConfigurationPath}|{x.
 11252        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
 253    }
 254}