< Summary

Information
Class: Elsa.ExternalAuthentication.Endpoints.Connections.CreateConnection
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Endpoints/Connections/ConnectionManagementEndpoints.cs
Line coverage
91%
Covered lines: 22
Uncovered lines: 2
Coverable lines: 24
Total lines: 527
Line coverage: 91.6%
Branch coverage
71%
Covered branches: 10
Total branches: 14
Branch coverage: 71.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Configure()100%11100%
HandleAsync()90%101089.47%
ToScope(...)25%44100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Endpoints/Connections/ConnectionManagementEndpoints.cs

#LineLine coverage
 1using System.Text;
 2using System.Text.Json;
 3using Elsa.Abstractions;
 4using Elsa.Common.Multitenancy;
 5using Elsa.ExternalAuthentication.Contracts;
 6using Elsa.ExternalAuthentication.Models;
 7using Elsa.ExternalAuthentication.Permissions;
 8using Elsa.ExternalAuthentication.Services;
 9using Microsoft.AspNetCore.Http;
 10using Microsoft.Extensions.Logging;
 11
 12namespace Elsa.ExternalAuthentication.Endpoints.Connections;
 13
 14internal sealed class ListConnections(IdentityProviderConnectionManagementService management, IConnectionObservationStor
 15{
 16    public override void Configure()
 17    {
 18        Get("/external-authentication/connections");
 19        ConfigurePermissions(ExternalAuthenticationPermissions.ConnectionsRead);
 20    }
 21
 22    public override async Task<ConnectionListResponse> ExecuteAsync(ConnectionListRequest request, CancellationToken can
 23    {
 24        var requestedScope = request.Scope ?? request.ScopeKind;
 25        var scope = ConnectionScope.Host;
 26        if ((!string.IsNullOrWhiteSpace(requestedScope) && !requestedScope.Equals("host", StringComparison.OrdinalIgnore
 27            !string.IsNullOrWhiteSpace(request.TenantId) ||
 28            request.PageSize is < 1 or > 100 ||
 29            !IsKnownSource(request.Source) ||
 30            !TryDecodeCursor(request.Cursor, out var cursor))
 31        {
 32            HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
 33            return new ConnectionListResponse([], null);
 34        }
 35
 36        var filter = new ConnectionFilter
 37        {
 38            Search = request.Search,
 39            Ownership = request.Source?.Equals("configuration", StringComparison.OrdinalIgnoreCase) == true ? Connection
 40            Scope = scope,
 41            AdapterType = request.AdapterType,
 42            IsEnabled = request.Enabled,
 43            IsArchived = request.Archived
 44        };
 45        var connections = (await management.ListAsync(tenantAccessor.TenantId, filter, cancellationToken))
 46            .Where(x => !request.Valid.HasValue || request.Valid.Value == (x.Validity == ConnectionValidity.Valid))
 47            .Where(x => !request.Shadowed.HasValue || request.Shadowed.Value == x.IsShadowed)
 48            .OrderBy(x => x.Scope.Kind)
 49            .ThenBy(x => x.Scope.TenantId, StringComparer.Ordinal)
 50            .ThenBy(x => x.Connection.DisplayOrder)
 51            .ThenBy(x => x.Connection.Key, StringComparer.Ordinal)
 52            .ThenBy(x => x.Connection.Id, StringComparer.Ordinal)
 53            .Where(x => cursor is null || Compare(CursorFor(x), cursor) > 0)
 54            .Take(request.PageSize.GetValueOrDefault(100) + 1)
 55            .ToArray();
 56        var hasNextPage = connections.Length > request.PageSize.GetValueOrDefault(100);
 57        var page = hasNextPage ? connections[..^1] : connections;
 58        var observationResults = await Task.WhenAll(page.Select(x => observations.FindLatestAsync(x.Connection.Id, cance
 59        var items = await Task.WhenAll(page.Select((x, index) => ConnectionResponse.FromAsync(x, management, adapters, o
 60        return new ConnectionListResponse(items, hasNextPage ? EncodeCursor(CursorFor(page[^1])) : null);
 61    }
 62
 63    private static bool IsKnownSource(string? source) => string.IsNullOrWhiteSpace(source) ||
 64        source.Equals("configuration", StringComparison.OrdinalIgnoreCase) ||
 65        source.Equals("database", StringComparison.OrdinalIgnoreCase);
 66
 67    private static ConnectionCursor CursorFor(EffectiveIdentityProviderConnection connection) => new((int)connection.Sco
 68    private static int Compare(ConnectionCursor left, ConnectionCursor right)
 69    {
 70        var result = left.ScopeKind.CompareTo(right.ScopeKind);
 71        result = result != 0 ? result : string.Compare(left.TenantId, right.TenantId, StringComparison.Ordinal);
 72        result = result != 0 ? result : left.Order.CompareTo(right.Order);
 73        result = result != 0 ? result : string.Compare(left.Key, right.Key, StringComparison.Ordinal);
 74        return result != 0 ? result : string.Compare(left.Id, right.Id, StringComparison.Ordinal);
 75    }
 76
 77    private static string EncodeCursor(ConnectionCursor cursor) => Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSer
 78    private static bool TryDecodeCursor(string? value, out ConnectionCursor? cursor)
 79    {
 80        cursor = null;
 81        if (string.IsNullOrWhiteSpace(value))
 82            return true;
 83        try
 84        {
 85            var padded = value.Replace('-', '+').Replace('_', '/');
 86            padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
 87            cursor = JsonSerializer.Deserialize<ConnectionCursor>(Encoding.UTF8.GetString(Convert.FromBase64String(padde
 88            return cursor is not null &&
 89                cursor.ScopeKind is >= (int)ConnectionScopeKind.Host and <= (int)ConnectionScopeKind.Tenant &&
 90                cursor.TenantId is not null && cursor.Key is not null && cursor.Id is not null &&
 91                cursor.TenantId.Length <= 256 && cursor.Key.Length <= 128 && cursor.Id.Length <= 256;
 92        }
 93        catch (Exception exception) when (exception is FormatException or JsonException)
 94        {
 95            return false;
 96        }
 97    }
 98
 99    private sealed record ConnectionCursor(int ScopeKind, string TenantId, int Order, string Key, string Id);
 100}
 101
 102internal sealed class GetConnection(IdentityProviderConnectionManagementService management, IConnectionObservationStore 
 103{
 104    public override void Configure()
 105    {
 106        Get("/external-authentication/connections/{connectionId}");
 107        ConfigurePermissions(ExternalAuthenticationPermissions.ConnectionsRead);
 108    }
 109
 110    public override async Task HandleAsync(CancellationToken cancellationToken)
 111    {
 112        var result = await management.FindAsync(Route<string>("connectionId")!, tenantAccessor.TenantId, cancellationTok
 113        if (result is not ManagementConnectionLookupResult.Found(var connection))
 114        {
 115            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status404NotFound, "not_found", "The
 116            return;
 117        }
 118
 119        ConnectionEndpointSupport.SetEtag(HttpContext, connection.Connection.Revision);
 120        await HttpContext.Response.WriteAsJsonAsync(await ConnectionResponse.FromAsync(connection, management, adapters,
 121    }
 122}
 123
 43124internal sealed class CreateConnection(IdentityProviderConnectionManagementService management, IExternalAuthenticationAd
 125{
 126    public override void Configure()
 127    {
 21128        Post("/external-authentication/connections");
 21129        ConfigurePermissions(ExternalAuthenticationPermissions.ConnectionsCreate);
 21130    }
 131
 132    public override async Task HandleAsync(ConnectionRequest request, CancellationToken cancellationToken)
 133    {
 22134        if (!request.HasOnlyHostScope())
 135        {
 2136            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status400BadRequest, "host_scope_req
 2137            return;
 138        }
 20139        if (request.SecretBindings is not null)
 140        {
 1141            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status400BadRequest, "secret_binding
 1142            return;
 143        }
 19144        if (ConnectionEndpointSupport.RequiresPolicyManagement(request) && !ConnectionEndpointSupport.HasPermission(User
 145        {
 0146            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status403Forbidden, "forbidden", "Th
 0147            return;
 148        }
 149
 19150        var result = await management.CreateAsync(request.ToConnection(), User, tenantAccessor.TenantId, request.Confirm
 19151        if (result is not ManagementConnectionMutationResult.Success(var connection))
 152        {
 8153            await ConnectionEndpointSupport.SendMutationResultAsync(HttpContext, result, management, cancellationToken);
 8154            return;
 155        }
 156
 11157        var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Database, ToScope(
 11158        HttpContext.Response.StatusCode = StatusCodes.Status201Created;
 11159        HttpContext.Response.Headers.Location = $"/external-authentication/connections/{Uri.EscapeDataString(connection.
 11160        ConnectionEndpointSupport.SetEtag(HttpContext, connection.Revision);
 11161        await HttpContext.Response.WriteAsJsonAsync(await ConnectionResponse.FromAsync(effective, management, adapters, 
 22162    }
 163
 11164    private static ConnectionScope ToScope(string tenantId) => tenantId == ConnectionScope.HostTenantId ? ConnectionScop
 165}
 166
 167internal sealed class UpdateConnection(IdentityProviderConnectionManagementService management, IExternalAuthenticationAd
 168{
 169    public override void Configure()
 170    {
 171        Put("/external-authentication/connections/{connectionId}");
 172        ConfigurePermissions(ExternalAuthenticationPermissions.ConnectionsUpdate);
 173    }
 174
 175    public override async Task HandleAsync(ConnectionRequest request, CancellationToken cancellationToken)
 176    {
 177        if (!request.HasOnlyHostScope())
 178        {
 179            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status400BadRequest, "host_scope_req
 180            return;
 181        }
 182        if (!ConnectionEndpointSupport.TryGetExpectedRevision(HttpContext, out var revision))
 183        {
 184            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status428PreconditionRequired, "prec
 185            return;
 186        }
 187
 188        var existing = await management.FindAsync(Route<string>("connectionId")!, tenantAccessor.TenantId, cancellationT
 189        if (existing is not ManagementConnectionLookupResult.Found(var effective))
 190        {
 191            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status404NotFound, "not_found", "The
 192            return;
 193        }
 194        if (!ConnectionEndpointSupport.IsDatabaseOwned(effective))
 195        {
 196            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status403Forbidden, "forbidden", "Co
 197            return;
 198        }
 199        if (request.SecretBindings is not null)
 200        {
 201            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status400BadRequest, "secret_binding
 202            return;
 203        }
 204        if (ConnectionEndpointSupport.RequiresPolicyManagement(request) && !ConnectionEndpointSupport.HasPermission(User
 205        {
 206            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status403Forbidden, "forbidden", "Th
 207            return;
 208        }
 209
 210        var candidate = request.ToConnection();
 211        if (request.SecretBindings is null)
 212            candidate.SecretBindings = IdentityProviderConnectionCloner.Clone(effective.Connection).SecretBindings;
 213
 214        var result = await management.UpdateAsync(effective.Connection.Id, candidate, revision, User, tenantAccessor.Ten
 215        if (result is not ManagementConnectionMutationResult.Success(var connection))
 216        {
 217            await ConnectionEndpointSupport.SendMutationResultAsync(HttpContext, result, management, cancellationToken);
 218            return;
 219        }
 220
 221        var responseConnection = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Database,
 222        ConnectionEndpointSupport.SetEtag(HttpContext, connection.Revision);
 223        await HttpContext.Response.WriteAsJsonAsync(await ConnectionResponse.FromAsync(responseConnection, management, a
 224    }
 225}
 226
 227internal abstract class ConnectionLifecycleEndpoint(IdentityProviderConnectionManagementService management, IExternalAut
 228{
 229    protected abstract ConnectionLifecycle Action { get; }
 230    protected abstract string Permission { get; }
 231    protected abstract void ConfigureRoute();
 232
 233    public override void Configure()
 234    {
 235        ConfigureRoute();
 236        ConfigurePermissions(Permission);
 237    }
 238
 239    public override async Task HandleAsync(CancellationToken cancellationToken)
 240    {
 241        if (!ConnectionEndpointSupport.TryGetExpectedRevision(HttpContext, out var revision))
 242        {
 243            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status428PreconditionRequired, "prec
 244            return;
 245        }
 246        var existing = await management.FindAsync(Route<string>("connectionId")!, tenantAccessor.TenantId, cancellationT
 247        if (existing is not ManagementConnectionLookupResult.Found(var effective))
 248        {
 249            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status404NotFound, "not_found", "The
 250            return;
 251        }
 252        if (!ConnectionEndpointSupport.IsDatabaseOwned(effective))
 253        {
 254            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status403Forbidden, "forbidden", "Co
 255            return;
 256        }
 257
 258        var confirmOverride = string.Equals(HttpContext.Request.Query["confirmFinalLoginPathOverride"], "true", StringCo
 259        var revokeActiveSessions = string.Equals(HttpContext.Request.Query["revokeActiveSessions"], "true", StringCompar
 260        if (revokeActiveSessions && !ConnectionEndpointSupport.HasPermission(User, ExternalAuthenticationPermissions.Ses
 261        {
 262            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status403Forbidden, "forbidden", "Re
 263            return;
 264        }
 265        var result = await management.ChangeLifecycleAsync(effective.Connection.Id, Action, revision, User, tenantAccess
 266        if (result is not ManagementConnectionMutationResult.Success(var connection))
 267        {
 268            await ConnectionEndpointSupport.SendMutationResultAsync(HttpContext, result, management, cancellationToken);
 269            return;
 270        }
 271
 272        ConnectionEndpointSupport.SetEtag(HttpContext, connection.Revision);
 273        HttpContext.Response.StatusCode = StatusCodes.Status200OK;
 274        await HttpContext.Response.WriteAsJsonAsync(await ConnectionResponse.FromAsync(new EffectiveIdentityProviderConn
 275    }
 276}
 277
 278internal sealed class EnableConnection(IdentityProviderConnectionManagementService management, IExternalAuthenticationAd
 279{
 280    protected override ConnectionLifecycle Action => ConnectionLifecycle.Enabled;
 281    protected override string Permission => ExternalAuthenticationPermissions.ConnectionsUpdate;
 282    protected override void ConfigureRoute() => Post("/external-authentication/connections/{connectionId}/enable");
 283}
 284
 285internal sealed class DisableConnection(IdentityProviderConnectionManagementService management, IExternalAuthenticationA
 286{
 287    protected override ConnectionLifecycle Action => ConnectionLifecycle.Disabled;
 288    protected override string Permission => ExternalAuthenticationPermissions.ConnectionsUpdate;
 289    protected override void ConfigureRoute() => Post("/external-authentication/connections/{connectionId}/disable");
 290}
 291
 292internal sealed class ArchiveConnection(IdentityProviderConnectionManagementService management, IExternalAuthenticationA
 293{
 294    protected override ConnectionLifecycle Action => ConnectionLifecycle.Archived;
 295    protected override string Permission => ExternalAuthenticationPermissions.ConnectionsArchive;
 296    protected override void ConfigureRoute() => Delete("/external-authentication/connections/{connectionId}");
 297}
 298
 299internal sealed class RestoreConnection(IdentityProviderConnectionManagementService management, IExternalAuthenticationA
 300{
 301    protected override ConnectionLifecycle Action => ConnectionLifecycle.Draft;
 302    protected override string Permission => ExternalAuthenticationPermissions.ConnectionsArchive;
 303    protected override void ConfigureRoute() => Post("/external-authentication/connections/{connectionId}/restore");
 304}
 305
 306internal sealed class ValidateConnection(IdentityProviderConnectionManagementService management, ITenantAccessor tenantA
 307{
 308    public override void Configure()
 309    {
 310        Post("/external-authentication/connections/{connectionId}/validate");
 311        ConfigurePermissions(ExternalAuthenticationPermissions.ConnectionsRead);
 312    }
 313
 314    public override async Task HandleAsync(CancellationToken cancellationToken)
 315    {
 316        var result = await management.FindAsync(Route<string>("connectionId")!, tenantAccessor.TenantId, cancellationTok
 317        if (result is not ManagementConnectionLookupResult.Found(var connection))
 318        {
 319            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status404NotFound, "not_found", "The
 320            return;
 321        }
 322
 323        var validation = await management.ValidateAsync(connection.Connection, User, tenantAccessor.TenantId, requireCom
 324        await HttpContext.Response.WriteAsJsonAsync(new ConnectionValidationResponse(validation.IsValid, validation.Erro
 325    }
 326}
 327
 328/// <summary>Stores a write-only secret through an installed managed secret writer.</summary>
 329internal sealed class ReplaceManagedSecretBinding(
 330    IdentityProviderConnectionManagementService management,
 331    IExternalAuthenticationAdapterRegistry adapters,
 332    IIdentityProviderConnectionStore store,
 333    IEnumerable<IManagedSecretBindingWriter> managedSecretBindingWriters,
 334    ITenantAccessor tenantAccessor,
 335    ILogger<ReplaceManagedSecretBinding> logger) : ElsaEndpoint<ManagedSecretBindingRequest>
 336{
 337    private readonly IReadOnlyDictionary<string, IManagedSecretBindingWriter> _writers = managedSecretBindingWriters.ToD
 338
 339    public override void Configure()
 340    {
 341        Put("/external-authentication/connections/{connectionId}/secret-bindings/{fieldName}/managed");
 342        ConfigurePermissions(ExternalAuthenticationPermissions.ConnectionsUpdate);
 343    }
 344
 345    public override async Task HandleAsync(ManagedSecretBindingRequest request, CancellationToken cancellationToken)
 346    {
 347        if (!ConnectionEndpointSupport.TryGetExpectedRevision(HttpContext, out var revision))
 348        {
 349            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status428PreconditionRequired, "prec
 350            return;
 351        }
 352
 353        var fieldName = Route<string>("fieldName")!;
 354        if (string.IsNullOrWhiteSpace(request.Value) || string.IsNullOrWhiteSpace(request.ResolverType) || !_writers.Try
 355        {
 356            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status400BadRequest, "invalid_manage
 357            return;
 358        }
 359
 360        var lookup = await management.FindAsync(Route<string>("connectionId")!, tenantAccessor.TenantId, cancellationTok
 361        if (lookup is not ManagementConnectionLookupResult.Found(var effective))
 362        {
 363            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status404NotFound, "not_found", "The
 364            return;
 365        }
 366        if (!ConnectionEndpointSupport.IsDatabaseOwned(effective))
 367        {
 368            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status403Forbidden, "forbidden", "Co
 369            return;
 370        }
 371        if (effective.Connection.Revision != revision)
 372        {
 373            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status412PreconditionFailed, "revisi
 374            return;
 375        }
 376        if (!adapters.TryGet(effective.Connection.AdapterType, out var adapter) || !adapter.Describe().Fields.Any(x => x
 377        {
 378            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status400BadRequest, "undeclared_sec
 379            return;
 380        }
 381
 382        using var value = new SensitiveString(request.Value);
 383        var stagedBinding = await writer.StageAsync(new ManagedSecretBindingWriteRequest(effective.Connection.Id, fieldN
 384        if (effective.Connection.SecretBindings.TryGetValue(fieldName, out var liveBinding) &&
 385            string.Equals(liveBinding.ResolverType, stagedBinding.ResolverType, StringComparison.Ordinal) &&
 386            string.Equals(liveBinding.Reference, stagedBinding.Reference, StringComparison.Ordinal))
 387            throw new InvalidOperationException("A managed secret writer returned the live secret reference instead of a
 388        var candidate = IdentityProviderConnectionCloner.Clone(effective.Connection);
 389        candidate.SecretBindings[fieldName] = stagedBinding;
 390        ManagementConnectionMutationResult result;
 391        try
 392        {
 393            result = await management.UpdateAsync(candidate.Id, candidate, revision, User, tenantAccessor.TenantId, fals
 394        }
 395        catch
 396        {
 397            await CleanupAfterExceptionalFailureAsync();
 398            throw;
 399        }
 400        if (result is not ManagementConnectionMutationResult.Success(var connection))
 401        {
 402            await ManagedSecretBindingCleanup.TryRemoveAsync(writer, stagedBinding, effective.Connection.Id, logger);
 403            await ConnectionEndpointSupport.SendMutationResultAsync(HttpContext, result, management, cancellationToken);
 404            return;
 405        }
 406
 407        if (effective.Connection.SecretBindings.TryGetValue(fieldName, out var previousBinding) &&
 408            previousBinding.Ownership == SecretBindingOwnership.Managed &&
 409            _writers.TryGetValue(previousBinding.ResolverType, out var previousWriter))
 410            await ManagedSecretBindingCleanup.TryRemoveAsync(previousWriter, previousBinding, effective.Connection.Id, l
 411
 412        ConnectionEndpointSupport.SetEtag(HttpContext, connection.Revision);
 413        await HttpContext.Response.WriteAsJsonAsync(await ConnectionResponse.FromAsync(new EffectiveIdentityProviderConn
 414
 415        return;
 416
 417        async ValueTask CleanupAfterExceptionalFailureAsync()
 418        {
 419            try
 420            {
 421                var persisted = await store.FindByIdAsync(effective.Connection.Id, CancellationToken.None);
 422                var stagedBindingWasPublished = persisted?.SecretBindings.TryGetValue(fieldName, out var publishedBindin
 423                    string.Equals(publishedBinding.ResolverType, stagedBinding.ResolverType, StringComparison.Ordinal) &
 424                    string.Equals(publishedBinding.Reference, stagedBinding.Reference, StringComparison.Ordinal);
 425                if (!stagedBindingWasPublished)
 426                    await ManagedSecretBindingCleanup.TryRemoveAsync(writer, stagedBinding, effective.Connection.Id, log
 427            }
 428            catch (Exception verificationException)
 429            {
 430                logger.LogWarning(
 431                    verificationException,
 432                    "Could not verify whether staged external-authentication secret material was published for connectio
 433                    effective.Connection.Id);
 434            }
 435        }
 436    }
 437}
 438
 439internal sealed class RemoveSecretBinding(
 440    IdentityProviderConnectionManagementService management,
 441    IExternalAuthenticationAdapterRegistry adapters,
 442    IEnumerable<IManagedSecretBindingWriter> managedSecretBindingWriters,
 443    ITenantAccessor tenantAccessor,
 444    ILogger<RemoveSecretBinding> logger) : ElsaEndpointWithoutRequest
 445{
 446    private readonly IReadOnlyDictionary<string, IManagedSecretBindingWriter> _writers = managedSecretBindingWriters.ToD
 447
 448    public override void Configure()
 449    {
 450        Delete("/external-authentication/connections/{connectionId}/secret-bindings/{fieldName}");
 451        ConfigurePermissions(ExternalAuthenticationPermissions.ConnectionsUpdate);
 452    }
 453
 454    public override async Task HandleAsync(CancellationToken cancellationToken)
 455    {
 456        if (!ConnectionEndpointSupport.TryGetExpectedRevision(HttpContext, out var revision))
 457        {
 458            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status428PreconditionRequired, "prec
 459            return;
 460        }
 461        var lookup = await management.FindAsync(Route<string>("connectionId")!, tenantAccessor.TenantId, cancellationTok
 462        if (lookup is not ManagementConnectionLookupResult.Found(var effective))
 463        {
 464            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status404NotFound, "not_found", "The
 465            return;
 466        }
 467        if (!ConnectionEndpointSupport.IsDatabaseOwned(effective))
 468        {
 469            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status403Forbidden, "forbidden", "Co
 470            return;
 471        }
 472        var fieldName = Route<string>("fieldName")!;
 473        if (!effective.Connection.SecretBindings.TryGetValue(fieldName, out var existingBinding))
 474        {
 475            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status404NotFound, "not_found", "The
 476            return;
 477        }
 478        if (existingBinding.Ownership != SecretBindingOwnership.Managed)
 479        {
 480            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status403Forbidden, "external_secret
 481            return;
 482        }
 483        var candidate = Elsa.ExternalAuthentication.Services.IdentityProviderConnectionCloner.Clone(effective.Connection
 484        candidate.SecretBindings.Remove(fieldName);
 485        var result = await management.UpdateAsync(candidate.Id, candidate, revision, User, tenantAccessor.TenantId, fals
 486        if (result is not ManagementConnectionMutationResult.Success(var connection))
 487        {
 488            await ConnectionEndpointSupport.SendMutationResultAsync(HttpContext, result, management, cancellationToken);
 489            return;
 490        }
 491        if (_writers.TryGetValue(existingBinding.ResolverType, out var writer))
 492            await ManagedSecretBindingCleanup.TryRemoveAsync(writer, existingBinding, effective.Connection.Id, logger);
 493        ConnectionEndpointSupport.SetEtag(HttpContext, connection.Revision);
 494        await HttpContext.Response.WriteAsJsonAsync(await ConnectionResponse.FromAsync(new EffectiveIdentityProviderConn
 495    }
 496}
 497
 498internal static class ManagedSecretBindingCleanup
 499{
 500    public static async ValueTask TryRemoveAsync(IManagedSecretBindingWriter writer, SecretBinding binding, string conne
 501    {
 502        try
 503        {
 504            await writer.RemoveAsync(binding, CancellationToken.None);
 505        }
 506        catch (Exception exception)
 507        {
 508            logger.LogWarning(exception, "Failed to remove detached external-authentication secret material for connecti
 509        }
 510    }
 511}
 512
 513internal sealed class ConnectionListRequest
 514{
 515    public string? Search { get; set; }
 516    public string? Source { get; set; }
 517    public string? Scope { get; set; }
 518    public string? ScopeKind { get; set; }
 519    public string? TenantId { get; set; }
 520    public string? AdapterType { get; set; }
 521    public bool? Enabled { get; set; }
 522    public bool? Valid { get; set; }
 523    public bool? Shadowed { get; set; }
 524    public bool? Archived { get; set; }
 525    public string? Cursor { get; set; }
 526    public int? PageSize { get; set; }
 527}