Skip to content

Commit d63dae9

Browse files
committed
Introduce shadow relationship management in identity provider connections
1 parent f4ca206 commit d63dae9

7 files changed

Lines changed: 131 additions & 6 deletions

File tree

src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/Connections/Models/ExternalAuthenticationConnection.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ public sealed class ExternalAuthenticationConnection
2828
public bool EffectivelyEnabled { get; set; }
2929
public string Validity { get; set; } = "";
3030
public bool Shadowed { get; set; }
31+
public ExternalAuthenticationConnectionReference? ShadowedBy { get; set; }
32+
public ICollection<ExternalAuthenticationConnectionReference> Shadows { get; set; } = [];
3133
public bool Archived { get; set; }
3234
public ExternalAuthenticationPolicySelection? UnlinkedPolicy { get; set; }
3335
public ICollection<ExternalAuthenticationGrantSourceSelection> PermissionGrantSources { get; set; } = [];
@@ -38,6 +40,16 @@ public sealed class ExternalAuthenticationConnection
3840
public ExternalAuthenticationConnectionObservation? LatestObservation { get; set; }
3941
}
4042

43+
/// <summary>
44+
/// Identifies a connection that participates in an effective/shadowed relationship.
45+
/// </summary>
46+
public sealed class ExternalAuthenticationConnectionReference
47+
{
48+
public string Id { get; set; } = "";
49+
public string DisplayName { get; set; } = "";
50+
public string Source { get; set; } = "";
51+
}
52+
4153
public sealed class ExternalAuthenticationConnectionScope
4254
{
4355
public string Kind { get; set; } = "host";

src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,13 +238,22 @@ public sealed record ResolvedSecretBinding(SensitiveString Value, string Generat
238238

239239
public sealed record ConnectionSourceSnapshot(ConnectionScope Scope, string Version, IReadOnlyCollection<IdentityProviderConnection> Connections);
240240

241+
public sealed record IdentityProviderConnectionReference(
242+
string Id,
243+
string DisplayName,
244+
ConnectionSourceOwnership Ownership);
245+
241246
public sealed record EffectiveIdentityProviderConnection(
242247
IdentityProviderConnection Connection,
243248
ConnectionSourceOwnership Ownership,
244249
ConnectionScope Scope,
245250
ConnectionValidity Validity,
246251
bool IsShadowed,
247-
string SourceName);
252+
string SourceName)
253+
{
254+
public IdentityProviderConnectionReference? ShadowedBy { get; init; }
255+
public IReadOnlyCollection<IdentityProviderConnectionReference> Shadows { get; init; } = [];
256+
}
248257

249258
public sealed record EffectiveConnectionRegistry(
250259
IReadOnlyCollection<EffectiveIdentityProviderConnection> Connections,

src/modules/Elsa.ExternalAuthentication/Endpoints/Connections/ConnectionManagementModels.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ internal sealed class ConnectionResponse
113113
public bool EffectivelyEnabled { get; init; }
114114
public string Validity { get; init; } = null!;
115115
public bool Shadowed { get; init; }
116+
public ConnectionReferenceResponse? ShadowedBy { get; init; }
117+
public IReadOnlyCollection<ConnectionReferenceResponse> Shadows { get; init; } = [];
116118
public bool Archived { get; init; }
117119
public PolicySelection? UnlinkedPolicy { get; init; }
118120
public IReadOnlyCollection<GrantSourceSelection> PermissionGrantSources { get; init; } = [];
@@ -163,6 +165,8 @@ public static async ValueTask<ConnectionResponse> FromAsync(EffectiveIdentityPro
163165
EffectivelyEnabled = effective.Connection.IsEnabled && !effective.Connection.ArchivedAt.HasValue && !effective.IsShadowed && effective.Validity != ConnectionValidity.Invalid,
164166
Validity = effective.Validity.ToString().ToLowerInvariant(),
165167
Shadowed = effective.IsShadowed,
168+
ShadowedBy = effective.ShadowedBy is null ? null : ConnectionReferenceResponse.From(effective.ShadowedBy),
169+
Shadows = effective.Shadows.Select(ConnectionReferenceResponse.From).ToArray(),
166170
Archived = effective.Connection.ArchivedAt.HasValue,
167171
UnlinkedPolicy = effective.Connection.UnlinkedPolicy,
168172
PermissionGrantSources = effective.Connection.PermissionGrantSources.ToArray(),
@@ -191,6 +195,15 @@ public static async ValueTask<ConnectionResponse> FromAsync(EffectiveIdentityPro
191195
};
192196
}
193197

198+
internal sealed record ConnectionReferenceResponse(string Id, string DisplayName, string Source)
199+
{
200+
public static ConnectionReferenceResponse From(IdentityProviderConnectionReference reference) =>
201+
new(
202+
reference.Id,
203+
reference.DisplayName,
204+
reference.Ownership == ConnectionSourceOwnership.Configuration ? "configuration" : "database");
205+
}
206+
194207
internal sealed record ConnectionObservationResponse(string Status, DateTimeOffset ObservedAt, string TestedMaterialRevision, bool IsStale, string Category, string Summary);
195208
internal sealed record ConnectionValidationResponse(bool Valid, IReadOnlyCollection<ConnectionValidationError> Errors, IReadOnlyCollection<string> Warnings);
196209
internal sealed record ConnectionListResponse(IReadOnlyCollection<ConnectionResponse> Items, string? NextCursor);

src/modules/Elsa.ExternalAuthentication/Services/DefaultIdentityProviderConnectionRegistry.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,29 @@ public async ValueTask<EffectiveConnectionRegistry> GetAsync(string targetTenant
4747

4848
var explicitOverride = candidatesForKey.FirstOrDefault(x => x.Source.Ownership == ConnectionSourceOwnership.Database && x.Connection.OverridesConfigurationConnection && !x.Connection.ArchivedAt.HasValue);
4949
var preferred = explicitOverride ?? candidatesForKey.FirstOrDefault(x => x.Source.Ownership == ConnectionSourceOwnership.Configuration) ?? candidatesForKey.First();
50+
var preferredReference = ToReference(preferred);
51+
var shadowedReferences = hasInheritedScopeCollision
52+
? []
53+
: candidatesForKey
54+
.Where(candidate => !ReferenceEquals(candidate, preferred))
55+
.Select(ToReference)
56+
.ToArray();
5057

5158
for (var index = 0; index < candidatesForKey.Length; index++)
5259
{
5360
var candidate = candidatesForKey[index];
61+
var isShadowed = !hasInheritedScopeCollision && !ReferenceEquals(candidate, preferred);
5462
connections.Add(new EffectiveIdentityProviderConnection(
5563
candidate.Connection,
5664
candidate.Source.Ownership,
5765
candidate.Scope,
5866
hasInheritedScopeCollision ? ConnectionValidity.Invalid : ConnectionValidity.Unknown,
59-
!hasInheritedScopeCollision && !ReferenceEquals(candidate, preferred),
60-
candidate.Source.Name));
67+
isShadowed,
68+
candidate.Source.Name)
69+
{
70+
ShadowedBy = isShadowed ? preferredReference : null,
71+
Shadows = isShadowed ? [] : shadowedReferences
72+
});
6173
}
6274
}
6375

@@ -132,6 +144,8 @@ private static IReadOnlyCollection<LoginMethod> ToLoginMethods(IEnumerable<Effec
132144

133145
private static bool IsInScope(IdentityProviderConnection connection, ConnectionScope scope) => string.Equals(connection.TenantId, scope.TenantId, StringComparison.Ordinal);
134146
private static int GetOwnershipPriority(ConnectionSourceOwnership ownership) => ownership == ConnectionSourceOwnership.Configuration ? 0 : 1;
147+
private static IdentityProviderConnectionReference ToReference(Candidate candidate) =>
148+
new(candidate.Connection.Id, candidate.Connection.DisplayName, candidate.Source.Ownership);
135149

136150
private sealed record Candidate(IIdentityProviderConnectionSource Source, ConnectionScope Scope, IdentityProviderConnection Connection);
137151
}

test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,10 @@ public async Task ShadowedDatabaseConnectionAdvertisesPromotionCapabilityOnlyWhe
217217
_registry.ConfigurationConnection = ConfigurationConnection("contoso");
218218
await _store.CreateAsync(DatabaseConnection(connectionId, ConnectionScope.HostTenantId, "contoso"));
219219

220-
Assert.False((await GetConnectionResponseAsync(connectionId)).CanPromoteToConfigurationOverride);
220+
var shadowedDatabase = await GetConnectionResponseAsync(connectionId);
221+
Assert.False(shadowedDatabase.CanPromoteToConfigurationOverride);
222+
Assert.Equal("configuration-contoso", shadowedDatabase.ShadowedBy?.Id);
223+
Assert.Equal(connectionId, Assert.Single((await GetConnectionResponseAsync("configuration-contoso")).Shadows).Id);
221224

222225
_app!.Services.GetRequiredService<IOptions<ExternalAuthenticationOptions>>().Value.AllowConfigurationConnectionOverrides = true;
223226
Assert.True((await GetConnectionResponseAsync(connectionId)).CanPromoteToConfigurationOverride);
@@ -646,6 +649,15 @@ private sealed class ConnectionDocument
646649
public bool EnabledIntent { get; set; }
647650
public int AdapterSettingsVersion { get; set; }
648651
public bool CanPromoteToConfigurationOverride { get; set; }
652+
public ConnectionReferenceDocument? ShadowedBy { get; set; }
653+
public ICollection<ConnectionReferenceDocument> Shadows { get; set; } = [];
654+
}
655+
656+
private sealed class ConnectionReferenceDocument
657+
{
658+
public string Id { get; set; } = null!;
659+
public string DisplayName { get; set; } = null!;
660+
public string Source { get; set; } = null!;
649661
}
650662

651663
private async Task<ConnectionDocument> GetConnectionResponseAsync(string connectionId)
@@ -867,7 +879,21 @@ public async ValueTask<EffectiveConnectionRegistry> GetAsync(string targetTenant
867879
var preferred = candidatesForKey.FirstOrDefault(x => x.Ownership == ConnectionSourceOwnership.Database && x.Connection.OverridesConfigurationConnection && !x.Connection.ArchivedAt.HasValue)
868880
?? candidatesForKey.FirstOrDefault(x => x.Ownership == ConnectionSourceOwnership.Configuration)
869881
?? candidatesForKey[0];
870-
return candidatesForKey.Select(x => x with { IsShadowed = !ReferenceEquals(x, preferred) });
882+
var preferredReference = ToReference(preferred);
883+
var shadowedReferences = candidatesForKey
884+
.Where(candidate => !ReferenceEquals(candidate, preferred))
885+
.Select(ToReference)
886+
.ToArray();
887+
return candidatesForKey.Select(candidate =>
888+
{
889+
var isShadowed = !ReferenceEquals(candidate, preferred);
890+
return candidate with
891+
{
892+
IsShadowed = isShadowed,
893+
ShadowedBy = isShadowed ? preferredReference : null,
894+
Shadows = isShadowed ? [] : shadowedReferences
895+
};
896+
});
871897
})
872898
.ToArray();
873899
return new EffectiveConnectionRegistry(connections, [], "test");
@@ -876,5 +902,7 @@ public async ValueTask<EffectiveConnectionRegistry> GetAsync(string targetTenant
876902
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));
877903
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));
878904
private static ConnectionScope ToScope(string tenantId) => tenantId == ConnectionScope.HostTenantId ? ConnectionScope.Host : tenantId.Length == 0 ? ConnectionScope.DefaultTenant : new ConnectionScope(ConnectionScopeKind.Tenant, tenantId);
905+
private static IdentityProviderConnectionReference ToReference(EffectiveIdentityProviderConnection connection) =>
906+
new(connection.Connection.Id, connection.Connection.DisplayName, connection.Ownership);
879907
}
880908
}

