From 0fa7657b3045d3f8e60157665f6b81f721fb90ec Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 27 Jul 2026 00:40:23 +0200 Subject: [PATCH] Harden external identity link management --- .../contracts/rest-api.md | 9 +++-- .../IdentityLinks/IdentityLinkEndpoints.cs | 33 +++++++++---------- .../ExternalIdentityLinkManagementService.cs | 16 +++++++-- .../Links/ExternalIdentityLinkTests.cs | 29 +++++++++++++--- 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/specs/012-external-authentication/contracts/rest-api.md b/specs/012-external-authentication/contracts/rest-api.md index 9df593cb0..86937226f 100644 --- a/specs/012-external-authentication/contracts/rest-api.md +++ b/specs/012-external-authentication/contracts/rest-api.md @@ -540,11 +540,12 @@ Requires `external-authentication:links:manage`. Tenant comes from authenticated No password, role, permission, external-link, or cross-tenant data is returned. -### List/Create/Delete +### List/Create/Replace/Delete ```http GET /external-authentication/identity-links?userId=&connectionKey=&cursor=&pageSize=100 POST /external-authentication/identity-links +POST /external-authentication/identity-links/{linkId}/replace DELETE /external-authentication/identity-links/{linkId} ``` @@ -561,7 +562,11 @@ Create: } ``` -The subject is accepted only over TLS, normalized and immediately transformed to the stored keyed hash; it is never returned. Duplicate tuple-to-same-user is idempotent `200`; tuple-to-different-user is `409 conflict`. Delete requires explicit Studio confirmation and returns `204`. +Replace accepts the same body. It atomically removes the identified tenant-scoped link and creates a new link with a new ID and `createdAt`; `lastSignedInAt` is reset to `null`. If the original link or a requested user/connection cannot be resolved, it returns `404` with `{"error":"not_found","message":"The requested resource was not found."}`. If the requested tuple belongs to any other link, including one for the same user, it returns `409 conflict`. Validation, not-found, and conflict responses leave the original link unchanged. + +Manual create and replace operations accept effective, nonarchived connections even when they are disabled or invalid. Shadowed, archived, and cross-tenant definitions remain unavailable. + +The subject is accepted only over TLS, normalized and immediately transformed to the stored keyed hash; it is never returned. Duplicate tuple-to-same-user is idempotent `200`; tuple-to-different-user is `409 conflict`. A successful replace returns `201` with the new link resource. Delete requires explicit Studio confirmation and returns `204`. ## Role Lifecycle Guard diff --git a/src/modules/Elsa.ExternalAuthentication/Endpoints/IdentityLinks/IdentityLinkEndpoints.cs b/src/modules/Elsa.ExternalAuthentication/Endpoints/IdentityLinks/IdentityLinkEndpoints.cs index 8c1220e79..8683f8ce1 100644 --- a/src/modules/Elsa.ExternalAuthentication/Endpoints/IdentityLinks/IdentityLinkEndpoints.cs +++ b/src/modules/Elsa.ExternalAuthentication/Endpoints/IdentityLinks/IdentityLinkEndpoints.cs @@ -83,7 +83,7 @@ internal sealed class PrelinkIdentityLink(ExternalIdentityLinkManagementService } catch (ArgumentException) { - await SendErrorAsync(StatusCodes.Status400BadRequest, "validation_failed", "The external identity tuple is invalid.", cancellationToken); + await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status400BadRequest, "validation_failed", "The external identity tuple is invalid.", cancellationToken); return; } @@ -94,20 +94,14 @@ internal sealed class PrelinkIdentityLink(ExternalIdentityLinkManagementService await HttpContext.Response.WriteAsJsonAsync(IdentityLinkDocument.From(link), cancellationToken); return; case ExternalIdentityLinkPrelinkResult.Conflict: - await SendErrorAsync(StatusCodes.Status409Conflict, "conflict", "The external identity is already linked to another user.", cancellationToken); + await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status409Conflict, "conflict", "The external identity is already linked to another user.", cancellationToken); return; default: // Do not reveal whether a user or connection exists outside the trusted tenant scope. - await SendErrorAsync(StatusCodes.Status404NotFound, "not_found", "The requested resource was not found.", cancellationToken); + await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status404NotFound, "not_found", "The requested resource was not found.", cancellationToken); return; } } - - private Task SendErrorAsync(int status, string error, string message, CancellationToken cancellationToken) - { - HttpContext.Response.StatusCode = status; - return HttpContext.Response.WriteAsJsonAsync(new IdentityLinkError(error, message), cancellationToken); - } } internal sealed class ReplaceIdentityLink(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccessor) : ElsaEndpoint @@ -135,7 +129,7 @@ internal sealed class ReplaceIdentityLink(ExternalIdentityLinkManagementService } catch (ArgumentException) { - await SendErrorAsync(StatusCodes.Status400BadRequest, "validation_failed", "The external identity tuple is invalid.", cancellationToken); + await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status400BadRequest, "validation_failed", "The external identity tuple is invalid.", cancellationToken); return; } @@ -146,19 +140,13 @@ internal sealed class ReplaceIdentityLink(ExternalIdentityLinkManagementService await HttpContext.Response.WriteAsJsonAsync(IdentityLinkDocument.From(success.NewLink), cancellationToken); return; case ExternalIdentityLinkReplaceResult.Conflict: - await SendErrorAsync(StatusCodes.Status409Conflict, "conflict", "The external identity is already linked.", cancellationToken); + await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status409Conflict, "conflict", "The external identity is already linked.", cancellationToken); return; default: - await SendErrorAsync(StatusCodes.Status404NotFound, "not_found", "The requested resource was not found.", cancellationToken); + await IdentityLinkEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status404NotFound, "not_found", "The requested resource was not found.", cancellationToken); return; } } - - private Task SendErrorAsync(int status, string error, string message, CancellationToken cancellationToken) - { - HttpContext.Response.StatusCode = status; - return HttpContext.Response.WriteAsJsonAsync(new IdentityLinkError(error, message), cancellationToken); - } } internal sealed class DeleteIdentityLink(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccessor) : ElsaEndpointWithoutRequest @@ -218,6 +206,15 @@ internal sealed record IdentityLinkError(string Error, string Message); internal sealed record IdentityLinkCursor(DateTimeOffset CreatedAt, string Id); internal sealed record UserCursor(string DisplayName, string Id); +internal static class IdentityLinkEndpointSupport +{ + public static Task SendErrorAsync(HttpContext httpContext, int status, string error, string message, CancellationToken cancellationToken) + { + httpContext.Response.StatusCode = status; + return httpContext.Response.WriteAsJsonAsync(new IdentityLinkError(error, message), cancellationToken); + } +} + internal static class IdentityLinkPagination { public static string EncodeCursor(IdentityLinkCursor cursor) => Encode(cursor); diff --git a/src/modules/Elsa.ExternalAuthentication/Services/ExternalIdentityLinkManagementService.cs b/src/modules/Elsa.ExternalAuthentication/Services/ExternalIdentityLinkManagementService.cs index 529a6ceef..c751de285 100644 --- a/src/modules/Elsa.ExternalAuthentication/Services/ExternalIdentityLinkManagementService.cs +++ b/src/modules/Elsa.ExternalAuthentication/Services/ExternalIdentityLinkManagementService.cs @@ -55,8 +55,8 @@ public sealed class ExternalIdentityLinkManagementService( if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal)) return new ExternalIdentityLinkPrelinkResult.UserNotFound(); - // Resolve against the effective tenant registry. A host connection is valid for this tenant, while a connection from another tenant is not revealed. - var connection = await connections.FindByKeyAsync(tenantId, connectionKey, cancellationToken); + // Manual administration may target disabled or invalid connections, but never archived, shadowed, or cross-tenant definitions. + var connection = await FindManageableConnectionAsync(tenantId, connectionKey, cancellationToken); if (connection is null) return new ExternalIdentityLinkPrelinkResult.ConnectionNotFound(); @@ -117,7 +117,7 @@ public sealed class ExternalIdentityLinkManagementService( if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal)) return new ExternalIdentityLinkReplaceResult.NotFound(); - var connection = await connections.FindByKeyAsync(tenantId, connectionKey, cancellationToken); + var connection = await FindManageableConnectionAsync(tenantId, connectionKey, cancellationToken); if (connection is null) return new ExternalIdentityLinkReplaceResult.NotFound(); @@ -159,6 +159,16 @@ public sealed class ExternalIdentityLinkManagementService( return result; } + private async ValueTask FindManageableConnectionAsync(string tenantId, string connectionKey, CancellationToken cancellationToken) + { + var normalizedKey = ConnectionRevisionCalculator.NormalizeKey(connectionKey); + var registry = await connections.GetAsync(tenantId, cancellationToken); + return registry.Connections.FirstOrDefault(x => + !x.IsShadowed && + !x.Connection.ArchivedAt.HasValue && + string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), normalizedKey, StringComparison.Ordinal)); + } + private async ValueTask PublishAsync(ClaimsPrincipal actor, ExternalIdentityLink link, string operation, CancellationToken cancellationToken) { var sender = services.GetService(); diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Links/ExternalIdentityLinkTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Links/ExternalIdentityLinkTests.cs index d57d6b2ad..de2a2cbb8 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Links/ExternalIdentityLinkTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Links/ExternalIdentityLinkTests.cs @@ -184,6 +184,22 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime Assert.Equal(HttpStatusCode.NotFound, (await PrelinkAsync("other-tenant-subject", "user-b")).StatusCode); } + [Fact] + public async Task ManualLinkManagementAllowsDisabledAndInvalidEffectiveConnections() + { + _connections.IsEnabled = false; + _connections.Validity = ConnectionValidity.Invalid; + + var prelinked = await (await PrelinkAsync("subject-old")).Content.ReadFromJsonAsync(); + var response = await ReplaceAsync(prelinked!.Id, "subject-new"); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.NotEqual(prelinked.Id, (await response.Content.ReadFromJsonAsync())!.Id); + + _connections.Archived = true; + Assert.Equal(HttpStatusCode.NotFound, (await PrelinkAsync("archived-subject")).StatusCode); + } + [Fact] public async Task ReplaceCreatesANewLinkAndResetsLifecycleMetadata() { @@ -307,12 +323,17 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime private sealed class TestConnectionRegistry : IIdentityProviderConnectionRegistry { public bool Archived { get; set; } + public bool IsEnabled { get; set; } = true; public bool UseHostConnection { get; set; } + public ConnectionValidity Validity { get; set; } = ConnectionValidity.Valid; public ValueTask GetAsync(string targetTenantId, CancellationToken cancellationToken = default) { - var connection = CreateConnection(); - return ValueTask.FromResult(new EffectiveConnectionRegistry([connection], [], "test")); + IReadOnlyCollection effective = + string.Equals(targetTenantId, "tenant-a", StringComparison.Ordinal) || UseHostConnection + ? [CreateConnection(), CreateConnection("fabrikam")] + : []; + return ValueTask.FromResult(new EffectiveConnectionRegistry(effective, [], "test")); } public ValueTask FindByKeyAsync(string targetTenantId, string key, CancellationToken cancellationToken = default) => ValueTask.FromResult(string.Equals(targetTenantId, "tenant-a", StringComparison.Ordinal) && (string.Equals(key, "contoso", StringComparison.Ordinal) || string.Equals(key, "fabrikam", StringComparison.Ordinal)) ? CreateConnection(key) : null); @@ -327,9 +348,9 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime AdapterSettingsVersion = 1, DisplayName = "Contoso", ArchivedAt = Archived ? DateTimeOffset.UtcNow : null, - IsEnabled = !Archived, + IsEnabled = IsEnabled, ClaimProjection = ClaimProjection.Empty - }, ConnectionSourceOwnership.Database, new ConnectionScope(ConnectionScopeKind.Tenant, "tenant-a"), ConnectionValidity.Valid, false, "test"); + }, ConnectionSourceOwnership.Database, new ConnectionScope(ConnectionScopeKind.Tenant, "tenant-a"), Validity, false, "test"); } private sealed class SteppingSystemClock(params DateTimeOffset[] instants) : ISystemClock