< 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
91%
Covered lines: 218
Uncovered lines: 19
Coverable lines: 237
Total lines: 439
Line coverage: 91.9%
Branch coverage
78%
Covered branches: 120
Total branches: 152
Branch coverage: 78.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

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.Authorization;
 7using Elsa.Common.Multitenancy;
 8using Elsa.ExternalAuthentication.Contracts;
 9using Elsa.ExternalAuthentication.Models;
 10using Elsa.ExternalAuthentication.Notifications;
 11using Elsa.ExternalAuthentication.Options;
 12using Elsa.ExternalAuthentication.Permissions;
 13using Elsa.ExternalAuthentication.Policies;
 14using Elsa.Identity.Contracts;
 15using Elsa.Identity.Entities;
 16using Elsa.Identity.Models;
 17using Microsoft.Extensions.Options;
 18
 19namespace Elsa.ExternalAuthentication.Services;
 20
 21/// <summary>Guards Elsa Role deletion against JIT-policy default-role references.</summary>
 22/// <remarks>
 23/// Roles are tenant-scoped, so impact and remediation only ever consider connections in the role's own tenant
 24/// context: the resolved role's own <c>TenantId</c>. The tenant active on <see cref="ITenantAccessor"/> is used
 25/// only as a fallback when the role cannot be resolved, because with multitenancy disabled (the default) the EF
 26/// Core role store installs no tenant query filter and can resolve a tenant-owned role by ID regardless of the
 27/// ambient tenant; trusting the ambient tenant instead of the resolved role's tenant would then let this
 28/// contributor scan the wrong tenant's connections while the coordinator deletes a role belonging to another
 29/// tenant. A connection carrying another tenant's ID is out of scope in both directions, for impact and for
 30/// remediation.
 31/// Host-scoped connections (<see cref="ConnectionScope.HostTenantId"/>, and configuration entries that leave the
 32/// tenant blank, which are materialized at host scope) stay in scope for every tenant. The connection registry
 33/// resolves the host scope for every signing-in tenant, and a connection's default role IDs are then resolved by
 34/// <c>ExternalIdentityUserProvisioningService</c> through <see cref="IRoleProvider"/> in the signing-in user's
 35/// tenant, so a host connection naming role ID X really does reference tenant A's role X.
 36/// A tenant-agnostic role (<see cref="Tenant.AgnosticTenantId"/>) is visible from every tenant, so its tenant
 37/// context is every tenant: impact and remediation for such a role scan every stored connection and every
 38/// configuration entry regardless of tenant, instead of the single active tenant plus host scope. Authorizing a
 39/// replacement role, however, is still performed through the ambient tenant's role services, so when the
 40/// deletion target is agnostic the replacement role must itself be agnostic; a tenant-scoped replacement is
 41/// rejected rather than being authorized in one tenant and written into every tenant's connections.
 42/// In EF Core persistence a role's primary key is its ID alone, so a role ID is unique across all tenants there
 43/// and an agnostic/tenant-scoped collision cannot exist. Only <c>MemoryRoleStore</c> can hold two roles that
 44/// share an ID (its storage key includes the tenant); resolving a role ID against it can then be genuinely
 45/// ambiguous. That ambiguity is never resolved by guessing: widening a tenant-scoped deletion would expose
 46/// another tenant's references, and narrowing an agnostic deletion would leave an agnostic role's references
 47/// dangling. It fails closed instead. The same collision makes a replacement candidate ambiguous too: a
 48/// replacement ID that resolves to more than one role (an ambient match and an agnostic match, under
 49/// <c>MemoryRoleStore</c>) is rejected as not agnostic rather than guessed at, so it is reported as
 50/// <c>replacement_role_unavailable_or_unauthorized</c> instead of surfacing as an exception.
 51/// </remarks>
 2452public sealed class ExternalAuthenticationRoleDeletionDependencyContributor(
 2453    IIdentityProviderConnectionStore store,
 2454    IOptionsMonitor<ExternalAuthenticationOptions> options,
 2455    IEnumerable<IRoleAuthorizationService> roleAuthorizationServices,
 2456    IEnumerable<IRoleStore> roleStores,
 2457    IConnectionRegistryVersionStore registryVersions,
 2458    ConnectionRevisionCalculator revisionCalculator,
 2459    ExternalAuthenticationSecurityNotifier notifier,
 2460    IPermissionEvaluator permissionEvaluator,
 2461    ITenantAccessor tenantAccessor) : IRoleDeletionDependencyContributor
 62{
 63    public const string SourceName = "external-authentication";
 16064    public string Source => SourceName;
 65
 66    /// <summary>
 67    /// Match the default DI container's direct-service semantics: when persistence replaces the in-memory
 68    /// store, the last registration is the active store.
 69    /// </summary>
 9370    private IRoleStore? ActiveRoleStore => roleStores.LastOrDefault();
 71
 72    public async ValueTask<RoleDeletionDependencySnapshot> InspectAsync(string roleId, CancellationToken cancellationTok
 73    {
 5274        var roleTenantId = await ResolveRoleTenantIdAsync(roleId, cancellationToken);
 4975        var dependencies = new List<RoleDeletionDependency>();
 4976        var configuredConnections = options.CurrentValue.ConfigurationConnections ?? [];
 4977        var configurationIndex = 0;
 10878        foreach (var connection in configuredConnections)
 79        {
 80            // The index is part of the configuration path an operator edits, so out-of-scope entries are
 81            // skipped without renumbering the entries that remain.
 582            if (IsInRoleTenantScope(GetConfigurationScopeTenantId(connection), roleTenantId))
 483                dependencies.AddRange(GetConfigurationDependencies(connection, configurationIndex, roleId));
 584            configurationIndex++;
 85        }
 86
 22287        foreach (var connection in await FindConnectionsInRoleTenantScopeAsync(roleTenantId, cancellationToken))
 88        {
 6289            if (!TryGetRoleReference(connection.UnlinkedPolicy, roleId, out var policyBranch, out _, out var removesLast
 90                continue;
 6291            dependencies.Add(new(
 6292                Source,
 6293                connection.Id,
 6294                connection.Key,
 6295                policyBranch,
 6296                RoleDeletionDependencyOwnership.Database,
 6297                null,
 6298                connection.Revision,
 6299                removesLastDefaultRole));
 100        }
 101
 49102        var ordered = dependencies
 34103            .OrderBy(x => x.Ownership)
 34104            .ThenBy(x => x.OwnerId, StringComparer.Ordinal)
 34105            .ThenBy(x => x.ConfigurationPath, StringComparer.Ordinal)
 49106            .ToArray();
 49107        return new(Source, CalculateVersion(ordered), false, ordered);
 49108    }
 109
 110    public async ValueTask<RoleReferenceRemovalValidationResult> ValidateRemovalAsync(RoleReferenceRemovalRequest reques
 111    {
 29112        var roleAuthorizationService = roleAuthorizationServices.SingleOrDefault();
 29113        if (roleAuthorizationService is null)
 0114            return new RoleReferenceRemovalValidationResult.Forbidden("role_authorization_unavailable");
 29115        if (!permissionEvaluator.HasPermission(request.Actor, ExternalAuthenticationResourcePermissions.Connections, Cor
 29116            !permissionEvaluator.HasPermission(request.Actor, ExternalAuthenticationResourcePermissions.Policies, CoreVe
 29117            !permissionEvaluator.HasPermission(request.Actor, ExternalAuthenticationResourcePermissions.PolicyDefaultRol
 3118            return new RoleReferenceRemovalValidationResult.Forbidden("missing_policy_permissions");
 26119        if (request.Dependencies.Count == 0 ||
 62120            request.Dependencies.Any(x => x.Ownership != RoleDeletionDependencyOwnership.Database || !string.Equals(x.So
 0121            return new RoleReferenceRemovalValidationResult.Conflict("invalid_dependency_set");
 122
 26123        var current = await InspectAsync(request.RoleId, cancellationToken);
 24124        if (!string.Equals(current.Version, request.ExpectedContributorVersion, StringComparison.Ordinal) ||
 55125            current.Dependencies.Any(x => x.Ownership == RoleDeletionDependencyOwnership.Configuration))
 2126            return new RoleReferenceRemovalValidationResult.Conflict("dependency_changed");
 127
 54128        var expectedOwners = request.Dependencies.Select(x => x.OwnerId).ToHashSet(StringComparer.Ordinal);
 22129        var currentOwners = current.Dependencies
 30130            .Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database)
 30131            .Select(x => x.OwnerId)
 22132            .ToHashSet(StringComparer.Ordinal);
 22133        if (!expectedOwners.IsSubsetOf(currentOwners))
 2134            return new RoleReferenceRemovalValidationResult.Conflict("dependency_changed");
 135
 20136        var roleTenantId = await ResolveRoleTenantIdAsync(request.RoleId, cancellationToken);
 82137        foreach (var dependency in request.Dependencies)
 138        {
 24139            var connection = await FindConnectionInRoleTenantScopeAsync(dependency.OwnerId, roleTenantId, cancellationTo
 24140            if (connection is null || connection.Revision != dependency.ExpectedRevision ||
 24141                !TryGetRoleReference(connection.UnlinkedPolicy, request.RoleId, out _, out var roleIds, out _))
 0142                return new RoleReferenceRemovalValidationResult.Conflict("connection_revision_changed");
 51143            var remainingRoleIds = roleIds.Where(x => !string.Equals(x, request.RoleId, StringComparison.Ordinal)).ToArr
 24144            var requiresReplacement = remainingRoleIds.Length == 0 && request.SelectedReferences is not null;
 24145            if (requiresReplacement &&
 24146                (string.IsNullOrWhiteSpace(request.ReplacementRoleId) ||
 24147                 string.Equals(request.ReplacementRoleId, request.RoleId, StringComparison.Ordinal)))
 1148                return new RoleReferenceRemovalValidationResult.Forbidden("replacement_role_unavailable_or_unauthorized"
 149
 150            // Authorization below still resolves through the ambient tenant's role services, so an agnostic
 151            // deletion target may only be replaced by another agnostic role; a tenant-scoped replacement would
 152            // otherwise be authorized in this tenant and then written into every other tenant's connections.
 153            // This does not extend to host-scoped connections: IdentityProviderConnectionManagementService
 154            // forces every managed connection to host scope, and in a deployment without multitenancy roles
 155            // are created scoped to the default tenant rather than agnostic, so requiring an agnostic
 156            // replacement for host-scoped connections would make every replacement remediation impossible in
 157            // the default deployment.
 23158            if (requiresReplacement &&
 23159                string.Equals(roleTenantId, Tenant.AgnosticTenantId, StringComparison.Ordinal) &&
 23160                !await IsAgnosticRoleAsync(request.ReplacementRoleId, cancellationToken))
 4161                return new RoleReferenceRemovalValidationResult.Forbidden("replacement_role_unavailable_or_unauthorized"
 162
 19163            var rolesToAssign = requiresReplacement
 19164                ? new[] { request.ReplacementRoleId! }
 19165                : remainingRoleIds;
 19166            if (!await roleAuthorizationService.CanAssignRolesAsync(request.Actor, rolesToAssign, cancellationToken))
 167            {
 1168                var code = requiresReplacement ? "replacement_role_unavailable_or_unauthorized" : "role_assignment_denie
 1169                return new RoleReferenceRemovalValidationResult.Forbidden(code);
 170            }
 18171        }
 172
 14173        return new RoleReferenceRemovalValidationResult.Valid();
 27174    }
 175
 176    public async ValueTask<RoleReferenceRemovalResult> RemoveEditableReferencesAsync(RoleReferenceRemovalRequest request
 177    {
 12178        var roleAuthorizationService = roleAuthorizationServices.SingleOrDefault();
 12179        if (roleAuthorizationService is null)
 0180            return new RoleReferenceRemovalResult.Failed("role_authorization_unavailable", []);
 12181        var validation = await ValidateRemovalAsync(request, cancellationToken);
 11182        if (validation is RoleReferenceRemovalValidationResult.Forbidden forbidden)
 3183            return new RoleReferenceRemovalResult.Failed(forbidden.Code, []);
 8184        if (validation is RoleReferenceRemovalValidationResult.Conflict conflict)
 1185            return new RoleReferenceRemovalResult.Conflict(conflict.Code, []);
 186
 7187        var roleTenantId = await ResolveRoleTenantIdAsync(request.RoleId, cancellationToken);
 7188        var changedOwnerIds = new List<string>();
 189        try
 190        {
 40191            foreach (var dependency in request.Dependencies.OrderBy(x => x.OwnerId, StringComparer.Ordinal))
 192            {
 9193                var connection = await FindConnectionInRoleTenantScopeAsync(dependency.OwnerId, roleTenantId, cancellati
 9194                if (connection is null || connection.Revision != dependency.ExpectedRevision ||
 9195                    !TryGetRoleReference(connection.UnlinkedPolicy, request.RoleId, out _, out _, out var removesLastDef
 1196                    return new RoleReferenceRemovalResult.Conflict("connection_revision_changed", changedOwnerIds);
 197
 8198                var candidate = IdentityProviderConnectionCloner.Clone(connection);
 8199                candidate.UnlinkedPolicy = candidate.UnlinkedPolicy is { } policy
 8200                    ? policy with { Settings = RemoveRole(policy.Settings, request.RoleId, request.SelectedReferences is
 8201                    : null;
 8202                candidate.UpdatedAt = DateTimeOffset.UtcNow;
 8203                candidate.MaterialRevision = revisionCalculator.CalculateMaterialRevision(candidate);
 204
 8205                if (request.SelectedReferences is not null && removesLastDefaultRole)
 206                {
 4207                    var roleStore = ActiveRoleStore;
 4208                    if (roleStore is null)
 0209                        return new RoleReferenceRemovalResult.Failed("replacement_role_unavailable_or_unauthorized", cha
 4210                    var replacement = await roleStore.FindAsync(new() { Id = request.ReplacementRoleId }, cancellationTo
 4211                    if (replacement is null ||
 4212                        !await roleAuthorizationService.CanAssignRolesAsync(request.Actor, [replacement.Id], cancellatio
 0213                        return new RoleReferenceRemovalResult.Failed("replacement_role_unavailable_or_unauthorized", cha
 214
 215                    // Authorization above still resolves through the ambient tenant's role services, so an
 216                    // agnostic deletion target may only be replaced by another agnostic role; a tenant-scoped
 217                    // replacement would otherwise be authorized in this tenant and then written into every other
 218                    // tenant's connections. This does not extend to host-scoped connections: see the matching
 219                    // guard in ValidateRemovalAsync for why. The check is re-run through IsAgnosticRoleAsync
 220                    // rather than trusting the TenantId on `replacement` from the FindAsync call above, because
 221                    // a same-ID tenant-scoped role added after validation could make that lookup ambiguous;
 222                    // IsAgnosticRoleAsync resolves the candidate itself and rejects an ambiguous match instead
 223                    // of accepting whichever role FindAsync happened to return.
 4224                    if (string.Equals(roleTenantId, Tenant.AgnosticTenantId, StringComparison.Ordinal) &&
 4225                        !await IsAgnosticRoleAsync(request.ReplacementRoleId, cancellationToken))
 0226                        return new RoleReferenceRemovalResult.Failed("replacement_role_unavailable_or_unauthorized", cha
 227                }
 228
 8229                var update = await store.UpdateAsync(candidate, connection.Revision, cancellationToken);
 8230                if (update is not ConnectionMutationResult.Updated updated)
 0231                    return new RoleReferenceRemovalResult.Conflict("connection_revision_changed", changedOwnerIds);
 232
 8233                changedOwnerIds.Add(updated.Connection.Id);
 8234                await registryVersions.AdvanceAsync(cancellationToken);
 8235                await notifier.PublishAsync(
 8236                    new IdentityProviderConnectionChanged(
 8237                        ExternalAuthenticationSecurityNotifier.Context(
 8238                            request.Actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? request.Actor.FindFirstValue("sub
 8239                            updated.Connection.TenantId,
 8240                            updated.Connection.Id,
 8241                            null,
 8242                            SecurityEventOutcome.Succeeded,
 8243                            "An Elsa Role reference was removed from an external authentication JIT policy."),
 8244                        "default-role-removed",
 8245                        updated.Connection.Revision,
 8246                        updated.Connection.MaterialRevision),
 8247                    cancellationToken);
 8248            }
 6249        }
 0250        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 251        {
 0252            throw;
 253        }
 0254        catch
 255        {
 0256            return new RoleReferenceRemovalResult.Failed("storage_error", changedOwnerIds);
 257        }
 258
 6259        return new RoleReferenceRemovalResult.Success(changedOwnerIds);
 11260    }
 261
 262    /// <summary>
 263    /// Resolves the tenant context for the role being deleted: the role's own <c>TenantId</c>
 264    /// (<see cref="Tenant.AgnosticTenantId"/> normalized when the role is tenant-agnostic, in which case its
 265    /// tenant context is every tenant). In EF Core persistence a role ID is unique across all tenants (the
 266    /// <c>Roles</c> table keys on <c>Id</c> alone), so this lookup resolves to at most one role there. Only
 267    /// <c>MemoryRoleStore</c> can hold two roles that share an ID because its storage key includes the tenant;
 268    /// if the ID resolves to more than one role, which tenant's role the coordinator's own delete actually
 269    /// targets is already ambiguous, and this method cannot make the operation consistent by guessing in either
 270    /// direction -- widening would expose another tenant's references for what may be a tenant-scoped deletion,
 271    /// and narrowing would leave an agnostic role's references dangling. It fails closed instead.
 272    /// A missing store or no matching role falls back to the ambient tenant on <see cref="ITenantAccessor"/>,
 273    /// which is the only case where the ambient tenant is trusted: the role cannot be resolved at all, so there
 274    /// is no resolved tenant to prefer over it.
 275    /// </summary>
 276    private async ValueTask<string> ResolveRoleTenantIdAsync(string roleId, CancellationToken cancellationToken)
 277    {
 79278        var roles = await FindRolesByIdAsync(roleId, cancellationToken);
 79279        return roles.Length switch
 79280        {
 0281            0 => tenantAccessor.TenantId.NormalizeTenantId(),
 76282            1 => roles[0].TenantId.NormalizeTenantId(),
 3283            _ => throw new InvalidOperationException(
 3284                $"Role '{roleId}' resolves to {roles.Length} roles across tenant scopes; the deletion target is ambiguou
 79285        };
 76286    }
 287
 288    /// <summary>
 289    /// Resolves whether a candidate role ID (typically a replacement role) is itself tenant-agnostic. Unlike
 290    /// <see cref="ResolveRoleTenantIdAsync"/>, an ambiguous candidate -- a role ID that resolves to more than one
 291    /// role, which only <c>MemoryRoleStore</c> can produce -- is not the coordinator's own deletion target, so
 292    /// there is no operation to fail closed on by throwing; it is instead treated the same as an unresolved
 293    /// candidate and reported as not agnostic, since there is no single resolved role to trust as safe to write
 294    /// into every tenant's connections.
 295    /// </summary>
 296    private async ValueTask<bool> IsAgnosticRoleAsync(string? roleId, CancellationToken cancellationToken)
 297    {
 10298        if (string.IsNullOrWhiteSpace(roleId))
 0299            return false;
 10300        var roles = await FindRolesByIdAsync(roleId, cancellationToken);
 10301        return roles.Length == 1 && string.Equals(roles[0].TenantId.NormalizeTenantId(), Tenant.AgnosticTenantId, String
 10302    }
 303
 304    /// <summary>Loads every role matching the given ID from the active role store, or an empty result if none is config
 305    private async ValueTask<Role[]> FindRolesByIdAsync(string roleId, CancellationToken cancellationToken)
 306    {
 89307        var roleStore = ActiveRoleStore;
 89308        if (roleStore is null)
 0309            return [];
 89310        return (await roleStore.FindManyAsync(new() { Id = roleId }, cancellationToken)).ToArray();
 89311    }
 312
 313    /// <summary>
 314    /// Loads every stored connection in a single snapshot and filters it in memory to the role's tenant context.
 315    /// Composing the result from separate per-scope reads instead would let a connection's <c>TenantId</c> change
 316    /// between those reads (<see cref="RemoveEditableReferencesAsync"/> permits it via <c>UpdateAsync</c>), so the
 317    /// connection could fall between the reads and appear in neither result. A single snapshot has no gap to fall
 318    /// through.
 319    /// </summary>
 320    private async ValueTask<IReadOnlyCollection<IdentityProviderConnection>> FindConnectionsInRoleTenantScopeAsync(strin
 321    {
 49322        var connections = (await store.FindAsync(new(), cancellationToken)).Items;
 116323        return connections.Where(x => IsInRoleTenantScope(x.TenantId, roleTenantId)).ToArray();
 49324    }
 325
 326    /// <summary>
 327    /// Loads one dependency's connection, reporting a connection outside the role's tenant context as absent so
 328    /// that a caller-supplied owner ID cannot reach across a tenant boundary. An agnostic role's tenant context
 329    /// is every tenant, so any connection loaded by owner ID qualifies.
 330    /// </summary>
 331    private async ValueTask<IdentityProviderConnection?> FindConnectionInRoleTenantScopeAsync(string ownerId, string rol
 332    {
 33333        var connection = await store.FindByIdAsync(ownerId, cancellationToken);
 33334        return connection is not null && IsInRoleTenantScope(connection.TenantId, roleTenantId) ? connection : null;
 33335    }
 336
 337    private static bool IsInRoleTenantScope(string? connectionTenantId, string roleTenantId) =>
 105338        string.Equals(roleTenantId, Tenant.AgnosticTenantId, StringComparison.Ordinal) ||
 105339        string.Equals(connectionTenantId, ConnectionScope.HostTenantId, StringComparison.Ordinal) ||
 105340        string.Equals(connectionTenantId.NormalizeTenantId(), roleTenantId, StringComparison.Ordinal);
 341
 342    /// <summary>A configuration entry that leaves the tenant blank is materialized at host scope.</summary>
 343    private static string GetConfigurationScopeTenantId(IdentityProviderConnection connection) =>
 5344        string.IsNullOrWhiteSpace(connection.TenantId) ? ConnectionScope.HostTenantId : connection.TenantId;
 345
 346    private IEnumerable<RoleDeletionDependency> GetConfigurationDependencies(IdentityProviderConnection connection, int 
 347    {
 4348        if (!TryGetRoleReference(connection.UnlinkedPolicy, roleId, out var policyBranch, out var roleIds, out var remov
 0349            yield break;
 350
 4351        var roleIndex = 0;
 18352        foreach (var configuredRoleId in ReadRoleIdsWithDuplicates(connection.UnlinkedPolicy!.Settings))
 353        {
 5354            if (string.Equals(configuredRoleId, roleId, StringComparison.Ordinal))
 355            {
 4356                yield return new(
 4357                    Source,
 4358                    string.IsNullOrWhiteSpace(connection.Id) ? $"configuration:{connectionIndex}" : connection.Id,
 4359                    connection.Key,
 4360                    policyBranch,
 4361                    RoleDeletionDependencyOwnership.Configuration,
 4362                    $"ExternalAuthentication:Connections:{connectionIndex}:UnlinkedPolicy:Settings:defaultRoleIds:{roleI
 4363                    null,
 4364                    removesLastDefaultRole);
 365            }
 366
 5367            roleIndex++;
 368        }
 4369    }
 370
 371    private static bool TryGetRoleReference(PolicySelection? policy, string roleId, out string policyBranch, out IReadOn
 372    {
 98373        policyBranch = string.Empty;
 98374        roleIds = [];
 98375        removesLastDefaultRole = false;
 98376        if (policy is null)
 0377            return false;
 378
 98379        if (string.Equals(policy.Type, CreateUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal))
 97380            policyBranch = "create-user";
 1381        else if (string.Equals(policy.Type, MatchExternalUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal
 1382                 string.Equals(ReadString(policy.Settings, "noMatchAction"), "create-user", StringComparison.OrdinalIgno
 1383            policyBranch = "matcher-no-match-create-user";
 384        else
 0385            return false;
 386
 98387        roleIds = CreateUserUnlinkedIdentityPolicy.ReadRoleIds(policy.Settings);
 98388        if (!roleIds.Contains(roleId, StringComparer.Ordinal))
 0389            return false;
 98390        removesLastDefaultRole = roleIds.Count == 1;
 98391        return true;
 392    }
 393
 394    private static JsonElement RemoveRole(JsonElement settings, string roleId, string? replacementRoleId = null)
 395    {
 8396        var root = settings.ValueKind == JsonValueKind.Object
 8397            ? JsonNode.Parse(settings.GetRawText()) as JsonObject
 8398            : new();
 8399        root ??= new();
 8400        var remainingRoleIds = ReadRoleIdsWithDuplicates(settings)
 9401            .Where(x => !string.Equals(x, roleId, StringComparison.Ordinal))
 8402            .Distinct(StringComparer.Ordinal)
 8403            .ToArray();
 8404        if (remainingRoleIds.Length == 0 && !string.IsNullOrWhiteSpace(replacementRoleId))
 4405            remainingRoleIds = [replacementRoleId];
 8406        var roleNodes = new JsonNode?[remainingRoleIds.Length];
 26407        for (var index = 0; index < remainingRoleIds.Length; index++)
 5408            roleNodes[index] = JsonValue.Create(remainingRoleIds[index]);
 8409        root["defaultRoleIds"] = new JsonArray(roleNodes);
 8410        return JsonSerializer.SerializeToElement(root);
 411    }
 412
 413    private static IReadOnlyCollection<string> ReadRoleIdsWithDuplicates(JsonElement settings) =>
 12414        settings.ValueKind == JsonValueKind.Object &&
 12415        settings.TryGetProperty("defaultRoleIds", out var values) &&
 12416        values.ValueKind == JsonValueKind.Array
 12417            ? values.EnumerateArray()
 14418                .Where(x => x.ValueKind == JsonValueKind.String)
 14419                .Select(x => x.GetString())
 14420                .Where(x => !string.IsNullOrWhiteSpace(x))
 12421                .Cast<string>()
 12422                .ToArray()
 12423            : [];
 424
 425    private static string? ReadString(JsonElement settings, string propertyName) =>
 1426        settings.ValueKind == JsonValueKind.Object &&
 1427        settings.TryGetProperty(propertyName, out var value) &&
 1428        value.ValueKind == JsonValueKind.String
 1429            ? value.GetString()
 1430            : null;
 431
 432    private static string CalculateVersion(IEnumerable<RoleDeletionDependency> dependencies)
 433    {
 49434        var payload = string.Join(
 49435            "\n",
 115436            dependencies.Select(x => $"{x.OwnerId}|{x.OwnerKey}|{x.PolicyBranch}|{x.Ownership}|{x.ConfigurationPath}|{x.
 49437        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
 438    }
 439}

Methods/Properties

.ctor(Elsa.ExternalAuthentication.Contracts.IIdentityProviderConnectionStore,Microsoft.Extensions.Options.IOptionsMonitor`1<Elsa.ExternalAuthentication.Options.ExternalAuthenticationOptions>,System.Collections.Generic.IEnumerable`1<Elsa.Identity.Contracts.IRoleAuthorizationService>,System.Collections.Generic.IEnumerable`1<Elsa.Identity.Contracts.IRoleStore>,Elsa.ExternalAuthentication.Contracts.IConnectionRegistryVersionStore,Elsa.ExternalAuthentication.Services.ConnectionRevisionCalculator,Elsa.ExternalAuthentication.Services.ExternalAuthenticationSecurityNotifier,Elsa.Authorization.IPermissionEvaluator,Elsa.Common.Multitenancy.ITenantAccessor)
get_Source()
get_ActiveRoleStore()
InspectAsync()
ValidateRemovalAsync()
RemoveEditableReferencesAsync()
ResolveRoleTenantIdAsync()
IsAgnosticRoleAsync()
FindRolesByIdAsync()
FindConnectionsInRoleTenantScopeAsync()
FindConnectionInRoleTenantScopeAsync()
IsInRoleTenantScope(System.String,System.String)
GetConfigurationScopeTenantId(Elsa.ExternalAuthentication.Models.IdentityProviderConnection)
GetConfigurationDependencies()
TryGetRoleReference(Elsa.ExternalAuthentication.Models.PolicySelection,System.String,System.String&,System.Collections.Generic.IReadOnlyCollection`1<System.String>&,System.Boolean&)
RemoveRole(System.Text.Json.JsonElement,System.String,System.String)
ReadRoleIdsWithDuplicates(System.Text.Json.JsonElement)
ReadString(System.Text.Json.JsonElement,System.String)
CalculateVersion(System.Collections.Generic.IEnumerable`1<Elsa.Identity.Models.RoleDeletionDependency>)