Harden external identity link management

This commit is contained in:
Sipke Schoorstra 2026-07-27 00:40:23 +02:00
parent e97a90d442
commit 0fa7657b30
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
4 changed files with 60 additions and 27 deletions

View file

@ -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

View file

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

View file

@ -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<EffectiveIdentityProviderConnection?> 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<INotificationSender>();

View file

@ -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<LinkDocument>();
var response = await ReplaceAsync(prelinked!.Id, "subject-new");
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotEqual(prelinked.Id, (await response.Content.ReadFromJsonAsync<LinkDocument>())!.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<EffectiveConnectionRegistry> GetAsync(string targetTenantId, CancellationToken cancellationToken = default)
{
var connection = CreateConnection();
return ValueTask.FromResult(new EffectiveConnectionRegistry([connection], [], "test"));
IReadOnlyCollection<EffectiveIdentityProviderConnection> effective =
string.Equals(targetTenantId, "tenant-a", StringComparison.Ordinal) || UseHostConnection
? [CreateConnection(), CreateConnection("fabrikam")]
: [];
return ValueTask.FromResult(new EffectiveConnectionRegistry(effective, [], "test"));
}
public ValueTask<EffectiveIdentityProviderConnection?> FindByKeyAsync(string targetTenantId, string key, CancellationToken cancellationToken = default) => ValueTask.FromResult<EffectiveIdentityProviderConnection?>(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