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.
|
No password, role, permission, external-link, or cross-tenant data is returned.
|
||||||
|
|
||||||
### List/Create/Delete
|
### List/Create/Replace/Delete
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /external-authentication/identity-links?userId=&connectionKey=&cursor=&pageSize=100
|
GET /external-authentication/identity-links?userId=&connectionKey=&cursor=&pageSize=100
|
||||||
POST /external-authentication/identity-links
|
POST /external-authentication/identity-links
|
||||||
|
POST /external-authentication/identity-links/{linkId}/replace
|
||||||
DELETE /external-authentication/identity-links/{linkId}
|
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
|
## Role Lifecycle Guard
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ internal sealed class PrelinkIdentityLink(ExternalIdentityLinkManagementService
|
||||||
}
|
}
|
||||||
catch (ArgumentException)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,20 +94,14 @@ internal sealed class PrelinkIdentityLink(ExternalIdentityLinkManagementService
|
||||||
await HttpContext.Response.WriteAsJsonAsync(IdentityLinkDocument.From(link), cancellationToken);
|
await HttpContext.Response.WriteAsJsonAsync(IdentityLinkDocument.From(link), cancellationToken);
|
||||||
return;
|
return;
|
||||||
case ExternalIdentityLinkPrelinkResult.Conflict:
|
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;
|
return;
|
||||||
default:
|
default:
|
||||||
// Do not reveal whether a user or connection exists outside the trusted tenant scope.
|
// 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;
|
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>
|
internal sealed class ReplaceIdentityLink(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccessor) : ElsaEndpoint<ReplaceIdentityLinkRequest>
|
||||||
|
|
@ -135,7 +129,7 @@ internal sealed class ReplaceIdentityLink(ExternalIdentityLinkManagementService
|
||||||
}
|
}
|
||||||
catch (ArgumentException)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,19 +140,13 @@ internal sealed class ReplaceIdentityLink(ExternalIdentityLinkManagementService
|
||||||
await HttpContext.Response.WriteAsJsonAsync(IdentityLinkDocument.From(success.NewLink), cancellationToken);
|
await HttpContext.Response.WriteAsJsonAsync(IdentityLinkDocument.From(success.NewLink), cancellationToken);
|
||||||
return;
|
return;
|
||||||
case ExternalIdentityLinkReplaceResult.Conflict:
|
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;
|
return;
|
||||||
default:
|
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;
|
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
|
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 IdentityLinkCursor(DateTimeOffset CreatedAt, string Id);
|
||||||
internal sealed record UserCursor(string DisplayName, 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
|
internal static class IdentityLinkPagination
|
||||||
{
|
{
|
||||||
public static string EncodeCursor(IdentityLinkCursor cursor) => Encode(cursor);
|
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))
|
if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
|
||||||
return new ExternalIdentityLinkPrelinkResult.UserNotFound();
|
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.
|
// Manual administration may target disabled or invalid connections, but never archived, shadowed, or cross-tenant definitions.
|
||||||
var connection = await connections.FindByKeyAsync(tenantId, connectionKey, cancellationToken);
|
var connection = await FindManageableConnectionAsync(tenantId, connectionKey, cancellationToken);
|
||||||
if (connection is null)
|
if (connection is null)
|
||||||
return new ExternalIdentityLinkPrelinkResult.ConnectionNotFound();
|
return new ExternalIdentityLinkPrelinkResult.ConnectionNotFound();
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ public sealed class ExternalIdentityLinkManagementService(
|
||||||
if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
|
if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
|
||||||
return new ExternalIdentityLinkReplaceResult.NotFound();
|
return new ExternalIdentityLinkReplaceResult.NotFound();
|
||||||
|
|
||||||
var connection = await connections.FindByKeyAsync(tenantId, connectionKey, cancellationToken);
|
var connection = await FindManageableConnectionAsync(tenantId, connectionKey, cancellationToken);
|
||||||
if (connection is null)
|
if (connection is null)
|
||||||
return new ExternalIdentityLinkReplaceResult.NotFound();
|
return new ExternalIdentityLinkReplaceResult.NotFound();
|
||||||
|
|
||||||
|
|
@ -159,6 +159,16 @@ public sealed class ExternalIdentityLinkManagementService(
|
||||||
return result;
|
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)
|
private async ValueTask PublishAsync(ClaimsPrincipal actor, ExternalIdentityLink link, string operation, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sender = services.GetService<INotificationSender>();
|
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);
|
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]
|
[Fact]
|
||||||
public async Task ReplaceCreatesANewLinkAndResetsLifecycleMetadata()
|
public async Task ReplaceCreatesANewLinkAndResetsLifecycleMetadata()
|
||||||
{
|
{
|
||||||
|
|
@ -307,12 +323,17 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime
|
||||||
private sealed class TestConnectionRegistry : IIdentityProviderConnectionRegistry
|
private sealed class TestConnectionRegistry : IIdentityProviderConnectionRegistry
|
||||||
{
|
{
|
||||||
public bool Archived { get; set; }
|
public bool Archived { get; set; }
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
public bool UseHostConnection { get; set; }
|
public bool UseHostConnection { get; set; }
|
||||||
|
public ConnectionValidity Validity { get; set; } = ConnectionValidity.Valid;
|
||||||
|
|
||||||
public ValueTask<EffectiveConnectionRegistry> GetAsync(string targetTenantId, CancellationToken cancellationToken = default)
|
public ValueTask<EffectiveConnectionRegistry> GetAsync(string targetTenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var connection = CreateConnection();
|
IReadOnlyCollection<EffectiveIdentityProviderConnection> effective =
|
||||||
return ValueTask.FromResult(new EffectiveConnectionRegistry([connection], [], "test"));
|
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);
|
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,
|
AdapterSettingsVersion = 1,
|
||||||
DisplayName = "Contoso",
|
DisplayName = "Contoso",
|
||||||
ArchivedAt = Archived ? DateTimeOffset.UtcNow : null,
|
ArchivedAt = Archived ? DateTimeOffset.UtcNow : null,
|
||||||
IsEnabled = !Archived,
|
IsEnabled = IsEnabled,
|
||||||
ClaimProjection = ClaimProjection.Empty
|
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
|
private sealed class SteppingSystemClock(params DateTimeOffset[] instants) : ISystemClock
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue