< 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: 687
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.Common;
 4using Elsa.Authorization;
 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    IPermissionEvaluator permissionEvaluator,
 36    ConnectionRevisionCalculator revisionCalculator,
 37    ISystemClock clock,
 38    IOptions<ExternalAuthenticationOptions> options,
 39    Elsa.Identity.Contracts.IRoleAuthorizationService roleAuthorizationService,
 40    IExternalAuthenticationSessionStore sessions,
 41    IServiceProvider services,
 42    ILogger<IdentityProviderConnectionManagementService> logger)
 43{
 44    private readonly IReadOnlyDictionary<string, ISecretBindingResolver> _secretBindingResolvers = secretBindingResolver
 45    private readonly IReadOnlyDictionary<string, IManagedSecretBindingWriter> _managedSecretBindingWriters = managedSecr
 46
 47    public async ValueTask<ManagementConnectionLookupResult> FindAsync(string id, string targetTenantId, CancellationTok
 48    {
 49        var effective = await registry.FindByIdAsync(targetTenantId, id, cancellationToken);
 50        if (effective is not null && effective.Scope == ConnectionScope.Host)
 51            return new ManagementConnectionLookupResult.Found(await validityAssessor.AssessAsync(effective, cancellation
 52
 53        var connection = await store.FindByIdAsync(id, cancellationToken);
 54        if (connection is null || connection.TenantId != ConnectionScope.HostTenantId)
 55            return new ManagementConnectionLookupResult.NotFound();
 56
 57        return new ManagementConnectionLookupResult.Found(await validityAssessor.AssessAsync(ToEffective(connection), ca
 58    }
 59
 60    /// <summary>Returns the deployment-derived read-only upstream callback URI for management display.</summary>
 61    public Uri? GetProviderCallbackUri(IdentityProviderConnection connection) => options.Value.Redirects.ExternalCallbac
 62        ? ExternalAuthenticationCallbackUris.GetAuthorizationCallbackUri(baseUri, connection, BrokerTransactionPurpose.E
 63        : null;
 64
 65    /// <summary>Returns the deployment-derived read-only callback URI used by provider preview sign-ins.</summary>
 66    public Uri? GetProviderPreviewCallbackUri(IdentityProviderConnection connection) => options.Value.Redirects.External
 67        ? ExternalAuthenticationCallbackUris.GetAuthorizationCallbackUri(baseUri, connection, BrokerTransactionPurpose.P
 68        : null;
 69
 70    public async ValueTask<IReadOnlyCollection<EffectiveIdentityProviderConnection>> ListAsync(string targetTenantId, Co
 71    {
 72        var effective = await registry.GetAsync(targetTenantId, cancellationToken);
 73        var matches = effective.Connections
 74            .Where(x => x.Scope == ConnectionScope.Host)
 75            .Where(x => Matches(x, filter))
 76            .OrderBy(x => x.Scope.Kind)
 77            .ThenBy(x => x.Connection.DisplayOrder)
 78            .ThenBy(x => x.Connection.Key, StringComparer.Ordinal)
 79            .ThenBy(x => x.Connection.Id, StringComparer.Ordinal)
 80            .ToArray();
 81        return await Task.WhenAll(matches.Select(x => validityAssessor.AssessAsync(x, cancellationToken).AsTask()));
 82    }
 83
 84    public async ValueTask<ManagementConnectionMutationResult> CreateAsync(IdentityProviderConnection connection, Claims
 85    {
 86        NormalizeForCreate(connection, targetTenantId);
 87        if (!CanMutate(connection.TenantId, targetTenantId))
 88            return new ManagementConnectionMutationResult.Forbidden();
 89        var validation = await ValidateAsync(connection, actor, targetTenantId, requireCompleteConfiguration: false, con
 90        if (!validation.IsValid)
 91            return new ManagementConnectionMutationResult.ValidationFailed(validation);
 92        if (await CollidesWithConfigurationOrHostAsync(connection, null, targetTenantId, cancellationToken))
 93            return new ManagementConnectionMutationResult.Conflict("connection_key_conflict");
 94
 95        connection.MaterialRevision = revisionCalculator.CalculateMaterialRevision(connection);
 96        var result = await store.CreateAsync(connection, cancellationToken);
 97        return await ProcessMutationAsync(result, actor, "created", null, cancellationToken);
 98    }
 99
 100    public async ValueTask<ManagementConnectionMutationResult> UpdateAsync(string id, IdentityProviderConnection candida
 101    {
 102        var existing = await store.FindByIdAsync(id, cancellationToken);
 103        if (existing is null || existing.TenantId != ConnectionScope.HostTenantId)
 104            return new ManagementConnectionMutationResult.NotFound();
 105        if (!CanMutate(existing.TenantId, targetTenantId))
 106            return new ManagementConnectionMutationResult.Forbidden();
 107
 108        candidate.Id = existing.Id;
 109        candidate.CreatedAt = existing.CreatedAt;
 110        candidate.Revision = existing.Revision;
 111        candidate.ArchivedAt = existing.ArchivedAt;
 112        NormalizeForUpdate(candidate, existing);
 113        if (!CanMutate(candidate.TenantId, targetTenantId))
 114            return new ManagementConnectionMutationResult.Forbidden();
 115        if (!string.Equals(existing.Key, candidate.Key, StringComparison.Ordinal))
 116            return new ManagementConnectionMutationResult.Conflict("connection_key_immutable");
 117        var requireUnsafeConfirmation = adapters.TryGet(candidate.AdapterType, out var candidateAdapter) &&
 118            UnsafeSettingsChanged(existing.AdapterSettings, candidate.AdapterSettings, candidateAdapter.Describe());
 119        var validation = await ValidateAsync(candidate, actor, targetTenantId, requireCompleteConfiguration: candidate.I
 120        if (!validation.IsValid)
 121            return new ManagementConnectionMutationResult.ValidationFailed(validation);
 122
 123        if (await IsBlockedByFinalLoginPathGuardAsync(existing, candidate, targetTenantId, actor, confirmFinalLoginPathO
 124            return new ManagementConnectionMutationResult.Conflict("final_login_path_guard");
 125
 126        candidate.MaterialRevision = revisionCalculator.CalculateMaterialRevision(candidate);
 127        var result = await store.UpdateAsync(candidate, expectedRevision, cancellationToken);
 128        return await ProcessMutationAsync(result, actor, "updated", GetLifecycle(existing), cancellationToken, existing)
 129    }
 130
 131    public async ValueTask<ManagementConnectionMutationResult> ChangeLifecycleAsync(string id, ConnectionLifecycle actio
 132    {
 133        var existing = await store.FindByIdAsync(id, cancellationToken);
 134        if (existing is null || existing.TenantId != ConnectionScope.HostTenantId)
 135            return new ManagementConnectionMutationResult.NotFound();
 136        if (!CanMutate(existing.TenantId, targetTenantId))
 137            return new ManagementConnectionMutationResult.Forbidden();
 138
 139        var previousLifecycle = GetLifecycle(existing);
 140        var candidate = IdentityProviderConnectionCloner.Clone(existing);
 141        switch (action)
 142        {
 143            case ConnectionLifecycle.Enabled:
 144            {
 145                var validation = await ValidateAsync(candidate, actor, targetTenantId, requireCompleteConfiguration: tru
 146                if (!validation.IsValid)
 147                    return new ManagementConnectionMutationResult.ValidationFailed(validation);
 148                if (candidate.ArchivedAt.HasValue)
 149                    return new ManagementConnectionMutationResult.Conflict("connection_archived");
 150                if (candidate.IsPreferred)
 151                {
 152                    var current = await registry.GetAsync(targetTenantId, cancellationToken);
 153                    if (current.Connections.Any(x =>
 154                            x is { Ownership: ConnectionSourceOwnership.Configuration, IsShadowed: false, Connection: { 
 155                            x.Validity != ConnectionValidity.Invalid &&
 156                            (!candidate.OverridesConfigurationConnection || !string.Equals(x.Connection.Key, candidate.K
 157                        return new ManagementConnectionMutationResult.Conflict("configuration_preferred_connection");
 158                    if (current.Connections.Any(x => x is { Ownership: ConnectionSourceOwnership.Database, Connection: {
 159                        return new ManagementConnectionMutationResult.Conflict("default_connection_conflict");
 160                }
 161                candidate.IsEnabled = true;
 162                break;
 163            }
 164            case ConnectionLifecycle.Disabled:
 165                if (candidate.ArchivedAt.HasValue)
 166                    return new ManagementConnectionMutationResult.Conflict("connection_archived");
 167                candidate.IsEnabled = false;
 168                break;
 169            case ConnectionLifecycle.Archived:
 170                candidate.IsEnabled = false;
 171                candidate.ArchivedAt = clock.UtcNow;
 172                break;
 173            case ConnectionLifecycle.Draft:
 174                if (!candidate.ArchivedAt.HasValue)
 175                    return new ManagementConnectionMutationResult.Conflict("connection_not_archived");
 176                candidate.ArchivedAt = null;
 177                candidate.IsEnabled = false;
 178                break;
 179            default:
 180                return new ManagementConnectionMutationResult.Conflict("invalid_lifecycle_action");
 181        }
 182
 183        candidate.UpdatedAt = clock.UtcNow;
 184        candidate.MaterialRevision = revisionCalculator.CalculateMaterialRevision(candidate);
 185        if (await IsBlockedByFinalLoginPathGuardAsync(existing, candidate, targetTenantId, actor, confirmFinalLoginPathO
 186            return new ManagementConnectionMutationResult.Conflict("final_login_path_guard");
 187        var result = await store.UpdateAsync(candidate, expectedRevision, cancellationToken);
 188        var processed = await ProcessMutationAsync(result, actor, action.ToString().ToLowerInvariant(), previousLifecycl
 189        if (processed is ManagementConnectionMutationResult.Success && action == ConnectionLifecycle.Disabled && revokeA
 190        {
 191            var connectionKey = ConnectionRevisionCalculator.NormalizeKey(candidate.Key);
 192            var revokedCount = await sessions.RevokeActiveForConnectionAsync(connectionKey, "connection_disabled", clock
 193            await PublishBulkSessionRevocationAsync(candidate, actor, revokedCount);
 194        }
 195        return processed;
 196    }
 197
 198    public async ValueTask<ConnectionValidationResult> ValidateAsync(IdentityProviderConnection connection, ClaimsPrinci
 199    {
 200        var errors = new List<ConnectionValidationError>();
 201        var warnings = new List<string>();
 202        var configuredOptions = options.Value;
 203        ValidateEnvelope(connection, configuredOptions, errors);
 204
 205        if (!adapters.TryGet(connection.AdapterType, out var adapter) || !IsAllowed(configuredOptions.AllowedAdapterType
 206            errors.Add(new("adapterType", "unavailable", "The selected adapter is not installed or is not allowed by thi
 207
 208        await ValidatePolicyAsync(connection, actor, targetTenantId, configuredOptions, errors, cancellationToken);
 209        ValidateGrantSources(connection, configuredOptions, errors);
 210        if (connection.PermissionGrantSources.Count != 0)
 211        {
 212            var delegation = await delegationAuthorizer.AuthorizeAsync(actor, connection.PermissionGrantSources.ToArray(
 213            if (!delegation.IsAuthorized)
 214                errors.Add(new("permissionGrantSources", "delegation_denied", "The caller may not delegate one or more c
 215        }
 216
 217        if (adapter is null)
 218            return new(false, errors, warnings);
 219
 220        await ApplySettingsMigrationAsync(connection, errors, cancellationToken);
 221        if (errors.Count != 0)
 222            return new(false, errors, warnings);
 223
 224        var descriptor = adapter.Describe();
 225        ValidateSecretBindingFields(connection, descriptor, requireCompleteConfiguration, errors);
 226        ValidateSecretBindingsAreNotAdapterSettings(connection.AdapterSettings, descriptor, errors);
 227        if (requireUnsafeConfirmation && UsesUnsafeSettings(connection.AdapterSettings, descriptor) && (!confirmUnsafeSe
 228            errors.Add(new("adapterSettings", "unsafe_confirmation_required", "Unsafe provider trust settings require pe
 229
 230        if (requireCompleteConfiguration)
 231        {
 232            var secretStates = await GetSecretStatesAsync(connection, cancellationToken);
 233            foreach (var (name, state) in secretStates)
 234            {
 235                if (!state.IsConfigured)
 236                    errors.Add(new($"secretBindings.{name}", "required", "A required secret binding is not configured.")
 237                else if (!state.IsResolvable)
 238                    errors.Add(new($"secretBindings.{name}", "unresolvable", "The secret binding cannot be resolved."));
 239            }
 240        }
 241
 242        if (errors.Count != 0 || allowIncompleteDraft)
 243            return new(errors.Count == 0, errors, warnings);
 244
 245        var effective = ToEffective(connection);
 246        var adapterValidation = await adapter.ValidateAsync(new(effective, new Dictionary<string, ResolvedSecretBinding>
 247        errors.AddRange(adapterValidation.Errors);
 248        warnings.AddRange(adapterValidation.Warnings);
 249        return new(errors.Count == 0 && adapterValidation.IsValid, errors, warnings);
 250    }
 251
 252    public ValueTask<IReadOnlyDictionary<string, SecretBindingState>> GetSecretBindingStatesAsync(IdentityProviderConnec
 253
 254    public SecretBindingPresentation PresentSecretBinding(SecretBinding binding, SecretBindingState? state) => new(
 255        binding.Ownership == SecretBindingOwnership.Managed ? "managed" : "external",
 256        state?.IsConfigured ?? false,
 257        state?.IsResolvable ?? false);
 258
 259    public bool CanCreateConfigurationOverride() => options.Value.AllowConfigurationConnectionOverrides;
 260
 261    public bool CanPromoteToConfigurationOverride(EffectiveIdentityProviderConnection connection) =>
 262        connection is { Ownership: ConnectionSourceOwnership.Database, IsShadowed: true, Connection.ArchivedAt: null } &
 263        options.Value.AllowConfigurationConnectionOverrides;
 264
 265    private async ValueTask<bool> IsBlockedByFinalLoginPathGuardAsync(IdentityProviderConnection existing, IdentityProvi
 266    {
 267        var guard = services.GetService<FinalLoginPathGuard>();
 268        if (guard is null)
 269            return false;
 270
 271        var guardExisting = existing;
 272        if (!existing.OverridesConfigurationConnection && candidate.OverridesConfigurationConnection)
 273        {
 274            var normalizedKey = ConnectionRevisionCalculator.NormalizeKey(candidate.Key);
 275            var effective = await registry.GetAsync(targetTenantId, cancellationToken);
 276            var displacedConfigurationConnection = effective.Connections.FirstOrDefault(x =>
 277                x is { Ownership: ConnectionSourceOwnership.Configuration, IsShadowed: false } &&
 278                string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), normalizedKey, StringComparis
 279            if (displacedConfigurationConnection is not null)
 280                guardExisting = displacedConfigurationConnection.Connection;
 281        }
 282
 283        return await guard.AuthorizeAsync(guardExisting, candidate, targetTenantId, actor, confirmedOverride, cancellati
 284    }
 285
 286    private async ValueTask<ManagementConnectionMutationResult> ProcessMutationAsync(ConnectionMutationResult result, Cl
 287    {
 288        switch (result)
 289        {
 290            case ConnectionMutationResult.Created(var createdConnection):
 291                await RunPostCommitActionsAsync(createdConnection, actor, operation, previousLifecycle, previousConnecti
 292                return new ManagementConnectionMutationResult.Success(createdConnection);
 293            case ConnectionMutationResult.Updated(var updatedConnection):
 294                await RunPostCommitActionsAsync(updatedConnection, actor, operation, previousLifecycle, previousConnecti
 295                return new ManagementConnectionMutationResult.Success(updatedConnection);
 296            case ConnectionMutationResult.NotFound:
 297                return new ManagementConnectionMutationResult.NotFound();
 298            case ConnectionMutationResult.DuplicateKey:
 299                return new ManagementConnectionMutationResult.Conflict("connection_key_conflict");
 300            case ConnectionMutationResult.RevisionConflict(var currentRevision):
 301                return new ManagementConnectionMutationResult.PreconditionFailed(currentRevision);
 302            default:
 303                throw new InvalidOperationException("The connection store returned an unknown mutation result.");
 304        }
 305    }
 306
 307    private async ValueTask RunPostCommitActionsAsync(IdentityProviderConnection connection, ClaimsPrincipal actor, stri
 308    {
 309        try
 310        {
 311            await registryVersions.AdvanceAsync(CancellationToken.None);
 312        }
 313        catch (Exception exception)
 314        {
 315            logger.LogCritical(exception, "Connection {ConnectionId} was committed, but advancing the external-authentic
 316        }
 317
 318        try
 319        {
 320            await PublishAsync(connection, actor, operation, previousLifecycle, previousConnection, CancellationToken.No
 321        }
 322        catch (Exception exception)
 323        {
 324            logger.LogError(exception, "Connection {ConnectionId} was committed, but publishing external-authentication 
 325        }
 326    }
 327
 328    private async ValueTask PublishBulkSessionRevocationAsync(IdentityProviderConnection connection, ClaimsPrincipal act
 329    {
 330        if (revokedCount == 0)
 331            return;
 332
 333        var notificationSender = services.GetService<INotificationSender>();
 334        if (notificationSender is null)
 335            return;
 336
 337        try
 338        {
 339            var context = new SecurityEventContext(
 340                actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 341                connection.TenantId,
 342                connection.Id,
 343                null,
 344                clock.UtcNow,
 345                SecurityEventOutcome.Succeeded,
 346                Guid.NewGuid().ToString("N"),
 347                "Active external authentication sessions were revoked when the connection was disabled.");
 348            await notificationSender.SendAsync(new ExternalAuthenticationConnectionSessionsRevoked(context, revokedCount
 349        }
 350        catch (Exception exception)
 351        {
 352            logger.LogError(exception, "Connection {ConnectionId} sessions were revoked, but publishing the aggregate se
 353        }
 354    }
 355
 356    private async ValueTask PublishAsync(IdentityProviderConnection connection, ClaimsPrincipal actor, string operation,
 357    {
 358        var notificationSender = services.GetService<INotificationSender>();
 359        if (notificationSender is null)
 360            return;
 361
 362        var context = new SecurityEventContext(
 363            actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
 364            connection.TenantId,
 365            connection.Id,
 366            null,
 367            clock.UtcNow,
 368            SecurityEventOutcome.Succeeded,
 369            Guid.NewGuid().ToString("N"),
 370            "Identity provider connection management operation completed.");
 371        await notificationSender.SendAsync(new IdentityProviderConnectionChanged(context, operation, connection.Revision
 372        if (previousLifecycle is { } previous && previous != GetLifecycle(connection))
 373            await notificationSender.SendAsync(new IdentityProviderConnectionLifecycleChanged(context, previous.ToString
 374        if (previousConnection is not null)
 375        {
 376            var fields = previousConnection.SecretBindings.Keys
 377                .Concat(connection.SecretBindings.Keys)
 378                .Distinct(StringComparer.Ordinal)
 379                .Where(field => !previousConnection.SecretBindings.TryGetValue(field, out var before) || !connection.Sec
 380            foreach (var field in fields)
 381            {
 382                previousConnection.SecretBindings.TryGetValue(field, out var previousBinding);
 383                connection.SecretBindings.TryGetValue(field, out var binding);
 384                var isConfigured = binding is not null && _secretBindingResolvers.TryGetValue(binding.ResolverType, out 
 385                await notificationSender.SendAsync(new IdentityProviderConnectionSecretBindingChanged(context, field, bi
 386            }
 387        }
 388    }
 389
 390    private async ValueTask<bool> CollidesWithConfigurationOrHostAsync(IdentityProviderConnection candidate, string? sel
 391    {
 392        var lookupTenant = candidate.TenantId == ConnectionScope.HostTenantId ? ConnectionScope.HostTenantId : candidate
 393        var effective = await registry.GetAsync(lookupTenant, cancellationToken);
 394        var key = ConnectionRevisionCalculator.NormalizeKey(candidate.Key);
 395        if (effective.Connections.Any(x => x.Ownership == ConnectionSourceOwnership.Configuration && x.Scope.TenantId ==
 396            return !candidate.OverridesConfigurationConnection;
 397
 398        if (candidate.TenantId != ConnectionScope.HostTenantId)
 399            return effective.Connections.Any(x =>
 400            !string.Equals(x.Connection.Id, selfId, StringComparison.Ordinal) &&
 401            x.Scope.Kind == ConnectionScopeKind.Host &&
 402            string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), key, StringComparison.Ordinal));
 403
 404        var rows = await store.FindAsync(new(), cancellationToken);
 405        if (rows.Items.Any(x =>
 406                !string.Equals(x.Id, selfId, StringComparison.Ordinal) &&
 407                x.TenantId != ConnectionScope.HostTenantId &&
 408                string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Key), key, StringComparison.Ordinal)))
 409            return true;
 410
 411        return options.Value.ConfigurationConnections.Any(x =>
 412            x.TenantId != ConnectionScope.HostTenantId &&
 413            string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Key), key, StringComparison.Ordinal));
 414    }
 415
 416    private async ValueTask<IReadOnlyDictionary<string, SecretBindingState>> GetSecretStatesAsync(IdentityProviderConnec
 417    {
 418        var states = new Dictionary<string, SecretBindingState>(StringComparer.Ordinal);
 419        foreach (var (name, binding) in connection.SecretBindings)
 420        {
 421            if (!_secretBindingResolvers.TryGetValue(binding.ResolverType, out var resolver))
 422                states[name] = new(false, false);
 423            else
 424                states[name] = await resolver.GetStateAsync(binding, cancellationToken);
 425        }
 426
 427        return states;
 428    }
 429
 430    private async ValueTask ApplySettingsMigrationAsync(IdentityProviderConnection connection, ICollection<ConnectionVal
 431    {
 432        try
 433        {
 434            var migration = await settingsMigrations.MigrateAsync(connection.AdapterType, connection.AdapterSettingsVers
 435            connection.AdapterSettingsVersion = migration.SettingsVersion;
 436            connection.AdapterSettings = migration.Settings;
 437        }
 438        catch (InvalidOperationException)
 439        {
 440            errors.Add(new("adapterSettingsVersion", "migration_unavailable", "The adapter settings version is not compa
 441        }
 442    }
 443
 444    private void NormalizeForCreate(IdentityProviderConnection connection, string targetTenantId)
 445    {
 446        connection.Id = string.IsNullOrWhiteSpace(connection.Id) ? Guid.NewGuid().ToString("N") : connection.Id;
 447        connection.Key = connection.Key?.Trim() ?? string.Empty;
 448        connection.TenantId = ConnectionScope.HostTenantId;
 449        connection.IsEnabled = false;
 450        connection.ArchivedAt = null;
 451        connection.Revision = 1;
 452        connection.CreatedAt = clock.UtcNow;
 453        connection.UpdatedAt = clock.UtcNow;
 454        connection.MaterialRevision = revisionCalculator.CalculateMaterialRevision(connection);
 455    }
 456
 457    private void NormalizeForUpdate(IdentityProviderConnection candidate, IdentityProviderConnection existing)
 458    {
 459        candidate.Key = candidate.Key?.Trim() ?? string.Empty;
 460        candidate.TenantId = ConnectionScope.HostTenantId;
 461        candidate.IsEnabled = existing.IsEnabled;
 462        candidate.UpdatedAt = clock.UtcNow;
 463        candidate.SecretBindings ??= new Dictionary<string, SecretBinding>(StringComparer.Ordinal);
 464        candidate.PermissionGrantSources ??= [];
 465        candidate.ClaimProjection ??= ClaimProjection.Empty;
 466    }
 467
 468    private static void ValidateEnvelope(IdentityProviderConnection connection, ExternalAuthenticationOptions configured
 469    {
 470        if (!configuredOptions.EnableDatabaseConnections)
 471            errors.Add(new("source", "disabled", "Database-owned connections are disabled by deployment configuration.")
 472        if (connection.OverridesConfigurationConnection && !configuredOptions.AllowConfigurationConnectionOverrides)
 473            errors.Add(new("overridesConfigurationConnection", "not_allowed", "This deployment does not allow database c
 474        if (string.IsNullOrWhiteSpace(connection.Key) || connection.Key.Length > 128 || connection.Key.Any(char.IsWhiteS
 475            errors.Add(new("key", "invalid", "Connection keys must be non-empty lowercase URL-safe tokens up to 128 char
 476        else if (!ConnectionKeyPattern().IsMatch(connection.Key))
 477            errors.Add(new("key", "invalid", "Connection keys must use lowercase letters, digits, and interior hyphens o
 478        if (string.IsNullOrWhiteSpace(connection.DisplayName) || connection.DisplayName.Trim().Length > 256)
 479            errors.Add(new("displayName", "invalid", "Display name is required and may not exceed 256 characters."));
 480        if (connection.AdapterSettingsVersion <= 0)
 481            errors.Add(new("adapterSettingsVersion", "invalid", "Adapter settings version must be positive."));
 482        if (!Enum.IsDefined(connection.UpstreamLogoutMode))
 483            errors.Add(new("upstreamLogoutMode", "invalid", "Upstream logout mode is invalid."));
 484        if (!IsValidScope(connection.TenantId))
 485            errors.Add(new("scope", "host_scope_required", "Identity provider connections are managed host-wide in this 
 486        if (connection.ClaimProjection.MaximumClaimCount < 0 || connection.ClaimProjection.MaximumValueLength < 0 || con
 487            connection.ClaimProjection.MaximumClaimCount > configuredOptions.Claims.MaximumClaimCount || connection.Clai
 488            errors.Add(new("claimProjection", "invalid", "Claim projection limits exceed deployment bounds."));
 489        if (!connection.ClaimProjection.RedactedClaimTypes.IsSubsetOf(connection.ClaimProjection.AllowedClaimTypes))
 490            errors.Add(new("claimProjection.redactedClaimTypes", "invalid", "Redacted claim types must also be allowed c
 491    }
 492
 493    private async ValueTask ValidatePolicyAsync(IdentityProviderConnection connection, ClaimsPrincipal actor, string tar
 494    {
 495        // The roles the candidate would actually assign. A policy that does not create users assigns none,
 496        // and so does no policy at all -- which is what makes switching away from, or clearing, a stored
 497        // create-user fallback a change rather than a no-op.
 498        var defaultRoleIds = connection.UnlinkedPolicy is { } candidate && UsesCreateUserFallback(candidate)
 499            ? Policies.CreateUserUnlinkedIdentityPolicy.ReadRoleIds(candidate.Settings)
 500            : [];
 501
 502        // Two independent checks, reported separately because they answer different questions. The
 503        // permission asks whether this actor may decide what auto-created users receive; the subset rule
 504        // asks whether these particular roles stay within what the actor already holds. Only the second
 505        // existed, which left the sibling resource guarded on the write path while the roles inside it
 506        // were not -- see #7977.
 507        //
 508        // Gated on the effective set *changing*, not on it being non-empty, and evaluated before the
 509        // null-policy early return. Validation runs on every update, on enabling a connection, and on
 510        // read-only validate, so keying off presence would stop an administrator without this permission
 511        // from editing an unrelated field once anyone had set roles. Evaluating it only for non-null
 512        // policies would be worse: omitting unlinkedPolicy from an update clears a stored fallback and
 513        // drops its role assignments, the same decision as switching it to 'reject'.
 514        //
 515        // The cheap in-memory permission check goes first: the unchanged-roles comparison rebuilds the
 516        // effective registry, and its answer is irrelevant for an actor who holds the permission.
 517        if (!permissionEvaluator.HasPermission(actor, ExternalAuthenticationResourcePermissions.PolicyDefaultRoles, Core
 518            && !await DefaultRolesAreUnchangedAsync(connection, targetTenantId, defaultRoleIds, cancellationToken))
 519            errors.Add(new("unlinkedPolicy.defaultRoleIds", "forbidden", "Changing the default roles for an unlinked ide
 520
 521        if (connection.UnlinkedPolicy is not { } policy)
 522            return;
 523        if (!configuredOptions.UnlinkedIdentityPolicy.AllowDatabaseConnectionOverride)
 524            errors.Add(new("unlinkedPolicy", "not_allowed", "This deployment does not allow database connection policy o
 525        else if (policy.SettingsVersion <= 0 || !policies.TryGet(policy.Type, out _) || !IsAllowed(configuredOptions.All
 526            errors.Add(new("unlinkedPolicy", "unavailable", "The selected unlinked identity policy is not installed or a
 527        else
 528        {
 529            // The subset rule only has something to say about roles actually being assigned.
 530            if (UsesCreateUserFallback(policy) && !await roleAuthorizationService.CanAssignRolesAsync(actor, defaultRole
 531                errors.Add(new("unlinkedPolicy.defaultRoleIds", "forbidden", "The selected default roles are unavailable
 532
 533            if (string.Equals(policy.Type, Policies.MatchExternalUserUnlinkedIdentityPolicy.PolicyType, StringComparison
 534                (!TryGetMatcherSelection(policy.Settings, out var matcherType, out var matcherSettingsVersion) ||
 535                 !IsAllowed(configuredOptions.AllowedExternalUserMatcherTypes, matcherType) ||
 536                 !matchers.TryGet(matcherType, out _) ||
 537                 matchers.ListDescriptors().All(x => !string.Equals(x.Type, matcherType, StringComparison.Ordinal) || x.
 538                errors.Add(new("unlinkedPolicy.matcher", "unavailable", "The selected external user matcher is not insta
 539        }
 540    }
 541
 542    /// <summary>Whether <paramref name="candidateRoleIds"/> matches what the connection already assigns.</summary>
 543    /// <remarks>
 544    /// The baseline comes from the registry rather than the database store, because a configuration-owned
 545    /// connection has no database record: looking only there made its configured roles read as newly assigned
 546    /// on every validation, so a caller with view access could not validate one at all. The registry answers
 547    /// for both ownerships, which is the question being asked -- what does this connection assign today.
 548    /// </remarks>
 549    private async ValueTask<bool> DefaultRolesAreUnchangedAsync(IdentityProviderConnection connection, string targetTena
 550    {
 551        var existing = string.IsNullOrWhiteSpace(connection.Id)
 552            ? null
 553            : (await registry.FindByIdAsync(targetTenantId, connection.Id, cancellationToken))?.Connection
 554              ?? await store.FindByIdAsync(connection.Id, cancellationToken);
 555        var storedRoleIds = existing?.UnlinkedPolicy is { } storedPolicy && UsesCreateUserFallback(storedPolicy)
 556            ? Policies.CreateUserUnlinkedIdentityPolicy.ReadRoleIds(storedPolicy.Settings)
 557            : [];
 558
 559        // Order is not meaningful in a role set, so a reordering is not a change.
 560        return storedRoleIds.OrderBy(x => x, StringComparer.Ordinal).SequenceEqual(candidateRoleIds.OrderBy(x => x, Stri
 561    }
 562
 563    private static bool UsesCreateUserFallback(PolicySelection policy) =>
 564        string.Equals(policy.Type, Policies.CreateUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal) ||
 565        string.Equals(policy.Type, Policies.MatchExternalUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal
 566        string.Equals(ReadString(policy.Settings, "noMatchAction"), "create-user", StringComparison.OrdinalIgnoreCase);
 567
 568    private static bool TryGetMatcherSelection(JsonElement settings, out string matcherType, out int settingsVersion)
 569    {
 570        matcherType = string.Empty;
 571        settingsVersion = 0;
 572        if (settings.ValueKind != JsonValueKind.Object || !settings.TryGetProperty("matcher", out var matcher) || matche
 573            return false;
 574
 575        matcherType = type.GetString() ?? string.Empty;
 576        return !string.IsNullOrWhiteSpace(matcherType) && matcher.TryGetProperty("settingsVersion", out var version) && 
 577    }
 578
 579    private static string? ReadString(JsonElement settings, string propertyName) =>
 580        settings.ValueKind == JsonValueKind.Object &&
 581        settings.TryGetProperty(propertyName, out var value) &&
 582        value.ValueKind == JsonValueKind.String
 583            ? value.GetString()
 584            : null;
 585
 586    private void ValidateGrantSources(IdentityProviderConnection connection, ExternalAuthenticationOptions configuredOpt
 587    {
 588        var orders = new HashSet<int>();
 589        foreach (var source in connection.PermissionGrantSources)
 590        {
 591            if (source.SettingsVersion <= 0 || !grantSources.TryGet(source.Type, out _) || !IsAllowed(configuredOptions.
 592                errors.Add(new("permissionGrantSources", "unavailable", "A selected permission grant source is not insta
 593            if (!orders.Add(source.Order))
 594                errors.Add(new("permissionGrantSources", "duplicate_order", "Permission grant source orders must be uniq
 595        }
 596    }
 597
 598    private static void ValidateSecretBindingFields(IdentityProviderConnection connection, ExternalAuthenticationAdapter
 599    {
 600        var secretFields = descriptor.Fields.Where(x => x.IsSecretBinding).ToDictionary(x => x.Name, StringComparer.Ordi
 601        foreach (var name in connection.SecretBindings.Keys)
 602            if (!secretFields.ContainsKey(name))
 603                errors.Add(new($"secretBindings.{name}", "undeclared", "The adapter does not declare this secret binding
 604        if (!requireCompleteConfiguration)
 605            return;
 606        foreach (var field in secretFields.Values.Where(x => x.IsRequired))
 607            if (!connection.SecretBindings.ContainsKey(field.Name))
 608                errors.Add(new($"secretBindings.{field.Name}", "required", "A required secret binding is missing."));
 609    }
 610
 611    private static void ValidateSecretBindingsAreNotAdapterSettings(JsonElement settings, ExternalAuthenticationAdapterD
 612    {
 613        if (settings.ValueKind != JsonValueKind.Object)
 614            return;
 615
 616        foreach (var field in descriptor.Fields.Where(x => x.IsSecretBinding))
 617            if (settings.TryGetProperty(field.Name, out _))
 618                errors.Add(new($"adapterSettings.{field.Name}", "secret_binding_required", "Secret fields must be config
 619    }
 620
 621    private static bool UsesUnsafeSettings(JsonElement settings, ExternalAuthenticationAdapterDescriptor descriptor)
 622    {
 623        if (settings.ValueKind != JsonValueKind.Object)
 624            return false;
 625        return descriptor.Fields.Where(x => x.IsUnsafe).Any(field =>
 626            settings.TryGetProperty(field.Name, out var value) && IsUnsafeFieldValue(field, value));
 627    }
 628
 629    private static bool UnsafeSettingsChanged(JsonElement beforeSettings, JsonElement afterSettings, ExternalAuthenticat
 630    {
 631        if (afterSettings.ValueKind != JsonValueKind.Object)
 632            return false;
 633
 634        foreach (var field in descriptor.Fields.Where(x => x.IsUnsafe))
 635        {
 636            if (!afterSettings.TryGetProperty(field.Name, out var afterValue) || !IsUnsafeFieldValue(field, afterValue))
 637                continue;
 638            if (beforeSettings.ValueKind != JsonValueKind.Object || !beforeSettings.TryGetProperty(field.Name, out var b
 639                return true;
 640        }
 641
 642        return false;
 643    }
 644
 645    private static bool IsUnsafeFieldValue(SettingFieldDescriptor field, JsonElement value) =>
 646        value.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined and not JsonValueKind.False &&
 647        (!string.Equals(field.Name, "providerPkce", StringComparison.Ordinal) || value.ValueKind != JsonValueKind.String
 648
 649    private static bool JsonValueEquals(JsonElement left, JsonElement right) =>
 650        left.ValueKind == right.ValueKind && string.Equals(left.GetRawText(), right.GetRawText(), StringComparison.Ordin
 651
 652    private static EffectiveIdentityProviderConnection ToEffective(IdentityProviderConnection connection) => new(connect
 653    private static ConnectionScope ToScope(string tenantId) => tenantId == ConnectionScope.HostTenantId ? ConnectionScop
 654    private static bool CanMutate(string connectionTenantId, string targetTenantId) => connectionTenantId == ConnectionS
 655    private static bool IsValidScope(string tenantId) => tenantId == ConnectionScope.HostTenantId;
 656    private static string NormalizeScopeTenantId(string requestedTenantId, string fallback) => requestedTenantId is null
 657    private static bool IsAllowed(ICollection<string> allowedTypes, string type) => allowedTypes.Count == 0 || allowedTy
 658    private static ConnectionLifecycle GetLifecycle(IdentityProviderConnection connection) => connection.ArchivedAt.HasV
 659    private static bool Matches(EffectiveIdentityProviderConnection connection, ConnectionFilter filter) =>
 660        (filter.Ownership is null || filter.Ownership == connection.Ownership) &&
 661        (filter.Scope is null || filter.Scope == connection.Scope) &&
 662        (string.IsNullOrWhiteSpace(filter.Search) || connection.Connection.Key.Contains(filter.Search, StringComparison.
 663        (string.IsNullOrWhiteSpace(filter.AdapterType) || string.Equals(filter.AdapterType, connection.Connection.Adapte
 664        (!filter.IsEnabled.HasValue || filter.IsEnabled.Value == connection.Connection.IsEnabled) &&
 665        (!filter.IsArchived.HasValue || filter.IsArchived.Value == connection.Connection.ArchivedAt.HasValue);
 666
 667    [System.Text.RegularExpressions.GeneratedRegex("^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$")]
 668    private static partial System.Text.RegularExpressions.Regex ConnectionKeyPattern();
 669}
 670
 671public abstract record ManagementConnectionLookupResult
 672{
 88673    private ManagementConnectionLookupResult() { }
 86674    public sealed record Found(EffectiveIdentityProviderConnection Connection) : ManagementConnectionLookupResult;
 675    public sealed record NotFound : ManagementConnectionLookupResult;
 676}
 677
 678public abstract record ManagementConnectionMutationResult
 679{
 680    private ManagementConnectionMutationResult() { }
 681    public sealed record Success(IdentityProviderConnection Connection) : ManagementConnectionMutationResult;
 682    public sealed record NotFound : ManagementConnectionMutationResult;
 683    public sealed record Conflict(string Code) : ManagementConnectionMutationResult;
 684    public sealed record PreconditionFailed(long CurrentRevision) : ManagementConnectionMutationResult;
 685    public sealed record Forbidden : ManagementConnectionMutationResult;
 686    public sealed record ValidationFailed(ConnectionValidationResult Validation) : ManagementConnectionMutationResult;
 687}

Methods/Properties

.ctor()
get_Connection()