< Summary

Line coverage
83%
Covered lines: 353
Uncovered lines: 72
Coverable lines: 425
Total lines: 671
Line coverage: 83%
Branch coverage
61%
Covered branches: 254
Total branches: 413
Branch coverage: 61.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: ConnectionKeyPattern()100%11100%
File 2: .ctor(...)100%11100%
File 2: FindAsync()87.5%8887.5%
File 2: GetProviderCallbackUri(...)50%22100%
File 2: GetProviderPreviewCallbackUri(...)50%22100%
File 2: ListAsync()100%11100%
File 2: CreateAsync()83.33%6691.66%
File 2: UpdateAsync()68.75%161688%
File 2: ChangeLifecycleAsync()58.06%463175%
File 2: ValidateAsync()71.87%423278.37%
File 2: GetSecretBindingStatesAsync(...)100%11100%
File 2: PresentSecretBinding(...)66.66%66100%
File 2: CanCreateConfigurationOverride()100%11100%
File 2: CanPromoteToConfigurationOverride(...)100%66100%
File 2: IsBlockedByFinalLoginPathGuardAsync()87.5%8892.3%
File 2: ProcessMutationAsync()70%141066.66%
File 2: RunPostCommitActionsAsync()100%1172.72%
File 2: PublishBulkSessionRevocationAsync()66.66%7675%
File 2: PublishAsync()70.83%242496.15%
File 2: CollidesWithConfigurationOrHostAsync()66.66%6690.9%
File 2: GetSecretStatesAsync()75%4485.71%
File 2: ApplySettingsMigrationAsync()100%11100%
File 2: NormalizeForCreate(...)50%66100%
File 2: NormalizeForUpdate(...)50%1010100%
File 2: ValidateEnvelope(...)57.89%1073863.63%
File 2: ValidatePolicyAsync()83.33%252487.5%
File 2: UsesCreateUserFallback(...)50%44100%
File 2: TryGetMatcherSelection(...)50%201883.33%
File 2: ReadString(...)50%66100%
File 2: ValidateGrantSources(...)20%291042.85%
File 2: ValidateSecretBindingFields(...)90%101090%
File 2: ValidateSecretBindingsAreNotAdapterSettings(...)83.33%6683.33%
File 2: UsesUnsafeSettings(...)75%4475%
File 2: UnsafeSettingsChanged(...)71.42%241462.5%
File 2: IsUnsafeFieldValue(...)50%1010100%
File 2: JsonValueEquals(...)50%22100%
File 2: ToEffective(...)100%11100%
File 2: ToScope(...)25%44100%
File 2: CanMutate(...)50%22100%
File 2: IsValidScope(...)100%11100%
File 2: NormalizeScopeTenantId(...)0%620%
File 2: IsAllowed(...)100%22100%
File 2: HasPermission(...)50%22100%
File 2: GetLifecycle(...)100%44100%
File 2: Matches(...)58.33%2424100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/obj/Release/net10.0/System.Text.RegularExpressions.Generator/System.Text.RegularExpressions.Generator.RegexGenerator/RegexGenerator.g.cs

File '/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/obj/Release/net10.0/System.Text.RegularExpressions.Generator/System.Text.RegularExpressions.Generator.RegexGenerator/RegexGenerator.g.cs' does not exist (any more).

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

