Introduce shadow relationship management in identity provider connections
This commit is contained in:
parent
f4ca206607
commit
d63dae95bc
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue