diff --git a/src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/Connections/Models/ExternalAuthenticationConnection.cs b/src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/Connections/Models/ExternalAuthenticationConnection.cs index 50bf7baaa..cb0ca11fc 100644 --- a/src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/Connections/Models/ExternalAuthenticationConnection.cs +++ b/src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/Connections/Models/ExternalAuthenticationConnection.cs @@ -28,6 +28,8 @@ public sealed class ExternalAuthenticationConnection public bool EffectivelyEnabled { get; set; } public string Validity { get; set; } = ""; public bool Shadowed { get; set; } + public ExternalAuthenticationConnectionReference? ShadowedBy { get; set; } + public ICollection Shadows { get; set; } = []; public bool Archived { get; set; } public ExternalAuthenticationPolicySelection? UnlinkedPolicy { get; set; } public ICollection PermissionGrantSources { get; set; } = []; @@ -38,6 +40,16 @@ public sealed class ExternalAuthenticationConnection public ExternalAuthenticationConnectionObservation? LatestObservation { get; set; } } +/// +/// Identifies a connection that participates in an effective/shadowed relationship. +/// +public sealed class ExternalAuthenticationConnectionReference +{ + public string Id { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string Source { get; set; } = ""; +} + public sealed class ExternalAuthenticationConnectionScope { public string Kind { get; set; } = "host"; diff --git a/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs b/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs index e55f1646e..ae949df08 100644 --- a/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs +++ b/src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs @@ -238,13 +238,22 @@ public sealed record ResolvedSecretBinding(SensitiveString Value, string Generat public sealed record ConnectionSourceSnapshot(ConnectionScope Scope, string Version, IReadOnlyCollection Connections); +public sealed record IdentityProviderConnectionReference( + string Id, + string DisplayName, + ConnectionSourceOwnership Ownership); + public sealed record EffectiveIdentityProviderConnection( IdentityProviderConnection Connection, ConnectionSourceOwnership Ownership, ConnectionScope Scope, ConnectionValidity Validity, bool IsShadowed, - string SourceName); + string SourceName) +{ + public IdentityProviderConnectionReference? ShadowedBy { get; init; } + public IReadOnlyCollection Shadows { get; init; } = []; +} public sealed record EffectiveConnectionRegistry( IReadOnlyCollection Connections, diff --git a/src/modules/Elsa.ExternalAuthentication/Endpoints/Connections/ConnectionManagementModels.cs b/src/modules/Elsa.ExternalAuthentication/Endpoints/Connections/ConnectionManagementModels.cs index aa22b5063..d6fcfc559 100644 --- a/src/modules/Elsa.ExternalAuthentication/Endpoints/Connections/ConnectionManagementModels.cs +++ b/src/modules/Elsa.ExternalAuthentication/Endpoints/Connections/ConnectionManagementModels.cs @@ -113,6 +113,8 @@ internal sealed class ConnectionResponse public bool EffectivelyEnabled { get; init; } public string Validity { get; init; } = null!; public bool Shadowed { get; init; } + public ConnectionReferenceResponse? ShadowedBy { get; init; } + public IReadOnlyCollection Shadows { get; init; } = []; public bool Archived { get; init; } public PolicySelection? UnlinkedPolicy { get; init; } public IReadOnlyCollection PermissionGrantSources { get; init; } = []; @@ -163,6 +165,8 @@ internal sealed class ConnectionResponse EffectivelyEnabled = effective.Connection.IsEnabled && !effective.Connection.ArchivedAt.HasValue && !effective.IsShadowed && effective.Validity != ConnectionValidity.Invalid, Validity = effective.Validity.ToString().ToLowerInvariant(), Shadowed = effective.IsShadowed, + ShadowedBy = effective.ShadowedBy is null ? null : ConnectionReferenceResponse.From(effective.ShadowedBy), + Shadows = effective.Shadows.Select(ConnectionReferenceResponse.From).ToArray(), Archived = effective.Connection.ArchivedAt.HasValue, UnlinkedPolicy = effective.Connection.UnlinkedPolicy, PermissionGrantSources = effective.Connection.PermissionGrantSources.ToArray(), @@ -191,6 +195,15 @@ internal sealed class ConnectionResponse }; } +internal sealed record ConnectionReferenceResponse(string Id, string DisplayName, string Source) +{ + public static ConnectionReferenceResponse From(IdentityProviderConnectionReference reference) => + new( + reference.Id, + reference.DisplayName, + reference.Ownership == ConnectionSourceOwnership.Configuration ? "configuration" : "database"); +} + internal sealed record ConnectionObservationResponse(string Status, DateTimeOffset ObservedAt, string TestedMaterialRevision, bool IsStale, string Category, string Summary); internal sealed record ConnectionValidationResponse(bool Valid, IReadOnlyCollection Errors, IReadOnlyCollection Warnings); internal sealed record ConnectionListResponse(IReadOnlyCollection Items, string? NextCursor); diff --git a/src/modules/Elsa.ExternalAuthentication/Services/DefaultIdentityProviderConnectionRegistry.cs b/src/modules/Elsa.ExternalAuthentication/Services/DefaultIdentityProviderConnectionRegistry.cs index 6024f6598..269664c7b 100644 --- a/src/modules/Elsa.ExternalAuthentication/Services/DefaultIdentityProviderConnectionRegistry.cs +++ b/src/modules/Elsa.ExternalAuthentication/Services/DefaultIdentityProviderConnectionRegistry.cs @@ -47,17 +47,29 @@ public sealed class DefaultIdentityProviderConnectionRegistry( var explicitOverride = candidatesForKey.FirstOrDefault(x => x.Source.Ownership == ConnectionSourceOwnership.Database && x.Connection.OverridesConfigurationConnection && !x.Connection.ArchivedAt.HasValue); var preferred = explicitOverride ?? candidatesForKey.FirstOrDefault(x => x.Source.Ownership == ConnectionSourceOwnership.Configuration) ?? candidatesForKey.First(); + var preferredReference = ToReference(preferred); + var shadowedReferences = hasInheritedScopeCollision + ? [] + : candidatesForKey + .Where(candidate => !ReferenceEquals(candidate, preferred)) + .Select(ToReference) + .ToArray(); for (var index = 0; index < candidatesForKey.Length; index++) { var candidate = candidatesForKey[index]; + var isShadowed = !hasInheritedScopeCollision && !ReferenceEquals(candidate, preferred); connections.Add(new EffectiveIdentityProviderConnection( candidate.Connection, candidate.Source.Ownership, candidate.Scope, hasInheritedScopeCollision ? ConnectionValidity.Invalid : ConnectionValidity.Unknown, - !hasInheritedScopeCollision && !ReferenceEquals(candidate, preferred), - candidate.Source.Name)); + isShadowed, + candidate.Source.Name) + { + ShadowedBy = isShadowed ? preferredReference : null, + Shadows = isShadowed ? [] : shadowedReferences + }); } } @@ -132,6 +144,8 @@ public sealed class DefaultIdentityProviderConnectionRegistry( private static bool IsInScope(IdentityProviderConnection connection, ConnectionScope scope) => string.Equals(connection.TenantId, scope.TenantId, StringComparison.Ordinal); private static int GetOwnershipPriority(ConnectionSourceOwnership ownership) => ownership == ConnectionSourceOwnership.Configuration ? 0 : 1; + private static IdentityProviderConnectionReference ToReference(Candidate candidate) => + new(candidate.Connection.Id, candidate.Connection.DisplayName, candidate.Source.Ownership); private sealed record Candidate(IIdentityProviderConnectionSource Source, ConnectionScope Scope, IdentityProviderConnection Connection); } diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs index 00216a423..8417cdc31 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs @@ -217,7 +217,10 @@ public class ConnectionManagementTests : IAsyncLifetime _registry.ConfigurationConnection = ConfigurationConnection("contoso"); await _store.CreateAsync(DatabaseConnection(connectionId, ConnectionScope.HostTenantId, "contoso")); - Assert.False((await GetConnectionResponseAsync(connectionId)).CanPromoteToConfigurationOverride); + var shadowedDatabase = await GetConnectionResponseAsync(connectionId); + Assert.False(shadowedDatabase.CanPromoteToConfigurationOverride); + Assert.Equal("configuration-contoso", shadowedDatabase.ShadowedBy?.Id); + Assert.Equal(connectionId, Assert.Single((await GetConnectionResponseAsync("configuration-contoso")).Shadows).Id); _app!.Services.GetRequiredService>().Value.AllowConfigurationConnectionOverrides = true; Assert.True((await GetConnectionResponseAsync(connectionId)).CanPromoteToConfigurationOverride); @@ -646,6 +649,15 @@ public class ConnectionManagementTests : IAsyncLifetime public bool EnabledIntent { get; set; } public int AdapterSettingsVersion { get; set; } public bool CanPromoteToConfigurationOverride { get; set; } + public ConnectionReferenceDocument? ShadowedBy { get; set; } + public ICollection Shadows { get; set; } = []; + } + + private sealed class ConnectionReferenceDocument + { + public string Id { get; set; } = null!; + public string DisplayName { get; set; } = null!; + public string Source { get; set; } = null!; } private async Task GetConnectionResponseAsync(string connectionId) @@ -867,7 +879,21 @@ public class ConnectionManagementTests : IAsyncLifetime var preferred = candidatesForKey.FirstOrDefault(x => x.Ownership == ConnectionSourceOwnership.Database && x.Connection.OverridesConfigurationConnection && !x.Connection.ArchivedAt.HasValue) ?? candidatesForKey.FirstOrDefault(x => x.Ownership == ConnectionSourceOwnership.Configuration) ?? candidatesForKey[0]; - return candidatesForKey.Select(x => x with { IsShadowed = !ReferenceEquals(x, preferred) }); + var preferredReference = ToReference(preferred); + var shadowedReferences = candidatesForKey + .Where(candidate => !ReferenceEquals(candidate, preferred)) + .Select(ToReference) + .ToArray(); + return candidatesForKey.Select(candidate => + { + var isShadowed = !ReferenceEquals(candidate, preferred); + return candidate with + { + IsShadowed = isShadowed, + ShadowedBy = isShadowed ? preferredReference : null, + Shadows = isShadowed ? [] : shadowedReferences + }; + }); }) .ToArray(); return new EffectiveConnectionRegistry(connections, [], "test"); @@ -876,5 +902,7 @@ public class ConnectionManagementTests : IAsyncLifetime public async ValueTask FindByKeyAsync(string targetTenantId, string key, CancellationToken cancellationToken = default) => (await GetAsync(targetTenantId, cancellationToken)).Connections.FirstOrDefault(x => string.Equals(x.Connection.Key, key, StringComparison.Ordinal)); public async ValueTask FindByIdAsync(string targetTenantId, string connectionId, CancellationToken cancellationToken = default) => (await GetAsync(targetTenantId, cancellationToken)).Connections.FirstOrDefault(x => string.Equals(x.Connection.Id, connectionId, StringComparison.Ordinal)); private static ConnectionScope ToScope(string tenantId) => tenantId == ConnectionScope.HostTenantId ? ConnectionScope.Host : tenantId.Length == 0 ? ConnectionScope.DefaultTenant : new ConnectionScope(ConnectionScopeKind.Tenant, tenantId); + private static IdentityProviderConnectionReference ToReference(EffectiveIdentityProviderConnection connection) => + new(connection.Connection.Id, connection.Connection.DisplayName, connection.Ownership); } } diff --git a/test/unit/Elsa.ExternalAuthentication.UnitTests/Clients/ExternalAuthenticationClientContractTests.cs b/test/unit/Elsa.ExternalAuthentication.UnitTests/Clients/ExternalAuthenticationClientContractTests.cs index bc1862c69..67dc896b4 100644 --- a/test/unit/Elsa.ExternalAuthentication.UnitTests/Clients/ExternalAuthenticationClientContractTests.cs +++ b/test/unit/Elsa.ExternalAuthentication.UnitTests/Clients/ExternalAuthenticationClientContractTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Elsa.Api.Client.Resources.ExternalAuthentication.Connections.Models; using Elsa.Api.Client.Resources.ExternalAuthentication.Connections.Requests; namespace Elsa.ExternalAuthentication.UnitTests.Clients; @@ -17,4 +18,29 @@ public class ExternalAuthenticationClientContractTests Assert.Equal("host", document.RootElement.GetProperty("scope").GetProperty("kind").GetString()); } + + [Fact] + public void ConnectionDeserializesNamedShadowRelationships() + { + var connection = JsonSerializer.Deserialize( + """ + { + "id": "deployment-keycloak", + "shadowed": true, + "shadowedBy": { + "id": "database-keycloak", + "displayName": "Keycloak", + "source": "database" + }, + "shadows": [] + } + """, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + Assert.NotNull(connection); + Assert.Equal("database-keycloak", connection.ShadowedBy?.Id); + Assert.Equal("Keycloak", connection.ShadowedBy?.DisplayName); + Assert.Equal("database", connection.ShadowedBy?.Source); + Assert.Empty(connection.Shadows); + } } diff --git a/test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/DefaultIdentityProviderConnectionRegistryTests.cs b/test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/DefaultIdentityProviderConnectionRegistryTests.cs index a51ac1f2a..d838398d1 100644 --- a/test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/DefaultIdentityProviderConnectionRegistryTests.cs +++ b/test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/DefaultIdentityProviderConnectionRegistryTests.cs @@ -19,10 +19,33 @@ public class DefaultIdentityProviderConnectionRegistryTests var effective = Assert.Single(result.Connections, x => !x.IsShadowed); Assert.Equal("configuration-oidc", effective.Connection.Id); - Assert.Single(result.Connections, x => x.IsShadowed); + Assert.Equal("database-oidc", Assert.Single(effective.Shadows).Id); + var shadowed = Assert.Single(result.Connections, x => x.IsShadowed); + Assert.Equal("configuration-oidc", Assert.IsType(shadowed.ShadowedBy).Id); Assert.Equal(["configuration-oidc"], result.LoginMethods.Select(x => x.Id)); } + [Fact] + public async Task ExplicitDatabaseOverrideIdentifiesItsShadowedConfigurationConnection() + { + var configuration = ExternalAuthenticationTestData.CreateConnection("configuration-oidc", ConnectionScope.HostTenantId, "oidc"); + var database = ExternalAuthenticationTestData.CreateConnection("database-oidc", ConnectionScope.HostTenantId, "OIDC"); + database.OverridesConfigurationConnection = true; + var registry = CreateRegistry( + new TestConnectionSource("database", ConnectionSourceOwnership.Database, [(ConnectionScope.Host, [database])]), + new TestConnectionSource("configuration", ConnectionSourceOwnership.Configuration, [(ConnectionScope.Host, [configuration])])); + + var result = await registry.GetAsync("tenant-a"); + + var effective = Assert.Single(result.Connections, x => !x.IsShadowed); + Assert.Equal("database-oidc", effective.Connection.Id); + Assert.Equal("configuration-oidc", Assert.Single(effective.Shadows).Id); + var shadowed = Assert.Single(result.Connections, x => x.IsShadowed); + var shadowedBy = Assert.IsType(shadowed.ShadowedBy); + Assert.Equal("database-oidc", shadowedBy.Id); + Assert.Equal(ConnectionSourceOwnership.Database, shadowedBy.Ownership); + } + [Fact] public async Task ConfigurationPreferredConnectionWinsOverDatabasePreferredConnection() {