test/unit/Elsa.ExternalAuthentication.UnitTests/Clients/ExternalAuthenticationClientContractTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Text.Json;
2+
using Elsa.Api.Client.Resources.ExternalAuthentication.Connections.Models;
23
using Elsa.Api.Client.Resources.ExternalAuthentication.Connections.Requests;
34

45
namespace Elsa.ExternalAuthentication.UnitTests.Clients;
@@ -17,4 +18,29 @@ public void NewSaveRequestSerializesHostScope()
1718

1819
Assert.Equal("host", document.RootElement.GetProperty("scope").GetProperty("kind").GetString());
1920
}
21+
22+
[Fact]
23+
public void ConnectionDeserializesNamedShadowRelationships()
24+
{
25+
var connection = JsonSerializer.Deserialize<ExternalAuthenticationConnection>(
26+
"""
27+
{
28+
"id": "deployment-keycloak",
29+
"shadowed": true,
30+
"shadowedBy": {
31+
"id": "database-keycloak",
32+
"displayName": "Keycloak",
33+
"source": "database"
34+
},
35+
"shadows": []
36+
}
37+
""",
38+
new JsonSerializerOptions(JsonSerializerDefaults.Web));
39+
40+
Assert.NotNull(connection);
41+
Assert.Equal("database-keycloak", connection.ShadowedBy?.Id);
42+
Assert.Equal("Keycloak", connection.ShadowedBy?.DisplayName);
43+
Assert.Equal("database", connection.ShadowedBy?.Source);
44+
Assert.Empty(connection.Shadows);
45+
}
2046
}

test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/DefaultIdentityProviderConnectionRegistryTests.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,33 @@ public async Task ConfigurationConnectionsShadowDatabaseConnectionsWithTheSameKe
1919

2020
var effective = Assert.Single(result.Connections, x => !x.IsShadowed);
2121
Assert.Equal("configuration-oidc", effective.Connection.Id);
22-
Assert.Single(result.Connections, x => x.IsShadowed);
22+
Assert.Equal("database-oidc", Assert.Single(effective.Shadows).Id);
23+
var shadowed = Assert.Single(result.Connections, x => x.IsShadowed);
24+
Assert.Equal("configuration-oidc", Assert.IsType<IdentityProviderConnectionReference>(shadowed.ShadowedBy).Id);
2325
Assert.Equal(["configuration-oidc"], result.LoginMethods.Select(x => x.Id));
2426
}
2527

28+
[Fact]
29+
public async Task ExplicitDatabaseOverrideIdentifiesItsShadowedConfigurationConnection()
30+
{
31+
var configuration = ExternalAuthenticationTestData.CreateConnection("configuration-oidc", ConnectionScope.HostTenantId, "oidc");
32+
var database = ExternalAuthenticationTestData.CreateConnection("database-oidc", ConnectionScope.HostTenantId, "OIDC");
33+
database.OverridesConfigurationConnection = true;
34+
var registry = CreateRegistry(
35+
new TestConnectionSource("database", ConnectionSourceOwnership.Database, [(ConnectionScope.Host, [database])]),
36+
new TestConnectionSource("configuration", ConnectionSourceOwnership.Configuration, [(ConnectionScope.Host, [configuration])]));
37+
38+
var result = await registry.GetAsync("tenant-a");
39+
40+
var effective = Assert.Single(result.Connections, x => !x.IsShadowed);
41+
Assert.Equal("database-oidc", effective.Connection.Id);
42+
Assert.Equal("configuration-oidc", Assert.Single(effective.Shadows).Id);
43+
var shadowed = Assert.Single(result.Connections, x => x.IsShadowed);
44+
var shadowedBy = Assert.IsType<IdentityProviderConnectionReference>(shadowed.ShadowedBy);
45+
Assert.Equal("database-oidc", shadowedBy.Id);
46+
Assert.Equal(ConnectionSourceOwnership.Database, shadowedBy.Ownership);
47+
}
48+
2649
[Fact]
2750
public async Task ConfigurationPreferredConnectionWinsOverDatabasePreferredConnection()
2851
{

0 commit comments

Comments
 (0)