Harden external identity link management
This commit is contained in:
parent
e97a90d442
commit
0fa7657b30
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue