< Summary

Information
Class: Elsa.ExternalAuthentication.Services.ManagementConnectionLookupResult
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/IdentityProviderConnectionManagementService.cs
Line coverage
100%
Covered lines: 2
Uncovered lines: 0
Coverable lines: 2
Total lines: 647
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
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_Connection()100%11100%

File(s)

/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>
 22public sealed partial class IdentityProviderConnectionManagementService(
 23    IIdentityProviderConnectionStore store,
 24    IIdentityProviderConnectionRegistry registry,
 25    IIdentityProviderConnectionValidityAssessor validityAssessor,
 26    IConnectionRegistryVersionStore registryVersions,
 27    IExternalAuthenticationAdapterRegistry adapters,
 28    IAdapterSettingsMigrationService settingsMigrations,
 29    IUnlinkedIdentityPolicyRegistry policies,
 30    IExternalUserMatcherRegistry matchers,
 31    IPermissionGrantSourceRegistry grantSources,
 32    IEnumerable<ISecretBindingResolver> secretBindingResolvers,
 33    IEnumerable<IManagedSecretBindingWriter> managedSecretBindingWriters,
 34    IPermissionDelegationAuthorizer delegationAuthorizer,
 35    ConnectionRevisionCalculator revisionCalculator,
 36    ISystemClock clock,
 37    IOptions<ExternalAuthenticationOptions> options,
 38    Elsa.Identity.Contracts.IRoleAuthorizationService roleAuthorizationService,
 39    IExternalAuthenticationSessionStore sessions,
 40    IServiceProvider services,
 41    ILogger<IdentityProviderConnectionManagementService> logger)
 42{
 43    private readonly IReadOnlyDictionary<string, ISecretBindingResolver> _secretBindingResolvers = secretBindingResolver
 44    private readonly IReadOnlyDictionary<string, IManagedSecretBindingWriter> _managedSecretBindingWriters = managedSecr
 45
 46    public async ValueTask<ManagementConnectionLookupResult> FindAsync(string id, string targetTenantId, CancellationTok
 47    {
 48        var effective = await registry.FindByIdAsync(targetTenantId, id, cancellationToken);
 49        if (effective is not null && effective.Scope == ConnectionScope.Host)
 50            return new ManagementConnectionLookupResult.Found(await validityAssessor.AssessAsync(effective, cancellation
 51
 52        var connection = await store.FindByIdAsync(id, cancellationToken);
 53        if (connection is null || connection.TenantId != ConnectionScope.HostTenantId)
 54            return new ManagementConnectionLookupResult.NotFound();
 55
 56        return new ManagementConnectionLookupResult.Found(await validityAssessor.AssessAsync(ToEffective(connection), ca
 57    }
 58
 59    /// <summary>Returns the deployment-derived read-only upstream callback URI for management display.</summary>
 60    public Uri? GetProviderCallbackUri(IdentityProviderConnection connection) => options.Value.Redirects.ExternalCallbac
 61        ? ExternalAuthenticationCallbackUris.GetAuthorizationCallbackUri(baseUri, connection, BrokerTransactionPurpose.E
 62        : null;
 63
 64    /// <summary>Returns the deployment-derived read-only callback URI used by provider preview sign-ins.</summary>
 65    public Uri? GetProviderPreviewCallbackUri(IdentityProviderConnection connection) => options.Value.Redirects.External
 66        ? ExternalAuthenticationCallbackUris.GetAuthorizationCallbackUri(baseUri, connection, BrokerTransactionPurpose.P
 67        : null;
 68
 69    public async ValueTask<IReadOnlyCollection<EffectiveIdentityProviderConnection>> ListAsync(string targetTenantId, Co
 70    {
 71        var effective = await registry.GetAsync(targetTenantId, cancellationToken);
 72        var matches = effective.Connections
 73            .Where(x => x.Scope == ConnectionScope.Host)
 74            .Where(x => Matches(x, filter))
 75            .OrderBy(x => x.Scope.Kind)
 76            .ThenBy(x => x.Connection.DisplayOrder)
 77            .ThenBy(x => x.Connection.Key, StringComparer.Ordinal)
 78            .ThenBy(x => x.Connection.Id, StringComparer.Ordinal)
 79            .ToArray();
 80        return await Task.WhenAll(matches.Select(x => validityAssessor.AssessAsync(x, cancellationToken).AsTask()));
 81    }
 82
 83    public async ValueTask<ManagementConnectionMutationResult> CreateAsync(IdentityProviderConnection connection, Claims
 84    {
 85        NormalizeForCreate(connection, targetTenantId);
 86        if (!CanMutate(connection.TenantId, targetTenantId))
 87            return new ManagementConnectionMutationResult.Forbidden();
 88        var validation = await ValidateAsync(connection, actor, targetTenantId, requireCompleteConfiguration: false, con
 89        if (!validation.IsValid)
 90            return new ManagementConnectionMutationResult.ValidationFailed(validation);
 91        if (await CollidesWithConfigurationOrHostAsync(connection, null, targetTenantId, cancellationToken))
 92            return new ManagementConnectionMutationResult.Conflict("connection_key_conflict");
 93
 94        connection.MaterialRevision = revisionCalculator.CalculateMaterialRevision(connection);
 95        var result = await store.CreateAsync(connection, cancellationToken);
 96        return await ProcessMutationAsync(result, actor, "created", null, cancellationToken);
 97    }
 98
 99    public async ValueTask<ManagementConnectionMutationResult> UpdateAsync(string id, IdentityProviderConnection candida
 100    {
 101        var existing = await store.FindByIdAsync(id, cancellationToken);
 102        if (existing is null || existing.TenantId != ConnectionScope.HostTenantId)
 103            return new ManagementConnectionMutationResult.NotFound();
 104        if (!CanMutate(existing.TenantId, targetTenantId))
 105            return new ManagementConnectionMutationResult.Forbidden();
 106
 107        candidate.Id = existing.Id;
 108        candidate.CreatedAt = existing.CreatedAt;
 109        candidate.Revision = existing.Revision;
 110        candidate.ArchivedAt = existing.ArchivedAt;
 111        NormalizeForUpdate(candidate, existing);
 112        if (!CanMutate(candidate.TenantId, targetTenantId))
 113            return new ManagementConnectionMutationResult.Forbidden();
 114        if (!string.Equals(existing.Key, candidate.Key, StringComparison.Ordinal))
 115            return new ManagementConnectionMutationResult.Conflict("connection_key_immutable");
 116        var requireUnsafeConfirmation = adapters.TryGet(candidate.AdapterType, out var candidateAdapter) &&
 117            UnsafeSettingsChanged(existing.AdapterSettings, candidate.AdapterSettings, candidateAdapter.Describe());
 118        var validation = await ValidateAsync(candidate, actor, targetTenantId, requireCompleteConfiguration: candidate.I
 119        if (!validation.IsValid)
 120            return new ManagementConnectionMutationResult.ValidationFailed(validation);
 121
 122        if (await IsBlockedByFinalLoginPathGuardAsync(existing, candidate, targetTenantId, actor, confirmFinalLoginPathO
 123            return new ManagementConnectionMutationResult.Conflict("final_login_path_guard");
 124
 125        candidate.MaterialRevision = revisionCalculator.CalculateMaterialRevision(candidate);
 126        var result = await store.UpdateAsync(candidate, expectedRevision, cancellationToken);
 127        return await ProcessMutationAsync(result, actor, "updated", GetLifecycle(existing), cancellationToken, existing)
 128    }
 129
 130    public async ValueTask<ManagementConnectionMutationResult> ChangeLifecycleAsync(string id, ConnectionLifecycle actio
 131    {
 132        var existing = await store.FindByIdAsync(id, cancellationToken);
 133        if (existing is null || existing.TenantId != ConnectionScope.HostTenantId)
 134            return new ManagementConnectionMutationResult.NotFound();
 135        if (!CanMutate(existing.TenantId, targetTenantId))
 136            return new ManagementConnectionMutationResult.Forbidden();
 137
 138        var previousLifecycle = GetLifecycle(existing);
 139        var candidate = IdentityProviderConnectionCloner.Clone(existing);
 140        switch (action)
 141        {
 142            case ConnectionLifecycle.Enabled:
 143            {
 144                var validation = await ValidateAsync(candidate, actor, targetTenantId, requireCompleteConfiguration: tru
 145                if (!validation.IsValid)
 146                    return new ManagementConnectionMutationResult.ValidationFailed(validation);
 147                if (candidate.ArchivedAt.HasValue)
 148                    return new ManagementConnectionMutationResult.Conflict("connection_archived");
 149                if (candidate.IsPreferred)
 150                {
 151                    var current = await registry.GetAsync(targetTenantId, cancellationToken);
 152                    if (current.Connections.Any(x =>
 153                            x.Ownership == ConnectionSourceOwnership.Configuration &&
 154                            !x.IsShadowed &&
 155                            x.Connection.IsEnabled &&
 156                            !x.Connection.ArchivedAt.HasValue &&
 157                            x.Connection.IsPreferred &&
 158                            x.Validity != ConnectionValidity.Invalid &&
 159                            (!candidate.OverridesConfigurationConnection || !string.Equals(x.Connection.Key, candidate.K
 160                        return new ManagementConnectionMutationResult.Conflict("configuration_preferred_connection");
 161                    if (current.Connections.Any(x => x.Ownership == ConnectionSourceOwnership.Database && x.Connection.I
 162                        return new ManagementConnectionMutationResult.Conflict("default_connection_conflict");
 163                }
 164                candidate.IsEnabled = true;
 165                break;
 166            }
 167            case ConnectionLifecycle.Disabled:
 168                if (candidate.ArchivedAt.HasValue)
 169                    return new ManagementConnectionMutationResult.Conflict("connection_archived");
 170                candidate.IsEnabled = false;
 171                break;
 172            case ConnectionLifecycle.Archived:
 173                candidate.IsEnabled = false;
 174                candidate.ArchivedAt = clock.UtcNow;
 175                break;
 176            case ConnectionLifecycle.Draft:
 177                if (!candidate.ArchivedAt.HasValue)
 178                    return new ManagementConnectionMutationResult.Conflict("connection_not_archived");
 179                candidate.ArchivedAt = null;
 180                candidate.IsEnabled = false;
 181                break;
 182            default:
 183                return new ManagementConnectionMutationResult.Conflict("invalid_lifecycle_action");
 184        }
 185
 186        candidate.UpdatedAt = clock.UtcNow;
 187        candidate.MaterialRevision = revisionCalculator.CalculateMaterialRevision(candidate);
 188        if (await IsBlockedByFinalLoginPathGuardAsync(existing, candidate, targetTenantId, actor, confirmFinalLoginPathO
 189            return new ManagementConnectionMutationResult.Conflict("final_login_path_guard");
 190        var result = await store.UpdateAsync(candidate, expectedRevision, cancellationToken);
 191        var processed = await ProcessMutationAsync(result, actor, action.ToString().ToLowerInvariant(), previousLifecycl
 192        if (processed is ManagementConnectionMutationResult.Success && action == ConnectionLifecycle.Disabled && revokeA
 193        {
 194            var connectionKey = ConnectionRevisionCalculator.NormalizeKey(candidate.Key);
 195            var revokedCount = await sessions.RevokeActiveForConnectionAsync(connectionKey, "connection_disabled", clock
 196            await PublishBulkSessionRevocationAsync(candidate, actor, revokedCount);
 197        }
 198        return processed;
 199    }
 200
 201    public async ValueTask<ConnectionValidationResult> ValidateAsync(IdentityProviderConnection connection, ClaimsPrinci
 202    {
 203        var errors = new List<ConnectionValidationError>();
 204        var warnings = new List<string>();
 205        var configuredOptions = options.Value;
 206        ValidateEnvelope(connection, configuredOptions, errors);
 207
 208        if (!adapters.TryGet(connection.AdapterType, out var adapter) || !IsAllowed(configuredOptions.AllowedAdapterType
 209            errors.Add(new ConnectionValidationError("adapterType", "unavailable", "The selected adapter is not installe
 210
 211        await ValidatePolicyAsync(connection, actor, configuredOptions, errors, cancellationToken);
 212        ValidateGrantSources(connection, configuredOptions, errors);
 213        if (connection.PermissionGrantSources.Count != 0)
 214        {
 215            var delegation = await delegationAuthorizer.AuthorizeAsync(actor, connection.PermissionGrantSources.ToArray(
 216            if (!delegation.IsAuthorized)
 217                errors.Add(new ConnectionValidationError("permissionGrantSources", "delegation_denied", "The caller may 
 218        }
 219
 220        if (adapter is null)
 221            return new ConnectionValidationResult(false, errors, warnings);
 222
 223        await ApplySettingsMigrationAsync(connection, errors, cancellationToken);
 224        if (errors.Count != 0)
 225            return new ConnectionValidationResult(false, errors, warnings);
 226
 227        var descriptor = adapter.Describe();
 228        ValidateSecretBindingFields(connection, descriptor, requireCompleteConfiguration, errors);
 229        ValidateSecretBindingsAreNotAdapterSettings(connection.AdapterSettings, descriptor, errors);
 230        if (requireUnsafeConfirmation && UsesUnsafeSettings(connection.AdapterSettings, descriptor) && (!confirmUnsafeSe
 231            errors.Add(new ConnectionValidationError("adapterSettings", "unsafe_confirmation_required", "Unsafe provider
 232
 233        if (requireCompleteConfiguration)
 234        {
 235            var secretStates = await GetSecretStatesAsync(connection, cancellationToken);
 236            foreach (var (name, state) in secretStates)
 237            {
 238                if (!state.IsConfigured)
 239                    errors.Add(new ConnectionValidationError($"secretBindings.{name}", "required", "A required secret bi
 240                else if (!state.IsResolvable)
 241                    errors.Add(new ConnectionValidationError($"secretBindings.{name}", "unresolvable", "The secret bindi
 242            }
 243        }
 244
 245        if (errors.Count != 0 || allowIncompleteDraft)
 246            return new ConnectionValidationResult(errors.Count == 0, errors, warnings);
 247
 248        var effective = ToEffective(connection);
 249        var adapterValidation = await adapter.ValidateAsync(new ConnectionValidationContext(effective, new Dictionary<st
 250        errors.AddRange(adapterValidation.Errors);
 251        warnings.AddRange(adapterValidation.Warnings);
 252        return new ConnectionValidationResult(errors.Count == 0 && adapterValidation.IsValid, errors, warnings);
 253    }
 254
 255    public ValueTask<IReadOnlyDictionary<string, SecretBindingState>> GetSecretBindingStatesAsync(IdentityProviderConnec
 256
 257    public SecretBindingPresentation PresentSecretBinding(SecretBinding binding, SecretBindingState? state) => new(
 258        binding.Ownership == SecretBindingOwnership.Managed ? "managed" : "external",
 259        state?.IsConfigured ?? false,
 260        state?.IsResolvable ?? false);
 261
 262    public bool CanCreateConfigurationOverride() => options.Value.AllowConfigurationConnectionOverrides;
 263
 264    public bool CanPromoteToConfigurationOverride(EffectiveIdentityProviderConnection connection) =>
 265        connection.Ownership == ConnectionSourceOwnership.Database &&
 266        connection.IsShadowed &&
 267        !connection.Connection.ArchivedAt.HasValue &&
 268        options.Value.AllowConfigurationConnectionOverrides;
 269
 270    private async ValueTask<bool> IsBlockedByFinalLoginPathGuardAsync(IdentityProviderConnection existing, IdentityProvi
 271    {
 272        var guard = services.GetService<FinalLoginPathGuard>();
 273        if (guard is null)
 274            return false;
 275
 276        var guardExisting = existing;
 277        if (!existing.OverridesConfigurationConnection && candidate.OverridesConfigurationConnection)
 278        {
 279            var normalizedKey = ConnectionRevisionCalculator.NormalizeKey(candidate.Key);
 280            var effective = await registry.GetAsync(targetTenantId, cancellationToken);
 281            var displacedConfigurationConnection = effective.Connections.FirstOrDefault(x =>
 282                x.Ownership == ConnectionSourceOwnership.Configuration &&
 283                !x.IsShadowed &&
 284                string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), normalizedKey, StringComparis
 285            if (displacedConfigurationConnection is not null)
 286                guardExisting = displacedConfigurationConnection.Connection;
 287        }
 288
 289        return await guard.AuthorizeAsync(guardExisting, candidate, targetTenantId, actor, confirmedOverride, cancellati
 290    }
 291
 292    private async ValueTask<ManagementConnectionMutationResult> ProcessMutationAsync(ConnectionMutationResult result, Cl
 293    {
 294        switch (result)
 295        {
 296            case ConnectionMutationResult.Created(var createdConnection):
 297                await RunPostCommitActionsAsync(createdConnection, actor, operation, previousLifecycle, previousConnecti
 298                return new ManagementConnectionMutationResult.Success(createdConnection);
 299            case ConnectionMutationResult.Updated(var updatedConnection):
 300                await RunPostCommitActionsAsync(updatedConnection, actor, operation, previousLifecycle, previousConnecti
 301                return new ManagementConnectionMutationResult.Success(updatedConnection);
 302            case ConnectionMutationResult.NotFound:
 303                return new ManagementConnectionMutationResult.NotFound();
 304            case ConnectionMutationResult.DuplicateKey:
 305                return new ManagementConnectionMutationResult.Conflict("connection_key_conflict");
 306            case ConnectionMutationResult.RevisionConflict(var currentRevision):
 307                return new ManagementConnectionMutationResult.PreconditionFailed(currentRevision);
 308            default:
 309                throw new InvalidOperationException("The connection store returned an unknown mutation result.");
 310        }
 311    }
 312
 313    private async ValueTask RunPostCommitActionsAsync(IdentityProviderConnection connection, ClaimsPrincipal actor, stri
 314    {
 315        try
 316        {
 317            await registryVersions.AdvanceAsync(CancellationToken.None);
 318        }
 319        catch (Exception exception)
 320        {
 321            logger.LogCritical(exception, "Connection {ConnectionId} was committed, but advancing the external-authentic
 322        }
 323
 324        try
 325        {
 326            await PublishAsync(connection, actor, operation, previousLifecycle, previousConnection, CancellationToken.No
 327        }
 328        catch (Exception exception)
 329        {
 330            logger.LogError(exception, "Connection {ConnectionId} was committed, but publishing external-authentication 
 331        }
 332    }
 333
 334    private async ValueTask PublishBulkSessionRevocationAsync(IdentityProviderConnection connection, ClaimsPrincipal act
 335    {
 336        if (revokedCount == 0)
 337            return;
 338
 339        var notificationSender = services.GetService<INotificationSender>();
 340        if (notificationSender is null)
 341            return;
 342
 343        try
 344        {
 345            var context = new SecurityEventContext(
 346                actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 347                connection.TenantId,
 348                connection.Id,
 349                null,
 350                clock.UtcNow,
 351                SecurityEventOutcome.Succeeded,
 352                Guid.NewGuid().ToString("N"),
 353                "Active external authentication sessions were revoked when the connection was disabled.");
 354            await notificationSender.SendAsync(new ExternalAuthenticationConnectionSessionsRevoked(context, revokedCount
 355        }
 356        catch (Exception exception)
 357        {
 358            logger.LogError(exception, "Connection {ConnectionId} sessions were revoked, but publishing the aggregate se
 359        }
 360    }
 361
 362    private async ValueTask PublishAsync(IdentityProviderConnection connection, ClaimsPrincipal actor, string operation,
 363    {
 364        var notificationSender = services.GetService<INotificationSender>();
 365        if (notificationSender is null)
 366            return;
 367
 368        var context = new SecurityEventContext(
 369            actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 370            connection.TenantId,
 371            connection.Id,
 372            null,
 373            clock.UtcNow,
 374            SecurityEventOutcome.Succeeded,
 375            Guid.NewGuid().ToString("N"),
 376            "Identity provider connection management operation completed.");
 377        await notificationSender.SendAsync(new IdentityProviderConnectionChanged(context, operation, connection.Revision
 378        if (previousLifecycle is { } previous && previous != GetLifecycle(connection))
 379            await notificationSender.SendAsync(new IdentityProviderConnectionLifecycleChanged(context, previous.ToString
 380        if (previousConnection is not null)
 381        {
 382            var fields = previousConnection.SecretBindings.Keys
 383                .Concat(connection.SecretBindings.Keys)
 384                .Distinct(StringComparer.Ordinal)
 385                .Where(field => !previousConnection.SecretBindings.TryGetValue(field, out var before) || !connection.Sec
 386            foreach (var field in fields)
 387            {
 388                previousConnection.SecretBindings.TryGetValue(field, out var previousBinding);
 389                connection.SecretBindings.TryGetValue(field, out var binding);
 390                var isConfigured = binding is not null && _secretBindingResolvers.TryGetValue(binding.ResolverType, out 
 391                await notificationSender.SendAsync(new IdentityProviderConnectionSecretBindingChanged(context, field, bi
 392            }
 393        }
 394    }
 395
 396    private async ValueTask<bool> CollidesWithConfigurationOrHostAsync(IdentityProviderConnection candidate, string? sel
 397    {
 398        var lookupTenant = candidate.TenantId == ConnectionScope.HostTenantId ? ConnectionScope.HostTenantId : candidate
 399        var effective = await registry.GetAsync(lookupTenant, cancellationToken);
 400        var key = ConnectionRevisionCalculator.NormalizeKey(candidate.Key);
 401        if (effective.Connections.Any(x => x.Ownership == ConnectionSourceOwnership.Configuration && x.Scope.TenantId ==
 402            return !candidate.OverridesConfigurationConnection;
 403
 404        if (candidate.TenantId != ConnectionScope.HostTenantId)
 405            return effective.Connections.Any(x =>
 406            !string.Equals(x.Connection.Id, selfId, StringComparison.Ordinal) &&
 407            x.Scope.Kind == ConnectionScopeKind.Host &&
 408            string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), key, StringComparison.Ordinal));
 409
 410        var rows = await store.FindAsync(new ConnectionFilter(), cancellationToken);
 411        if (rows.Items.Any(x =>
 412                !string.Equals(x.Id, selfId, StringComparison.Ordinal) &&
 413                x.TenantId != ConnectionScope.HostTenantId &&
 414                string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Key), key, StringComparison.Ordinal)))
 415            return true;
 416
 417        return options.Value.ConfigurationConnections.Any(x =>
 418            x.TenantId != ConnectionScope.HostTenantId &&
 419            string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Key), key, StringComparison.Ordinal));
 420    }
 421
 422    private async ValueTask<IReadOnlyDictionary<string, SecretBindingState>> GetSecretStatesAsync(IdentityProviderConnec
 423    {
 424        var states = new Dictionary<string, SecretBindingState>(StringComparer.Ordinal);
 425        foreach (var (name, binding) in connection.SecretBindings)
 426        {
 427            if (!_secretBindingResolvers.TryGetValue(binding.ResolverType, out var resolver))
 428                states[name] = new SecretBindingState(false, false);
 429            else
 430                states[name] = await resolver.GetStateAsync(binding, cancellationToken);
 431        }
 432
 433        return states;
 434    }
 435
 436    private async ValueTask ApplySettingsMigrationAsync(IdentityProviderConnection connection, ICollection<ConnectionVal
 437    {
 438        try
 439        {
 440            var migration = await settingsMigrations.MigrateAsync(connection.AdapterType, connection.AdapterSettingsVers
 441            connection.AdapterSettingsVersion = migration.SettingsVersion;
 442            connection.AdapterSettings = migration.Settings;
 443        }
 444        catch (InvalidOperationException)
 445        {
 446            errors.Add(new ConnectionValidationError("adapterSettingsVersion", "migration_unavailable", "The adapter set
 447        }
 448    }
 449
 450    private void NormalizeForCreate(IdentityProviderConnection connection, string targetTenantId)
 451    {
 452        connection.Id = string.IsNullOrWhiteSpace(connection.Id) ? Guid.NewGuid().ToString("N") : connection.Id;
 453        connection.Key = connection.Key?.Trim() ?? string.Empty;
 454        connection.TenantId = ConnectionScope.HostTenantId;
 455        connection.IsEnabled = false;
 456        connection.ArchivedAt = null;
 457        connection.Revision = 1;
 458        connection.CreatedAt = clock.UtcNow;
 459        connection.UpdatedAt = clock.UtcNow;
 460        connection.MaterialRevision = revisionCalculator.CalculateMaterialRevision(connection);
 461    }
 462
 463    private void NormalizeForUpdate(IdentityProviderConnection candidate, IdentityProviderConnection existing)
 464    {
 465        candidate.Key = candidate.Key?.Trim() ?? string.Empty;
 466        candidate.TenantId = ConnectionScope.HostTenantId;
 467        candidate.IsEnabled = existing.IsEnabled;
 468        candidate.UpdatedAt = clock.UtcNow;
 469        candidate.SecretBindings ??= new Dictionary<string, SecretBinding>(StringComparer.Ordinal);
 470        candidate.PermissionGrantSources ??= [];
 471        candidate.ClaimProjection ??= ClaimProjection.Empty;
 472    }
 473
 474    private static void ValidateEnvelope(IdentityProviderConnection connection, ExternalAuthenticationOptions configured
 475    {
 476        if (!configuredOptions.EnableDatabaseConnections)
 477            errors.Add(new ConnectionValidationError("source", "disabled", "Database-owned connections are disabled by d
 478        if (connection.OverridesConfigurationConnection && !configuredOptions.AllowConfigurationConnectionOverrides)
 479            errors.Add(new ConnectionValidationError("overridesConfigurationConnection", "not_allowed", "This deployment
 480        if (string.IsNullOrWhiteSpace(connection.Key) || connection.Key.Length > 128 || connection.Key.Any(char.IsWhiteS
 481            errors.Add(new ConnectionValidationError("key", "invalid", "Connection keys must be non-empty lowercase URL-
 482        else if (!ConnectionKeyPattern().IsMatch(connection.Key))
 483            errors.Add(new ConnectionValidationError("key", "invalid", "Connection keys must use lowercase letters, digi
 484        if (string.IsNullOrWhiteSpace(connection.DisplayName) || connection.DisplayName.Trim().Length > 256)
 485            errors.Add(new ConnectionValidationError("displayName", "invalid", "Display name is required and may not exc
 486        if (connection.AdapterSettingsVersion <= 0)
 487            errors.Add(new ConnectionValidationError("adapterSettingsVersion", "invalid", "Adapter settings version must
 488        if (!Enum.IsDefined(connection.UpstreamLogoutMode))
 489            errors.Add(new ConnectionValidationError("upstreamLogoutMode", "invalid", "Upstream logout mode is invalid."
 490        if (!IsValidScope(connection.TenantId))
 491            errors.Add(new ConnectionValidationError("scope", "host_scope_required", "Identity provider connections are 
 492        if (connection.ClaimProjection.MaximumClaimCount < 0 || connection.ClaimProjection.MaximumValueLength < 0 || con
 493            connection.ClaimProjection.MaximumClaimCount > configuredOptions.Claims.MaximumClaimCount || connection.Clai
 494            errors.Add(new ConnectionValidationError("claimProjection", "invalid", "Claim projection limits exceed deplo
 495        if (!connection.ClaimProjection.RedactedClaimTypes.IsSubsetOf(connection.ClaimProjection.AllowedClaimTypes))
 496            errors.Add(new ConnectionValidationError("claimProjection.redactedClaimTypes", "invalid", "Redacted claim ty
 497    }
 498
 499    private async ValueTask ValidatePolicyAsync(IdentityProviderConnection connection, ClaimsPrincipal actor, ExternalAu
 500    {
 501        if (connection.UnlinkedPolicy is not { } policy)
 502            return;
 503        if (!configuredOptions.UnlinkedIdentityPolicy.AllowDatabaseConnectionOverride)
 504            errors.Add(new ConnectionValidationError("unlinkedPolicy", "not_allowed", "This deployment does not allow da
 505        else if (policy.SettingsVersion <= 0 || !policies.TryGet(policy.Type, out _) || !IsAllowed(configuredOptions.All
 506            errors.Add(new ConnectionValidationError("unlinkedPolicy", "unavailable", "The selected unlinked identity po
 507        else
 508        {
 509            if (UsesCreateUserFallback(policy) &&
 510                !await roleAuthorizationService.CanAssignRolesAsync(actor, Policies.CreateUserUnlinkedIdentityPolicy.Rea
 511                errors.Add(new ConnectionValidationError("unlinkedPolicy.defaultRoleIds", "forbidden", "The selected def
 512
 513            if (string.Equals(policy.Type, Policies.MatchExternalUserUnlinkedIdentityPolicy.PolicyType, StringComparison
 514                (!TryGetMatcherSelection(policy.Settings, out var matcherType, out var matcherSettingsVersion) ||
 515                 !IsAllowed(configuredOptions.AllowedExternalUserMatcherTypes, matcherType) ||
 516                 !matchers.TryGet(matcherType, out _) ||
 517                 matchers.ListDescriptors().All(x => !string.Equals(x.Type, matcherType, StringComparison.Ordinal) || x.
 518                errors.Add(new ConnectionValidationError("unlinkedPolicy.matcher", "unavailable", "The selected external
 519        }
 520    }
 521
 522    private static bool UsesCreateUserFallback(PolicySelection policy) =>
 523        string.Equals(policy.Type, Policies.CreateUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal) ||
 524        string.Equals(policy.Type, Policies.MatchExternalUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal
 525        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    {
 529        matcherType = string.Empty;
 530        settingsVersion = 0;
 531        if (settings.ValueKind != JsonValueKind.Object || !settings.TryGetProperty("matcher", out var matcher) || matche
 532            return false;
 533
 534        matcherType = type.GetString() ?? string.Empty;
 535        return !string.IsNullOrWhiteSpace(matcherType) && matcher.TryGetProperty("settingsVersion", out var version) && 
 536    }
 537
 538    private static string? ReadString(JsonElement settings, string propertyName) =>
 539        settings.ValueKind == JsonValueKind.Object &&
 540        settings.TryGetProperty(propertyName, out var value) &&
 541        value.ValueKind == JsonValueKind.String
 542            ? value.GetString()
 543            : null;
 544
 545    private void ValidateGrantSources(IdentityProviderConnection connection, ExternalAuthenticationOptions configuredOpt
 546    {
 547        var orders = new HashSet<int>();
 548        foreach (var source in connection.PermissionGrantSources)
 549        {
 550            if (source.SettingsVersion <= 0 || !grantSources.TryGet(source.Type, out _) || !IsAllowed(configuredOptions.
 551                errors.Add(new ConnectionValidationError("permissionGrantSources", "unavailable", "A selected permission
 552            if (!orders.Add(source.Order))
 553                errors.Add(new ConnectionValidationError("permissionGrantSources", "duplicate_order", "Permission grant 
 554        }
 555    }
 556
 557    private static void ValidateSecretBindingFields(IdentityProviderConnection connection, ExternalAuthenticationAdapter
 558    {
 559        var secretFields = descriptor.Fields.Where(x => x.IsSecretBinding).ToDictionary(x => x.Name, StringComparer.Ordi
 560        foreach (var name in connection.SecretBindings.Keys)
 561            if (!secretFields.ContainsKey(name))
 562                errors.Add(new ConnectionValidationError($"secretBindings.{name}", "undeclared", "The adapter does not d
 563        if (!requireCompleteConfiguration)
 564            return;
 565        foreach (var field in secretFields.Values.Where(x => x.IsRequired))
 566            if (!connection.SecretBindings.ContainsKey(field.Name))
 567                errors.Add(new ConnectionValidationError($"secretBindings.{field.Name}", "required", "A required secret 
 568    }
 569
 570    private static void ValidateSecretBindingsAreNotAdapterSettings(JsonElement settings, ExternalAuthenticationAdapterD
 571    {
 572        if (settings.ValueKind != JsonValueKind.Object)
 573            return;
 574
 575        foreach (var field in descriptor.Fields.Where(x => x.IsSecretBinding))
 576            if (settings.TryGetProperty(field.Name, out _))
 577                errors.Add(new ConnectionValidationError($"adapterSettings.{field.Name}", "secret_binding_required", "Se
 578    }
 579
 580    private static bool UsesUnsafeSettings(JsonElement settings, ExternalAuthenticationAdapterDescriptor descriptor)
 581    {
 582        if (settings.ValueKind != JsonValueKind.Object)
 583            return false;
 584        return descriptor.Fields.Where(x => x.IsUnsafe).Any(field =>
 585            settings.TryGetProperty(field.Name, out var value) && IsUnsafeFieldValue(field, value));
 586    }
 587
 588    private static bool UnsafeSettingsChanged(JsonElement beforeSettings, JsonElement afterSettings, ExternalAuthenticat
 589    {
 590        if (afterSettings.ValueKind != JsonValueKind.Object)
 591            return false;
 592
 593        foreach (var field in descriptor.Fields.Where(x => x.IsUnsafe))
 594        {
 595            if (!afterSettings.TryGetProperty(field.Name, out var afterValue) || !IsUnsafeFieldValue(field, afterValue))
 596                continue;
 597            if (beforeSettings.ValueKind != JsonValueKind.Object || !beforeSettings.TryGetProperty(field.Name, out var b
 598                return true;
 599        }
 600
 601        return false;
 602    }
 603
 604    private static bool IsUnsafeFieldValue(SettingFieldDescriptor field, JsonElement value) =>
 605        value.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined and not JsonValueKind.False &&
 606        (!string.Equals(field.Name, "providerPkce", StringComparison.Ordinal) || value.ValueKind != JsonValueKind.String
 607
 608    private static bool JsonValueEquals(JsonElement left, JsonElement right) =>
 609        left.ValueKind == right.ValueKind && string.Equals(left.GetRawText(), right.GetRawText(), StringComparison.Ordin
 610
 611    private static EffectiveIdentityProviderConnection ToEffective(IdentityProviderConnection connection) => new(connect
 612    private static ConnectionScope ToScope(string tenantId) => tenantId == ConnectionScope.HostTenantId ? ConnectionScop
 613    private static bool CanMutate(string connectionTenantId, string targetTenantId) => connectionTenantId == ConnectionS
 614    private static bool IsValidScope(string tenantId) => tenantId == ConnectionScope.HostTenantId;
 615    private static string NormalizeScopeTenantId(string requestedTenantId, string fallback) => requestedTenantId is null
 616    private static bool IsAllowed(ICollection<string> allowedTypes, string type) => allowedTypes.Count == 0 || allowedTy
 617    private static bool HasPermission(ClaimsPrincipal actor, string permission) => actor.FindAll(PermissionNames.ClaimTy
 618    private static ConnectionLifecycle GetLifecycle(IdentityProviderConnection connection) => connection.ArchivedAt.HasV
 619    private static bool Matches(EffectiveIdentityProviderConnection connection, ConnectionFilter filter) =>
 620        (filter.Ownership is null || filter.Ownership == connection.Ownership) &&
 621        (filter.Scope is null || filter.Scope == connection.Scope) &&
 622        (string.IsNullOrWhiteSpace(filter.Search) || connection.Connection.Key.Contains(filter.Search, StringComparison.
 623        (string.IsNullOrWhiteSpace(filter.AdapterType) || string.Equals(filter.AdapterType, connection.Connection.Adapte
 624        (!filter.IsEnabled.HasValue || filter.IsEnabled.Value == connection.Connection.IsEnabled) &&
 625        (!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{
 76633    private ManagementConnectionLookupResult() { }
 74634    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

.ctor()
get_Connection()