#LineLine coverage
 1using System.Security.Claims;
 2using System.Text.Json;
 3using Elsa.Abstractions;
 4using Elsa.Common;
 5using Elsa.ExternalAuthentication.Contracts;
 6using Elsa.ExternalAuthentication.Models;
 7using Elsa.ExternalAuthentication.Notifications;
 8using Elsa.ExternalAuthentication.Options;
 9using Elsa.ExternalAuthentication.Permissions;
 10using Elsa.ExternalAuthentication.Providers;
 11using Elsa.Mediator.Contracts;
 12using Microsoft.Extensions.DependencyInjection;
 13using Microsoft.Extensions.Logging;
 14using Microsoft.Extensions.Options;
 15
 16namespace Elsa.ExternalAuthentication.Services;
 17
 18/// <summary>
 19/// Applies management-only invariants before mutating database-owned connections.
 20/// Connection stores remain responsible for durable compare-and-swap and unique-key enforcement.
 21/// </summary>
 8622public sealed partial class IdentityProviderConnectionManagementService(
 8623    IIdentityProviderConnectionStore store,
 8624    IIdentityProviderConnectionRegistry registry,
 8625    IIdentityProviderConnectionValidityAssessor validityAssessor,
 8626    IConnectionRegistryVersionStore registryVersions,
 8627    IExternalAuthenticationAdapterRegistry adapters,
 8628    IAdapterSettingsMigrationService settingsMigrations,
 8629    IUnlinkedIdentityPolicyRegistry policies,
 8630    IExternalUserMatcherRegistry matchers,
 8631    IPermissionGrantSourceRegistry grantSources,
 8632    IEnumerable<ISecretBindingResolver> secretBindingResolvers,
 8633    IEnumerable<IManagedSecretBindingWriter> managedSecretBindingWriters,
 8634    IPermissionDelegationAuthorizer delegationAuthorizer,
 8635    ConnectionRevisionCalculator revisionCalculator,
 8636    ISystemClock clock,
 8637    IOptions<ExternalAuthenticationOptions> options,
 8638    Elsa.Identity.Contracts.IRoleAuthorizationService roleAuthorizationService,
 8639    IExternalAuthenticationSessionStore sessions,
 8640    IServiceProvider services,
 8641    ILogger<IdentityProviderConnectionManagementService> logger)
 42{
 17043    private readonly IReadOnlyDictionary<string, ISecretBindingResolver> _secretBindingResolvers = secretBindingResolver
 17044    private readonly IReadOnlyDictionary<string, IManagedSecretBindingWriter> _managedSecretBindingWriters = managedSecr
 45
 46    public async ValueTask<ManagementConnectionLookupResult> FindAsync(string id, string targetTenantId, CancellationTok
 47    {
 3848        var effective = await registry.FindByIdAsync(targetTenantId, id, cancellationToken);
 3849        if (effective is not null && effective.Scope == ConnectionScope.Host)
 3750            return new ManagementConnectionLookupResult.Found(await validityAssessor.AssessAsync(effective, cancellation
 51
 152        var connection = await store.FindByIdAsync(id, cancellationToken);
 153        if (connection is null || connection.TenantId != ConnectionScope.HostTenantId)
 154            return new ManagementConnectionLookupResult.NotFound();
 55
 056        return new ManagementConnectionLookupResult.Found(await validityAssessor.AssessAsync(ToEffective(connection), ca
 3857    }
 58
 59    /// <summary>Returns the deployment-derived read-only upstream callback URI for management display.</summary>
 3360    public Uri? GetProviderCallbackUri(IdentityProviderConnection connection) => options.Value.Redirects.ExternalCallbac
 3361        ? ExternalAuthenticationCallbackUris.GetAuthorizationCallbackUri(baseUri, connection, BrokerTransactionPurpose.E
 3362        : null;
 63
 64    /// <summary>Returns the deployment-derived read-only callback URI used by provider preview sign-ins.</summary>
 3365    public Uri? GetProviderPreviewCallbackUri(IdentityProviderConnection connection) => options.Value.Redirects.External
 3366        ? ExternalAuthenticationCallbackUris.GetAuthorizationCallbackUri(baseUri, connection, BrokerTransactionPurpose.P
 3367        : null;
 68
 69    public async ValueTask<IReadOnlyCollection<EffectiveIdentityProviderConnection>> ListAsync(string targetTenantId, Co
 70    {
 271        var effective = await registry.GetAsync(targetTenantId, cancellationToken);
 272        var matches = effective.Connections
 673            .Where(x => x.Scope == ConnectionScope.Host)
 674            .Where(x => Matches(x, filter))
 675            .OrderBy(x => x.Scope.Kind)
 676            .ThenBy(x => x.Connection.DisplayOrder)
 677            .ThenBy(x => x.Connection.Key, StringComparer.Ordinal)
 678            .ThenBy(x => x.Connection.Id, StringComparer.Ordinal)
 279            .ToArray();
 880        return await Task.WhenAll(matches.Select(x => validityAssessor.AssessAsync(x, cancellationToken).AsTask()));
 281    }
 82
 83    public async ValueTask<ManagementConnectionMutationResult> CreateAsync(IdentityProviderConnection connection, Claims
 84    {
 1985        NormalizeForCreate(connection, targetTenantId);
 1986        if (!CanMutate(connection.TenantId, targetTenantId))
 087            return new ManagementConnectionMutationResult.Forbidden();
 1988        var validation = await ValidateAsync(connection, actor, targetTenantId, requireCompleteConfiguration: false, con
 1989        if (!validation.IsValid)
 690            return new ManagementConnectionMutationResult.ValidationFailed(validation);
 1391        if (await CollidesWithConfigurationOrHostAsync(connection, null, targetTenantId, cancellationToken))
 292            return new ManagementConnectionMutationResult.Conflict("connection_key_conflict");
 93
 1194        connection.MaterialRevision = revisionCalculator.CalculateMaterialRevision(connection);
 1195        var result = await store.CreateAsync(connection, cancellationToken);
 1196        return await ProcessMutationAsync(result, actor, "created", null, cancellationToken);
 1997    }
 98
 99    public async ValueTask<ManagementConnectionMutationResult> UpdateAsync(string id, IdentityProviderConnection candida
 100    {
 14101        var existing = await store.FindByIdAsync(id, cancellationToken);
 14102        if (existing is null || existing.TenantId != ConnectionScope.HostTenantId)
 0103            return new ManagementConnectionMutationResult.NotFound();
 14104        if (!CanMutate(existing.TenantId, targetTenantId))
 0105            return new ManagementConnectionMutationResult.Forbidden();
 106
 14107        candidate.Id = existing.Id;
 14108        candidate.CreatedAt = existing.CreatedAt;
 14109        candidate.Revision = existing.Revision;
 14110        candidate.ArchivedAt = existing.ArchivedAt;
 14111        NormalizeForUpdate(candidate, existing);
 14112        if (!CanMutate(candidate.TenantId, targetTenantId))
 0113            return new ManagementConnectionMutationResult.Forbidden();
 14114        if (!string.Equals(existing.Key, candidate.Key, StringComparison.Ordinal))
 1115            return new ManagementConnectionMutationResult.Conflict("connection_key_immutable");
 13116        var requireUnsafeConfirmation = adapters.TryGet(candidate.AdapterType, out var candidateAdapter) &&
 13117            UnsafeSettingsChanged(existing.AdapterSettings, candidate.AdapterSettings, candidateAdapter.Describe());
 13118        var validation = await ValidateAsync(candidate, actor, targetTenantId, requireCompleteConfiguration: candidate.I
 12119        if (!validation.IsValid)
 1120            return new ManagementConnectionMutationResult.ValidationFailed(validation);
 121
 11122        if (await IsBlockedByFinalLoginPathGuardAsync(existing, candidate, targetTenantId, actor, confirmFinalLoginPathO
 1123            return new ManagementConnectionMutationResult.Conflict("final_login_path_guard");
 124
 10125        candidate.MaterialRevision = revisionCalculator.CalculateMaterialRevision(candidate);
 10126        var result = await store.UpdateAsync(candidate, expectedRevision, cancellationToken);
 10127        return await ProcessMutationAsync(result, actor, "updated", GetLifecycle(existing), cancellationToken, existing)
 13128    }
 129
 130    public async ValueTask<ManagementConnectionMutationResult> ChangeLifecycleAsync(string id, ConnectionLifecycle actio
 131    {
 5132        var existing = await store.FindByIdAsync(id, cancellationToken);
 5133        if (existing is null || existing.TenantId != ConnectionScope.HostTenantId)
 0134            return new ManagementConnectionMutationResult.NotFound();
 5135        if (!CanMutate(existing.TenantId, targetTenantId))
 0136            return new ManagementConnectionMutationResult.Forbidden();
 137
 5138        var previousLifecycle = GetLifecycle(existing);
 5139        var candidate = IdentityProviderConnectionCloner.Clone(existing);
 140        switch (action)
 141        {
 142            case ConnectionLifecycle.Enabled:
 143            {
 2144                var validation = await ValidateAsync(candidate, actor, targetTenantId, requireCompleteConfiguration: tru
 2145                if (!validation.IsValid)
 1146                    return new ManagementConnectionMutationResult.ValidationFailed(validation);
 1147                if (candidate.ArchivedAt.HasValue)
 0148                    return new ManagementConnectionMutationResult.Conflict("connection_archived");
 1149                if (candidate.IsPreferred)
 150                {
 0151                    var current = await registry.GetAsync(targetTenantId, cancellationToken);
 0152                    if (current.Connections.Any(x =>
 0153                            x.Ownership == ConnectionSourceOwnership.Configuration &&
 0154                            !x.IsShadowed &&
 0155                            x.Connection.IsEnabled &&
 0156                            !x.Connection.ArchivedAt.HasValue &&
 0157                            x.Connection.IsPreferred &&
 0158                            x.Validity != ConnectionValidity.Invalid &&
 0159                            (!candidate.OverridesConfigurationConnection || !string.Equals(x.Connection.Key, candidate.K
 0160                        return new ManagementConnectionMutationResult.Conflict("configuration_preferred_connection");
 0161                    if (current.Connections.Any(x => x.Ownership == ConnectionSourceOwnership.Database && x.Connection.I
 0162                        return new ManagementConnectionMutationResult.Conflict("default_connection_conflict");
 163                }
 1164                candidate.IsEnabled = true;
 1165                break;
 166            }
 167            case ConnectionLifecycle.Disabled:
 1168                if (candidate.ArchivedAt.HasValue)
 0169                    return new ManagementConnectionMutationResult.Conflict("connection_archived");
 1170                candidate.IsEnabled = false;
 1171                break;
 172            case ConnectionLifecycle.Archived:
 1173                candidate.IsEnabled = false;
 1174                candidate.ArchivedAt = clock.UtcNow;
 1175                break;
 176            case ConnectionLifecycle.Draft:
 1177                if (!candidate.ArchivedAt.HasValue)
 0178                    return new ManagementConnectionMutationResult.Conflict("connection_not_archived");
 1179                candidate.ArchivedAt = null;
 1180                candidate.IsEnabled = false;
 1181                break;
 182            default:
 0183                return new ManagementConnectionMutationResult.Conflict("invalid_lifecycle_action");
 184        }
 185
 4186        candidate.UpdatedAt = clock.UtcNow;
 4187        candidate.MaterialRevision = revisionCalculator.CalculateMaterialRevision(candidate);
 4188        if (await IsBlockedByFinalLoginPathGuardAsync(existing, candidate, targetTenantId, actor, confirmFinalLoginPathO
 0189            return new ManagementConnectionMutationResult.Conflict("final_login_path_guard");
 4190        var result = await store.UpdateAsync(candidate, expectedRevision, cancellationToken);
 4191        var processed = await ProcessMutationAsync(result, actor, action.ToString().ToLowerInvariant(), previousLifecycl
 4192        if (processed is ManagementConnectionMutationResult.Success && action == ConnectionLifecycle.Disabled && revokeA
 193        {
 1194            var connectionKey = ConnectionRevisionCalculator.NormalizeKey(candidate.Key);
 1195            var revokedCount = await sessions.RevokeActiveForConnectionAsync(connectionKey, "connection_disabled", clock
 1196            await PublishBulkSessionRevocationAsync(candidate, actor, revokedCount);
 197        }
 4198        return processed;
 5199    }
 200
 201    public async ValueTask<ConnectionValidationResult> ValidateAsync(IdentityProviderConnection connection, ClaimsPrinci
 202    {
 37203        var errors = new List<ConnectionValidationError>();
 37204        var warnings = new List<string>();
 37205        var configuredOptions = options.Value;
 37206        ValidateEnvelope(connection, configuredOptions, errors);
 207
 37208        if (!adapters.TryGet(connection.AdapterType, out var adapter) || !IsAllowed(configuredOptions.AllowedAdapterType
 0209            errors.Add(new ConnectionValidationError("adapterType", "unavailable", "The selected adapter is not installe
 210
 37211        await ValidatePolicyAsync(connection, actor, configuredOptions, errors, cancellationToken);
 36212        ValidateGrantSources(connection, configuredOptions, errors);
 36213        if (connection.PermissionGrantSources.Count != 0)
 214        {
 0215            var delegation = await delegationAuthorizer.AuthorizeAsync(actor, connection.PermissionGrantSources.ToArray(
 0216            if (!delegation.IsAuthorized)
 0217                errors.Add(new ConnectionValidationError("permissionGrantSources", "delegation_denied", "The caller may 
 218        }
 219
 36220        if (adapter is null)
 0221            return new ConnectionValidationResult(false, errors, warnings);
 222
 36223        await ApplySettingsMigrationAsync(connection, errors, cancellationToken);
 36224        if (errors.Count != 0)
 6225            return new ConnectionValidationResult(false, errors, warnings);
 226
 30227        var descriptor = adapter.Describe();
 30228        ValidateSecretBindingFields(connection, descriptor, requireCompleteConfiguration, errors);
 30229        ValidateSecretBindingsAreNotAdapterSettings(connection.AdapterSettings, descriptor, errors);
 30230        if (requireUnsafeConfirmation && UsesUnsafeSettings(connection.AdapterSettings, descriptor) && (!confirmUnsafeSe
 0231            errors.Add(new ConnectionValidationError("adapterSettings", "unsafe_confirmation_required", "Unsafe provider
 232
 30233        if (requireCompleteConfiguration)
 234        {
 6235            var secretStates = await GetSecretStatesAsync(connection, cancellationToken);
 14236            foreach (var (name, state) in secretStates)
 237            {
 1238                if (!state.IsConfigured)
 0239                    errors.Add(new ConnectionValidationError($"secretBindings.{name}", "required", "A required secret bi
 1240                else if (!state.IsResolvable)
 0241                    errors.Add(new ConnectionValidationError($"secretBindings.{name}", "unresolvable", "The secret bindi
 242            }
 243        }
 244
 30245        if (errors.Count != 0 || allowIncompleteDraft)
 25246            return new ConnectionValidationResult(errors.Count == 0, errors, warnings);
 247
 5248        var effective = ToEffective(connection);
 5249        var adapterValidation = await adapter.ValidateAsync(new ConnectionValidationContext(effective, new Dictionary<st
 5250        errors.AddRange(adapterValidation.Errors);
 5251        warnings.AddRange(adapterValidation.Warnings);
 5252        return new ConnectionValidationResult(errors.Count == 0 && adapterValidation.IsValid, errors, warnings);
 36253    }
 254
 33255    public ValueTask<IReadOnlyDictionary<string, SecretBindingState>> GetSecretBindingStatesAsync(IdentityProviderConnec
 256
 5257    public SecretBindingPresentation PresentSecretBinding(SecretBinding binding, SecretBindingState? state) => new(
 5258        binding.Ownership == SecretBindingOwnership.Managed ? "managed" : "external",
 5259        state?.IsConfigured ?? false,
 5260        state?.IsResolvable ?? false);
 261
 1262    public bool CanCreateConfigurationOverride() => options.Value.AllowConfigurationConnectionOverrides;
 263
 264    public bool CanPromoteToConfigurationOverride(EffectiveIdentityProviderConnection connection) =>
 33265        connection.Ownership == ConnectionSourceOwnership.Database &&
 33266        connection.IsShadowed &&
 33267        !connection.Connection.ArchivedAt.HasValue &&
 33268        options.Value.AllowConfigurationConnectionOverrides;
 269
 270    private async ValueTask<bool> IsBlockedByFinalLoginPathGuardAsync(IdentityProviderConnection existing, IdentityProvi
 271    {
 15272        var guard = services.GetService<FinalLoginPathGuard>();
 15273        if (guard is null)
 0274            return false;
 275
 15276        var guardExisting = existing;
 15277        if (!existing.OverridesConfigurationConnection && candidate.OverridesConfigurationConnection)
 278        {
 2279            var normalizedKey = ConnectionRevisionCalculator.NormalizeKey(candidate.Key);
 2280            var effective = await registry.GetAsync(targetTenantId, cancellationToken);
 2281            var displacedConfigurationConnection = effective.Connections.FirstOrDefault(x =>
 4282                x.Ownership == ConnectionSourceOwnership.Configuration &&
 4283                !x.IsShadowed &&
 4284                string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), normalizedKey, StringComparis
 2285            if (displacedConfigurationConnection is not null)
 2286                guardExisting = displacedConfigurationConnection.Connection;
 2287        }
 288
 15289        return await guard.AuthorizeAsync(guardExisting, candidate, targetTenantId, actor, confirmedOverride, cancellati
 15290    }
 291
 292    private async ValueTask<ManagementConnectionMutationResult> ProcessMutationAsync(ConnectionMutationResult result, Cl
 293    {
 294        switch (result)
 295        {
 296            case ConnectionMutationResult.Created(var createdConnection):
 11297                await RunPostCommitActionsAsync(createdConnection, actor, operation, previousLifecycle, previousConnecti
 11298                return new ManagementConnectionMutationResult.Success(createdConnection);
 299            case ConnectionMutationResult.Updated(var updatedConnection):
 12300                await RunPostCommitActionsAsync(updatedConnection, actor, operation, previousLifecycle, previousConnecti
 12301                return new ManagementConnectionMutationResult.Success(updatedConnection);
 302            case ConnectionMutationResult.NotFound:
 0303                return new ManagementConnectionMutationResult.NotFound();
 304            case ConnectionMutationResult.DuplicateKey:
 0305                return new ManagementConnectionMutationResult.Conflict("connection_key_conflict");
 306            case ConnectionMutationResult.RevisionConflict(var currentRevision):
 2307                return new ManagementConnectionMutationResult.PreconditionFailed(currentRevision);
 308            default:
 0309                throw new InvalidOperationException("The connection store returned an unknown mutation result.");
 310        }
 25311    }
 312
 313    private async ValueTask RunPostCommitActionsAsync(IdentityProviderConnection connection, ClaimsPrincipal actor, stri
 314    {
 315        try
 316        {
 23317            await registryVersions.AdvanceAsync(CancellationToken.None);
 23318        }
 0319        catch (Exception exception)
 320        {
 0321            logger.LogCritical(exception, "Connection {ConnectionId} was committed, but advancing the external-authentic
 0322        }
 323
 324        try
 325        {
 23326            await PublishAsync(connection, actor, operation, previousLifecycle, previousConnection, CancellationToken.No
 22327        }
 1328        catch (Exception exception)
 329        {
 1330            logger.LogError(exception, "Connection {ConnectionId} was committed, but publishing external-authentication 
 1331        }
 23332    }
 333
 334    private async ValueTask PublishBulkSessionRevocationAsync(IdentityProviderConnection connection, ClaimsPrincipal act
 335    {
 1336        if (revokedCount == 0)
 0337            return;
 338
 1339        var notificationSender = services.GetService<INotificationSender>();
 1340        if (notificationSender is null)
 0341            return;
 342
 343        try
 344        {
 1345            var context = new SecurityEventContext(
 1346                actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 1347                connection.TenantId,
 1348                connection.Id,
 1349                null,
 1350                clock.UtcNow,
 1351                SecurityEventOutcome.Succeeded,
 1352                Guid.NewGuid().ToString("N"),
 1353                "Active external authentication sessions were revoked when the connection was disabled.");
 1354            await notificationSender.SendAsync(new ExternalAuthenticationConnectionSessionsRevoked(context, revokedCount
 1355        }
 0356        catch (Exception exception)
 357        {
 0358            logger.LogError(exception, "Connection {ConnectionId} sessions were revoked, but publishing the aggregate se
 0359        }
 1360    }
 361
 362    private async ValueTask PublishAsync(IdentityProviderConnection connection, ClaimsPrincipal actor, string operation,
 363    {
 23364        var notificationSender = services.GetService<INotificationSender>();
 23365        if (notificationSender is null)
 0366            return;
 367
 23368        var context = new SecurityEventContext(
 23369            actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 23370            connection.TenantId,
 23371            connection.Id,
 23372            null,
 23373            clock.UtcNow,
 23374            SecurityEventOutcome.Succeeded,
 23375            Guid.NewGuid().ToString("N"),
 23376            "Identity provider connection management operation completed.");
 23377        await notificationSender.SendAsync(new IdentityProviderConnectionChanged(context, operation, connection.Revision
 22378        if (previousLifecycle is { } previous && previous != GetLifecycle(connection))
 4379            await notificationSender.SendAsync(new IdentityProviderConnectionLifecycleChanged(context, previous.ToString
 22380        if (previousConnection is not null)
 381        {
 11382            var fields = previousConnection.SecretBindings.Keys
 11383                .Concat(connection.SecretBindings.Keys)
 11384                .Distinct(StringComparer.Ordinal)
 15385                .Where(field => !previousConnection.SecretBindings.TryGetValue(field, out var before) || !connection.Sec
 28386            foreach (var field in fields)
 387            {
 3388                previousConnection.SecretBindings.TryGetValue(field, out var previousBinding);
 3389                connection.SecretBindings.TryGetValue(field, out var binding);
 3390                var isConfigured = binding is not null && _secretBindingResolvers.TryGetValue(binding.ResolverType, out 
 3391                await notificationSender.SendAsync(new IdentityProviderConnectionSecretBindingChanged(context, field, bi
 3392            }
 393        }
 22394    }
 395
 396    private async ValueTask<bool> CollidesWithConfigurationOrHostAsync(IdentityProviderConnection candidate, string? sel
 397    {
 13398        var lookupTenant = candidate.TenantId == ConnectionScope.HostTenantId ? ConnectionScope.HostTenantId : candidate
 13399        var effective = await registry.GetAsync(lookupTenant, cancellationToken);
 13400        var key = ConnectionRevisionCalculator.NormalizeKey(candidate.Key);
 15401        if (effective.Connections.Any(x => x.Ownership == ConnectionSourceOwnership.Configuration && x.Scope.TenantId ==
 1402            return !candidate.OverridesConfigurationConnection;
 403
 12404        if (candidate.TenantId != ConnectionScope.HostTenantId)
 0405            return effective.Connections.Any(x =>
 0406            !string.Equals(x.Connection.Id, selfId, StringComparison.Ordinal) &&
 0407            x.Scope.Kind == ConnectionScopeKind.Host &&
 0408            string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), key, StringComparison.Ordinal));
 409
 12410        var rows = await store.FindAsync(new ConnectionFilter(), cancellationToken);
 12411        if (rows.Items.Any(x =>
 15412                !string.Equals(x.Id, selfId, StringComparison.Ordinal) &&
 15413                x.TenantId != ConnectionScope.HostTenantId &&
 15414                string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Key), key, StringComparison.Ordinal)))
 1415            return true;
 416
 11417        return options.Value.ConfigurationConnections.Any(x =>
 11418            x.TenantId != ConnectionScope.HostTenantId &&
 11419            string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Key), key, StringComparison.Ordinal));
 13420    }
 421
 422    private async ValueTask<IReadOnlyDictionary<string, SecretBindingState>> GetSecretStatesAsync(IdentityProviderConnec
 423    {
 39424        var states = new Dictionary<string, SecretBindingState>(StringComparer.Ordinal);
 90425        foreach (var (name, binding) in connection.SecretBindings)
 426        {
 6427            if (!_secretBindingResolvers.TryGetValue(binding.ResolverType, out var resolver))
 0428                states[name] = new SecretBindingState(false, false);
 429            else
 6430                states[name] = await resolver.GetStateAsync(binding, cancellationToken);
 431        }
 432
 39433        return states;
 39434    }
 435
 436    private async ValueTask ApplySettingsMigrationAsync(IdentityProviderConnection connection, ICollection<ConnectionVal
 437    {
 438        try
 439        {
 36440            var migration = await settingsMigrations.MigrateAsync(connection.AdapterType, connection.AdapterSettingsVers
 34441            connection.AdapterSettingsVersion = migration.SettingsVersion;
 34442            connection.AdapterSettings = migration.Settings;
 34443        }
 2444        catch (InvalidOperationException)
 445        {
 2446            errors.Add(new ConnectionValidationError("adapterSettingsVersion", "migration_unavailable", "The adapter set
 2447        }
 36448    }
 449
 450    private void NormalizeForCreate(IdentityProviderConnection connection, string targetTenantId)
 451    {
 19452        connection.Id = string.IsNullOrWhiteSpace(connection.Id) ? Guid.NewGuid().ToString("N") : connection.Id;
 19453        connection.Key = connection.Key?.Trim() ?? string.Empty;
 19454        connection.TenantId = ConnectionScope.HostTenantId;
 19455        connection.IsEnabled = false;
 19456        connection.ArchivedAt = null;
 19457        connection.Revision = 1;
 19458        connection.CreatedAt = clock.UtcNow;
 19459        connection.UpdatedAt = clock.UtcNow;
 19460        connection.MaterialRevision = revisionCalculator.CalculateMaterialRevision(connection);
 19461    }
 462
 463    private void NormalizeForUpdate(IdentityProviderConnection candidate, IdentityProviderConnection existing)
 464    {
 14465        candidate.Key = candidate.Key?.Trim() ?? string.Empty;
 14466        candidate.TenantId = ConnectionScope.HostTenantId;
 14467        candidate.IsEnabled = existing.IsEnabled;
 14468        candidate.UpdatedAt = clock.UtcNow;
 14469        candidate.SecretBindings ??= new Dictionary<string, SecretBinding>(StringComparer.Ordinal);
 14470        candidate.PermissionGrantSources ??= [];
 14471        candidate.ClaimProjection ??= ClaimProjection.Empty;
 14472    }
 473
 474    private static void ValidateEnvelope(IdentityProviderConnection connection, ExternalAuthenticationOptions configured
 475    {
 37476        if (!configuredOptions.EnableDatabaseConnections)
 0477            errors.Add(new ConnectionValidationError("source", "disabled", "Database-owned connections are disabled by d
 37478        if (connection.OverridesConfigurationConnection && !configuredOptions.AllowConfigurationConnectionOverrides)
 1479            errors.Add(new ConnectionValidationError("overridesConfigurationConnection", "not_allowed", "This deployment
 37480        if (string.IsNullOrWhiteSpace(connection.Key) || connection.Key.Length > 128 || connection.Key.Any(char.IsWhiteS
 0481            errors.Add(new ConnectionValidationError("key", "invalid", "Connection keys must be non-empty lowercase URL-
 37482        else if (!ConnectionKeyPattern().IsMatch(connection.Key))
 1483            errors.Add(new ConnectionValidationError("key", "invalid", "Connection keys must use lowercase letters, digi
 37484        if (string.IsNullOrWhiteSpace(connection.DisplayName) || connection.DisplayName.Trim().Length > 256)
 0485            errors.Add(new ConnectionValidationError("displayName", "invalid", "Display name is required and may not exc
 37486        if (connection.AdapterSettingsVersion <= 0)
 0487            errors.Add(new ConnectionValidationError("adapterSettingsVersion", "invalid", "Adapter settings version must
 37488        if (!Enum.IsDefined(connection.UpstreamLogoutMode))
 0489            errors.Add(new ConnectionValidationError("upstreamLogoutMode", "invalid", "Upstream logout mode is invalid."
 37490        if (!IsValidScope(connection.TenantId))
 0491            errors.Add(new ConnectionValidationError("scope", "host_scope_required", "Identity provider connections are 
 37492        if (connection.ClaimProjection.MaximumClaimCount < 0 || connection.ClaimProjection.MaximumValueLength < 0 || con
 37493            connection.ClaimProjection.MaximumClaimCount > configuredOptions.Claims.MaximumClaimCount || connection.Clai
 0494            errors.Add(new ConnectionValidationError("claimProjection", "invalid", "Claim projection limits exceed deplo
 37495        if (!connection.ClaimProjection.RedactedClaimTypes.IsSubsetOf(connection.ClaimProjection.AllowedClaimTypes))
 0496            errors.Add(new ConnectionValidationError("claimProjection.redactedClaimTypes", "invalid", "Redacted claim ty
 37497    }
 498
 499    private async ValueTask ValidatePolicyAsync(IdentityProviderConnection connection, ClaimsPrincipal actor, ExternalAu
 500    {
 37501        if (connection.UnlinkedPolicy is not { } policy)
 33502            return;
 4503        if (!configuredOptions.UnlinkedIdentityPolicy.AllowDatabaseConnectionOverride)
 0504            errors.Add(new ConnectionValidationError("unlinkedPolicy", "not_allowed", "This deployment does not allow da
 4505        else if (policy.SettingsVersion <= 0 || !policies.TryGet(policy.Type, out _) || !IsAllowed(configuredOptions.All
 0506            errors.Add(new ConnectionValidationError("unlinkedPolicy", "unavailable", "The selected unlinked identity po
 507        else
 508        {
 4509            if (UsesCreateUserFallback(policy) &&
 4510                !await roleAuthorizationService.CanAssignRolesAsync(actor, Policies.CreateUserUnlinkedIdentityPolicy.Rea
 1511                errors.Add(new ConnectionValidationError("unlinkedPolicy.defaultRoleIds", "forbidden", "The selected def
 512
 3513            if (string.Equals(policy.Type, Policies.MatchExternalUserUnlinkedIdentityPolicy.PolicyType, StringComparison
 3514                (!TryGetMatcherSelection(policy.Settings, out var matcherType, out var matcherSettingsVersion) ||
 3515                 !IsAllowed(configuredOptions.AllowedExternalUserMatcherTypes, matcherType) ||
 3516                 !matchers.TryGet(matcherType, out _) ||
 5517                 matchers.ListDescriptors().All(x => !string.Equals(x.Type, matcherType, StringComparison.Ordinal) || x.
 1518                errors.Add(new ConnectionValidationError("unlinkedPolicy.matcher", "unavailable", "The selected external
 3519        }
 36520    }
 521
 522    private static bool UsesCreateUserFallback(PolicySelection policy) =>
 4523        string.Equals(policy.Type, Policies.CreateUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal) ||
 4524        string.Equals(policy.Type, Policies.MatchExternalUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal
 4525        string.Equals(ReadString(policy.Settings, "noMatchAction"), "create-user", StringComparison.OrdinalIgnoreCase);
 526
 527    private static bool TryGetMatcherSelection(JsonElement settings, out string matcherType, out int settingsVersion)
 528    {
 3529        matcherType = string.Empty;
 3530        settingsVersion = 0;
 3531        if (settings.ValueKind != JsonValueKind.Object || !settings.TryGetProperty("matcher", out var matcher) || matche
 0532            return false;
 533
 3534        matcherType = type.GetString() ?? string.Empty;
 3535        return !string.IsNullOrWhiteSpace(matcherType) && matcher.TryGetProperty("settingsVersion", out var version) && 
 536    }
 537
 538    private static string? ReadString(JsonElement settings, string propertyName) =>
 4539        settings.ValueKind == JsonValueKind.Object &&
 4540        settings.TryGetProperty(propertyName, out var value) &&
 4541        value.ValueKind == JsonValueKind.String
 4542            ? value.GetString()
 4543            : null;
 544
 545    private void ValidateGrantSources(IdentityProviderConnection connection, ExternalAuthenticationOptions configuredOpt
 546    {
 36547        var orders = new HashSet<int>();
 72548        foreach (var source in connection.PermissionGrantSources)
 549        {
 0550            if (source.SettingsVersion <= 0 || !grantSources.TryGet(source.Type, out _) || !IsAllowed(configuredOptions.
 0551                errors.Add(new ConnectionValidationError("permissionGrantSources", "unavailable", "A selected permission
 0552            if (!orders.Add(source.Order))
 0553                errors.Add(new ConnectionValidationError("permissionGrantSources", "duplicate_order", "Permission grant 
 554        }
 36555    }
 556
 557    private static void ValidateSecretBindingFields(IdentityProviderConnection connection, ExternalAuthenticationAdapter
 558    {
 120559        var secretFields = descriptor.Fields.Where(x => x.IsSecretBinding).ToDictionary(x => x.Name, StringComparer.Ordi
 72560        foreach (var name in connection.SecretBindings.Keys)
 6561            if (!secretFields.ContainsKey(name))
 0562                errors.Add(new ConnectionValidationError($"secretBindings.{name}", "undeclared", "The adapter does not d
 30563        if (!requireCompleteConfiguration)
 24564            return;
 20565        foreach (var field in secretFields.Values.Where(x => x.IsRequired))
 1566            if (!connection.SecretBindings.ContainsKey(field.Name))
 1567                errors.Add(new ConnectionValidationError($"secretBindings.{field.Name}", "required", "A required secret 
 6568    }
 569
 570    private static void ValidateSecretBindingsAreNotAdapterSettings(JsonElement settings, ExternalAuthenticationAdapterD
 571    {
 30572        if (settings.ValueKind != JsonValueKind.Object)
 0573            return;
 574
 180575        foreach (var field in descriptor.Fields.Where(x => x.IsSecretBinding))
 30576            if (settings.TryGetProperty(field.Name, out _))
 1577                errors.Add(new ConnectionValidationError($"adapterSettings.{field.Name}", "secret_binding_required", "Se
 30578    }
 579
 580    private static bool UsesUnsafeSettings(JsonElement settings, ExternalAuthenticationAdapterDescriptor descriptor)
 581    {
 14582        if (settings.ValueKind != JsonValueKind.Object)
 0583            return false;
 42584        return descriptor.Fields.Where(x => x.IsUnsafe).Any(field =>
 28585            settings.TryGetProperty(field.Name, out var value) && IsUnsafeFieldValue(field, value));
 586    }
 587
 588    private static bool UnsafeSettingsChanged(JsonElement beforeSettings, JsonElement afterSettings, ExternalAuthenticat
 589    {
 13590        if (afterSettings.ValueKind != JsonValueKind.Object)
 0591            return false;
 592
 78593        foreach (var field in descriptor.Fields.Where(x => x.IsUnsafe))
 594        {
 13595            if (!afterSettings.TryGetProperty(field.Name, out var afterValue) || !IsUnsafeFieldValue(field, afterValue))
 596                continue;
 2597            if (beforeSettings.ValueKind != JsonValueKind.Object || !beforeSettings.TryGetProperty(field.Name, out var b
 0598                return true;
 599        }
 600
 13601        return false;
 0602    }
 603
 604    private static bool IsUnsafeFieldValue(SettingFieldDescriptor field, JsonElement value) =>
 3605        value.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined and not JsonValueKind.False &&
 3606        (!string.Equals(field.Name, "providerPkce", StringComparison.Ordinal) || value.ValueKind != JsonValueKind.String
 607
 608    private static bool JsonValueEquals(JsonElement left, JsonElement right) =>
 2609        left.ValueKind == right.ValueKind && string.Equals(left.GetRawText(), right.GetRawText(), StringComparison.Ordin
 610
 5611    private static EffectiveIdentityProviderConnection ToEffective(IdentityProviderConnection connection) => new(connect
 5612    private static ConnectionScope ToScope(string tenantId) => tenantId == ConnectionScope.HostTenantId ? ConnectionScop
 52613    private static bool CanMutate(string connectionTenantId, string targetTenantId) => connectionTenantId == ConnectionS
 37614    private static bool IsValidScope(string tenantId) => tenantId == ConnectionScope.HostTenantId;
 0615    private static string NormalizeScopeTenantId(string requestedTenantId, string fallback) => requestedTenantId is null
 44616    private static bool IsAllowed(ICollection<string> allowedTypes, string type) => allowedTypes.Count == 0 || allowedTy
 2617    private static bool HasPermission(ClaimsPrincipal actor, string permission) => actor.FindAll(PermissionNames.ClaimTy
 30618    private static ConnectionLifecycle GetLifecycle(IdentityProviderConnection connection) => connection.ArchivedAt.HasV
 619    private static bool Matches(EffectiveIdentityProviderConnection connection, ConnectionFilter filter) =>
 6620        (filter.Ownership is null || filter.Ownership == connection.Ownership) &&
 6621        (filter.Scope is null || filter.Scope == connection.Scope) &&
 6622        (string.IsNullOrWhiteSpace(filter.Search) || connection.Connection.Key.Contains(filter.Search, StringComparison.
 6623        (string.IsNullOrWhiteSpace(filter.AdapterType) || string.Equals(filter.AdapterType, connection.Connection.Adapte
 6624        (!filter.IsEnabled.HasValue || filter.IsEnabled.Value == connection.Connection.IsEnabled) &&
 6625        (!filter.IsArchived.HasValue || filter.IsArchived.Value == connection.Connection.ArchivedAt.HasValue);
 626
 627    [System.Text.RegularExpressions.GeneratedRegex("^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$")]
 628    private static partial System.Text.RegularExpressions.Regex ConnectionKeyPattern();
 629}
 630
 631public abstract record ManagementConnectionLookupResult
 632{
 633    private ManagementConnectionLookupResult() { }
 634    public sealed record Found(EffectiveIdentityProviderConnection Connection) : ManagementConnectionLookupResult;
 635    public sealed record NotFound : ManagementConnectionLookupResult;
 636}
 637
 638public abstract record ManagementConnectionMutationResult
 639{
 640    private ManagementConnectionMutationResult() { }
 641    public sealed record Success(IdentityProviderConnection Connection) : ManagementConnectionMutationResult;
 642    public sealed record NotFound : ManagementConnectionMutationResult;
 643    public sealed record Conflict(string Code) : ManagementConnectionMutationResult;
 644    public sealed record PreconditionFailed(long CurrentRevision) : ManagementConnectionMutationResult;
 645    public sealed record Forbidden : ManagementConnectionMutationResult;
 646    public sealed record ValidationFailed(ConnectionValidationResult Validation) : ManagementConnectionMutationResult;
 647}

Methods/Properties

ConnectionKeyPattern()
.ctor(Elsa.ExternalAuthentication.Contracts.IIdentityProviderConnectionStore,Elsa.ExternalAuthentication.Contracts.IIdentityProviderConnectionRegistry,Elsa.ExternalAuthentication.Contracts.IIdentityProviderConnectionValidityAssessor,Elsa.ExternalAuthentication.Contracts.IConnectionRegistryVersionStore,Elsa.ExternalAuthentication.Contracts.IExternalAuthenticationAdapterRegistry,Elsa.ExternalAuthentication.Contracts.IAdapterSettingsMigrationService,Elsa.ExternalAuthentication.Contracts.IUnlinkedIdentityPolicyRegistry,Elsa.ExternalAuthentication.Contracts.IExternalUserMatcherRegistry,Elsa.ExternalAuthentication.Contracts.IPermissionGrantSourceRegistry,System.Collections.Generic.IEnumerable`1<Elsa.ExternalAuthentication.Contracts.ISecretBindingResolver>,System.Collections.Generic.IEnumerable`1<Elsa.ExternalAuthentication.Contracts.IManagedSecretBindingWriter>,Elsa.ExternalAuthentication.Contracts.IPermissionDelegationAuthorizer,Elsa.ExternalAuthentication.Services.ConnectionRevisionCalculator,Elsa.Common.ISystemClock,Microsoft.Extensions.Options.IOptions`1<Elsa.ExternalAuthentication.Options.ExternalAuthenticationOptions>,Elsa.Identity.Contracts.IRoleAuthorizationService,Elsa.ExternalAuthentication.Contracts.IExternalAuthenticationSessionStore,System.IServiceProvider,Microsoft.Extensions.Logging.ILogger`1<Elsa.ExternalAuthentication.Services.IdentityProviderConnectionManagementService>)
FindAsync()
GetProviderCallbackUri(Elsa.ExternalAuthentication.Models.IdentityProviderConnection)
GetProviderPreviewCallbackUri(Elsa.ExternalAuthentication.Models.IdentityProviderConnection)
ListAsync()
CreateAsync()
UpdateAsync()
ChangeLifecycleAsync()
ValidateAsync()
GetSecretBindingStatesAsync(Elsa.ExternalAuthentication.Models.IdentityProviderConnection,System.Threading.CancellationToken)
PresentSecretBinding(Elsa.ExternalAuthentication.Models.SecretBinding,Elsa.ExternalAuthentication.Models.SecretBindingState)
CanCreateConfigurationOverride()
CanPromoteToConfigurationOverride(Elsa.ExternalAuthentication.Contracts.EffectiveIdentityProviderConnection)
IsBlockedByFinalLoginPathGuardAsync()
ProcessMutationAsync()
RunPostCommitActionsAsync()
PublishBulkSessionRevocationAsync()
PublishAsync()
CollidesWithConfigurationOrHostAsync()
GetSecretStatesAsync()
ApplySettingsMigrationAsync()
NormalizeForCreate(Elsa.ExternalAuthentication.Models.IdentityProviderConnection,System.String)
NormalizeForUpdate(Elsa.ExternalAuthentication.Models.IdentityProviderConnection,Elsa.ExternalAuthentication.Models.IdentityProviderConnection)
ValidateEnvelope(Elsa.ExternalAuthentication.Models.IdentityProviderConnection,Elsa.ExternalAuthentication.Options.ExternalAuthenticationOptions,System.Collections.Generic.ICollection`1<Elsa.ExternalAuthentication.Models.ConnectionValidationError>)
ValidatePolicyAsync()
UsesCreateUserFallback(Elsa.ExternalAuthentication.Models.PolicySelection)
TryGetMatcherSelection(System.Text.Json.JsonElement,System.String&,System.Int32&)
ReadString(System.Text.Json.JsonElement,System.String)
ValidateGrantSources(Elsa.ExternalAuthentication.Models.IdentityProviderConnection,Elsa.ExternalAuthentication.Options.ExternalAuthenticationOptions,System.Collections.Generic.ICollection`1<Elsa.ExternalAuthentication.Models.ConnectionValidationError>)
ValidateSecretBindingFields(Elsa.ExternalAuthentication.Models.IdentityProviderConnection,Elsa.ExternalAuthentication.Models.ExternalAuthenticationAdapterDescriptor,System.Boolean,System.Collections.Generic.ICollection`1<Elsa.ExternalAuthentication.Models.ConnectionValidationError>)
ValidateSecretBindingsAreNotAdapterSettings(System.Text.Json.JsonElement,Elsa.ExternalAuthentication.Models.ExternalAuthenticationAdapterDescriptor,System.Collections.Generic.ICollection`1<Elsa.ExternalAuthentication.Models.ConnectionValidationError>)
UsesUnsafeSettings(System.Text.Json.JsonElement,Elsa.ExternalAuthentication.Models.ExternalAuthenticationAdapterDescriptor)
UnsafeSettingsChanged(System.Text.Json.JsonElement,System.Text.Json.JsonElement,Elsa.ExternalAuthentication.Models.ExternalAuthenticationAdapterDescriptor)
IsUnsafeFieldValue(Elsa.ExternalAuthentication.Models.SettingFieldDescriptor,System.Text.Json.JsonElement)
JsonValueEquals(System.Text.Json.JsonElement,System.Text.Json.JsonElement)
ToEffective(Elsa.ExternalAuthentication.Models.IdentityProviderConnection)
ToScope(System.String)
CanMutate(System.String,System.String)
IsValidScope(System.String)
NormalizeScopeTenantId(System.String,System.String)
IsAllowed(System.Collections.Generic.ICollection`1<System.String>,System.String)
HasPermission(System.Security.Claims.ClaimsPrincipal,System.String)
GetLifecycle(Elsa.ExternalAuthentication.Models.IdentityProviderConnection)
Matches(Elsa.ExternalAuthentication.Contracts.EffectiveIdentityProviderConnection,Elsa.ExternalAuthentication.Models.ConnectionFilter)