Introduce shadow relationship management in identity provider connections

This commit is contained in:
Sipke Schoorstra 2026-07-31 01:00:36 +02:00
parent f4ca206607
commit d63dae95bc
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
7 changed files with 131 additions and 6 deletions

View file

@ -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<ExternalAuthenticationConnectionReference> Shadows { get; set; } = [];
public bool Archived { get; set; }
public ExternalAuthenticationPolicySelection? UnlinkedPolicy { get; set; }
public ICollection<ExternalAuthenticationGrantSourceSelection> PermissionGrantSources { get; set; } = [];
@ -38,6 +40,16 @@ public sealed class ExternalAuthenticationConnection
public ExternalAuthenticationConnectionObservation? LatestObservation { get; set; }
}
/// <summary>
/// Identifies a connection that participates in an effective/shadowed relationship.
/// </summary>
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";

View file

@ -238,13 +238,22 @@ public sealed record ResolvedSecretBinding(SensitiveString Value, string Generat
public sealed record ConnectionSourceSnapshot(ConnectionScope Scope, string Version, IReadOnlyCollection<IdentityProviderConnection> 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<IdentityProviderConnectionReference> Shadows { get; init; } = [];
}
public sealed record EffectiveConnectionRegistry(
IReadOnlyCollection<EffectiveIdentityProviderConnection> Connections,

View file

@ -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<ConnectionReferenceResponse> Shadows { get; init; } = [];
public bool Archived { get; init; }
public PolicySelection? UnlinkedPolicy { get; init; }
public IReadOnlyCollection<GrantSourceSelection> 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<ConnectionValidationError> Errors, IReadOnlyCollection<string> Warnings);
internal sealed record ConnectionListResponse(IReadOnlyCollection<ConnectionResponse> Items, string? NextCursor);

View file

@ -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);
}

View file

@ -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<IOptions<ExternalAuthenticationOptions>>().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<ConnectionReferenceDocument> 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<ConnectionDocument> 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<EffectiveIdentityProviderConnection?> 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<EffectiveIdentityProviderConnection?> 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);
}
}

View file

@ -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<ExternalAuthenticationConnection>(
"""
{
"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);
}
}

View file

@ -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<IdentityProviderConnectionReference>(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<IdentityProviderConnectionReference>(shadowed.ShadowedBy);
Assert.Equal("database-oidc", shadowedBy.Id);
Assert.Equal(ConnectionSourceOwnership.Database, shadowedBy.Ownership);
}
[Fact]
public async Task ConfigurationPreferredConnectionWinsOverDatabasePreferredConnection()
{