Add atomic external identity link replacement
This commit is contained in:
parent
97c459f198
commit
e97a90d442
|
|
@ -19,6 +19,9 @@ public interface IExternalIdentityLinksApi
|
|||
[Post("/external-authentication/identity-links")]
|
||||
Task<ExternalIdentityLink> PrelinkAsync([Body] PrelinkExternalIdentityRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
[Post("/external-authentication/identity-links/{linkId}/replace")]
|
||||
Task<ExternalIdentityLink> ReplaceAsync(string linkId, [Body] ReplaceExternalIdentityLinkRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
[Delete("/external-authentication/identity-links/{linkId}")]
|
||||
Task UnlinkAsync(string linkId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
namespace Elsa.Api.Client.Resources.ExternalAuthentication.IdentityLinks.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Atomically replaces an external identity link with a newly created link.
|
||||
/// The subject is accepted only for this request and is never returned by the API.
|
||||
/// </summary>
|
||||
public record ReplaceExternalIdentityLinkRequest(string UserId, string ConnectionKey, string Issuer, string Subject);
|
||||
|
|
@ -140,6 +140,13 @@ public interface IExternalIdentityProvisioner
|
|||
/// Atomically creates the requested link and, when requested, its credential-less user, or returns the winner of a concurrent operation.
|
||||
/// </summary>
|
||||
ValueTask<ProvisioningResult> CreateLinkOrGetExistingAsync(ProvisioningRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically removes the tenant-scoped link identified by <see cref="ExternalIdentityLinkReplaceRequest.LinkId"/>
|
||||
/// and creates its replacement, or returns the conflicting link without changing the original.
|
||||
/// </summary>
|
||||
ValueTask<ExternalIdentityLinkReplaceResult> ReplaceAsync(ExternalIdentityLinkReplaceRequest request, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException("This external identity provisioner does not support atomic link replacement.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -110,6 +110,57 @@ internal sealed class PrelinkIdentityLink(ExternalIdentityLinkManagementService
|
|||
}
|
||||
}
|
||||
|
||||
internal sealed class ReplaceIdentityLink(ExternalIdentityLinkManagementService management, ITenantAccessor tenantAccessor) : ElsaEndpoint<ReplaceIdentityLinkRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/external-authentication/identity-links/{linkId}/replace");
|
||||
ConfigurePermissions(ExternalAuthenticationPermissions.LinksManage);
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ReplaceIdentityLinkRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ExternalIdentityLinkReplaceResult result;
|
||||
try
|
||||
{
|
||||
result = await management.ReplaceAsync(
|
||||
tenantAccessor.TenantId,
|
||||
Route<string>("linkId")!,
|
||||
request.UserId,
|
||||
request.ConnectionKey,
|
||||
request.Issuer,
|
||||
request.Subject,
|
||||
User,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
await SendErrorAsync(StatusCodes.Status400BadRequest, "validation_failed", "The external identity tuple is invalid.", cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (result)
|
||||
{
|
||||
case ExternalIdentityLinkReplaceResult.Success success:
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status201Created;
|
||||
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);
|
||||
return;
|
||||
default:
|
||||
await SendErrorAsync(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
|
||||
{
|
||||
public override void Configure()
|
||||
|
|
@ -148,6 +199,14 @@ internal sealed class PrelinkIdentityLinkRequest
|
|||
public string Subject { get; set; } = null!;
|
||||
}
|
||||
|
||||
internal sealed class ReplaceIdentityLinkRequest
|
||||
{
|
||||
public string UserId { get; set; } = null!;
|
||||
public string ConnectionKey { get; set; } = null!;
|
||||
public string Issuer { get; set; } = null!;
|
||||
public string Subject { get; set; } = null!;
|
||||
}
|
||||
|
||||
internal sealed record IdentityLinkListResponse(IReadOnlyCollection<IdentityLinkDocument> Items, string? NextCursor);
|
||||
internal sealed record FindIdentityLinkUsersResponse(IReadOnlyCollection<IdentityLinkUserDocument> Items, string? NextCursor);
|
||||
internal sealed record IdentityLinkUserDocument(string Id, string DisplayName);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ public abstract record ExternalUserMatchResult
|
|||
}
|
||||
public sealed record ProvisioningRequest(string TenantId, string ConnectionKey, ExternalIdentity Identity, UserCreationProposal? Proposal = null, string? ExistingUserId = null);
|
||||
public sealed record ProvisioningResult(string UserId, ExternalIdentityLink Link, bool WasCreated, bool WasLinkCreated = false);
|
||||
public sealed record ExternalIdentityLinkReplaceRequest(string TenantId, string LinkId, string UserId, string ConnectionKey, ExternalIdentity Identity);
|
||||
public abstract record ExternalIdentityLinkReplaceResult
|
||||
{
|
||||
private ExternalIdentityLinkReplaceResult() { }
|
||||
public sealed record Success(ExternalIdentityLink OldLink, ExternalIdentityLink NewLink) : ExternalIdentityLinkReplaceResult;
|
||||
public sealed record Conflict(ExternalIdentityLink OldLink, ExternalIdentityLink ConflictingLink) : ExternalIdentityLinkReplaceResult;
|
||||
public sealed record NotFound : ExternalIdentityLinkReplaceResult;
|
||||
}
|
||||
public sealed record ExternalTokenResponse(string AccessToken, string TokenType, long ExpiresIn, string RefreshToken, long RefreshExpiresIn, long ExternalSessionExpiresIn);
|
||||
public sealed record AdapterSettingsMigrationResult(int SettingsVersion, System.Text.Json.JsonElement Settings, bool WasMigrated);
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,17 @@ public sealed record IdentityProviderConnectionSecretBindingChanged(SecurityEven
|
|||
public sealed record IdentityProviderConnectionTested(SecurityEventContext Context, string TestedMaterialRevision, string Status, string Category, TimeSpan Duration) : INotification;
|
||||
public sealed record IdentityProviderConnectionPreviewed(SecurityEventContext Context, string MaterialRevision) : INotification;
|
||||
public sealed record ExternalIdentityLinkChanged(SecurityEventContext Context, string Operation, string LinkId) : INotification;
|
||||
public sealed record ExternalIdentityLinkReplaced(
|
||||
SecurityEventContext Context,
|
||||
string OldLinkId,
|
||||
string? NewLinkId,
|
||||
string OldUserId,
|
||||
string NewUserId,
|
||||
string OldConnectionKey,
|
||||
string NewConnectionKey,
|
||||
string? ConflictingLinkId = null,
|
||||
string? ConflictingUserId = null,
|
||||
string? ConflictingConnectionKey = null) : INotification;
|
||||
public sealed record ExternalAuthenticationSessionRevoked(SecurityEventContext Context, string SessionId, string Reason) : INotification;
|
||||
public sealed record ExternalAuthenticationConnectionSessionsRevoked(SecurityEventContext Context, int SessionCount, string Reason) : INotification;
|
||||
public sealed record ExternalSignInCompleted(SecurityEventContext Context, string? SessionId, string? AdapterType) : INotification;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,69 @@ public sealed class ExternalIdentityLinkManagementService(
|
|||
return true;
|
||||
}
|
||||
|
||||
public async ValueTask<ExternalIdentityLinkReplaceResult> ReplaceAsync(
|
||||
string tenantId,
|
||||
string linkId,
|
||||
string userId,
|
||||
string connectionKey,
|
||||
string issuer,
|
||||
string subject,
|
||||
ClaimsPrincipal actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateTargetTenant(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(linkId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionKey);
|
||||
var normalizedIssuer = NormalizeIssuer(issuer);
|
||||
var normalizedSubject = NormalizeSubject(subject);
|
||||
|
||||
var user = await users.FindAsync(new UserFilter { Id = userId }, cancellationToken);
|
||||
if (user is null || !string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
|
||||
return new ExternalIdentityLinkReplaceResult.NotFound();
|
||||
|
||||
var connection = await connections.FindByKeyAsync(tenantId, connectionKey, cancellationToken);
|
||||
if (connection is null)
|
||||
return new ExternalIdentityLinkReplaceResult.NotFound();
|
||||
|
||||
ExternalIdentityLinkReplaceResult result;
|
||||
try
|
||||
{
|
||||
result = await provisioner.ReplaceAsync(
|
||||
new ExternalIdentityLinkReplaceRequest(
|
||||
tenantId,
|
||||
linkId,
|
||||
user.Id,
|
||||
ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
|
||||
new ExternalIdentity(normalizedIssuer, normalizedSubject, new Dictionary<string, IReadOnlyCollection<string>>())),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return new ExternalIdentityLinkReplaceResult.NotFound();
|
||||
}
|
||||
|
||||
switch (result)
|
||||
{
|
||||
case ExternalIdentityLinkReplaceResult.Success success:
|
||||
await PublishReplacementAsync(actor, success.OldLink, success.NewLink, null, user.Id, success.NewLink.ConnectionKey, SecurityEventOutcome.Succeeded, cancellationToken);
|
||||
break;
|
||||
case ExternalIdentityLinkReplaceResult.Conflict conflict:
|
||||
await PublishReplacementAsync(
|
||||
actor,
|
||||
conflict.OldLink,
|
||||
null,
|
||||
conflict.ConflictingLink,
|
||||
user.Id,
|
||||
ConnectionRevisionCalculator.NormalizeKey(connection.Connection.Key),
|
||||
SecurityEventOutcome.Failed,
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async ValueTask PublishAsync(ClaimsPrincipal actor, ExternalIdentityLink link, string operation, CancellationToken cancellationToken)
|
||||
{
|
||||
var sender = services.GetService<INotificationSender>();
|
||||
|
|
@ -114,6 +177,46 @@ public sealed class ExternalIdentityLinkManagementService(
|
|||
await sender.SendAsync(new ExternalIdentityLinkChanged(context, operation, link.Id), cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask PublishReplacementAsync(
|
||||
ClaimsPrincipal actor,
|
||||
ExternalIdentityLink oldLink,
|
||||
ExternalIdentityLink? newLink,
|
||||
ExternalIdentityLink? conflictingLink,
|
||||
string targetUserId,
|
||||
string targetConnectionKey,
|
||||
SecurityEventOutcome outcome,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sender = services.GetService<INotificationSender>();
|
||||
if (sender is null)
|
||||
return;
|
||||
|
||||
var context = new SecurityEventContext(
|
||||
actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? actor.FindFirstValue("sub"),
|
||||
oldLink.TenantId,
|
||||
targetConnectionKey,
|
||||
targetUserId,
|
||||
clock.UtcNow,
|
||||
outcome,
|
||||
Guid.NewGuid().ToString("N"),
|
||||
outcome == SecurityEventOutcome.Succeeded
|
||||
? "External identity link replacement completed."
|
||||
: "External identity link replacement was rejected because the identity is already linked.");
|
||||
await sender.SendAsync(
|
||||
new ExternalIdentityLinkReplaced(
|
||||
context,
|
||||
oldLink.Id,
|
||||
newLink?.Id,
|
||||
oldLink.UserId,
|
||||
targetUserId,
|
||||
oldLink.ConnectionKey,
|
||||
targetConnectionKey,
|
||||
conflictingLink?.Id,
|
||||
conflictingLink?.UserId,
|
||||
conflictingLink?.ConnectionKey),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string NormalizeIssuer(string issuer)
|
||||
{
|
||||
if (!Uri.TryCreate(issuer?.Trim(), UriKind.Absolute, out var uri) || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))
|
||||
|
|
|
|||
|
|
@ -74,6 +74,49 @@ public sealed class InMemoryExternalIdentityProvisioner(
|
|||
}
|
||||
}
|
||||
|
||||
public async ValueTask<ExternalIdentityLinkReplaceResult> ReplaceAsync(ExternalIdentityLinkReplaceRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var normalizedConnectionKey = ConnectionRevisionCalculator.NormalizeKey(request.ConnectionKey);
|
||||
var replacementKey = new ExternalIdentityKey(request.TenantId, normalizedConnectionKey, request.Identity.Issuer, handleHasher.Hash(request.Identity.Subject));
|
||||
|
||||
await state.Mutex.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var oldEntry = state.Links.FirstOrDefault(x =>
|
||||
string.Equals(x.Value.Id, request.LinkId, StringComparison.Ordinal) &&
|
||||
string.Equals(x.Value.TenantId, request.TenantId, StringComparison.Ordinal));
|
||||
if (oldEntry.Equals(default(KeyValuePair<ExternalIdentityKey, ExternalIdentityLink>)))
|
||||
return new ExternalIdentityLinkReplaceResult.NotFound();
|
||||
|
||||
if (state.Links.TryGetValue(replacementKey, out var conflictingLink) &&
|
||||
!string.Equals(conflictingLink.Id, oldEntry.Value.Id, StringComparison.Ordinal))
|
||||
return new ExternalIdentityLinkReplaceResult.Conflict(oldEntry.Value, conflictingLink);
|
||||
|
||||
var (user, _) = await ResolveUserAsync(
|
||||
new ProvisioningRequest(request.TenantId, normalizedConnectionKey, request.Identity, null, request.UserId),
|
||||
cancellationToken);
|
||||
var replacement = new ExternalIdentityLink(
|
||||
identityGenerator.GenerateId(),
|
||||
request.TenantId,
|
||||
normalizedConnectionKey,
|
||||
request.Identity.Issuer,
|
||||
replacementKey.SubjectHash,
|
||||
null,
|
||||
user.Id,
|
||||
clock.UtcNow,
|
||||
null);
|
||||
state.Links.Remove(oldEntry.Key);
|
||||
state.Links[replacementKey] = replacement;
|
||||
return new ExternalIdentityLinkReplaceResult.Success(oldEntry.Value, replacement);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.Mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Page<ExternalIdentityLink>> FindAsync(ExternalIdentityLinkFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
|
|
|
|||
|
|
@ -65,6 +65,74 @@ public sealed class EFCoreExternalIdentityProvisioner(
|
|||
}
|
||||
}
|
||||
|
||||
public async ValueTask<ExternalIdentityLinkReplaceResult> ReplaceAsync(ExternalIdentityLinkReplaceRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var normalizedConnectionKey = ConnectionRevisionCalculator.NormalizeKey(request.ConnectionKey);
|
||||
var subjectHash = handleHasher.Hash(request.Identity.Subject);
|
||||
ExternalIdentityLink? oldLink = null;
|
||||
|
||||
try
|
||||
{
|
||||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
var oldEntity = await dbContext.ExternalIdentityLinks.AsNoTracking().SingleOrDefaultAsync(
|
||||
x => x.Id == request.LinkId && x.TenantId == request.TenantId,
|
||||
cancellationToken);
|
||||
if (oldEntity is null)
|
||||
return new ExternalIdentityLinkReplaceResult.NotFound();
|
||||
|
||||
oldLink = ToModel(oldEntity);
|
||||
var conflictingEntity = await dbContext.ExternalIdentityLinks.AsNoTracking().SingleOrDefaultAsync(
|
||||
x => x.Id != request.LinkId &&
|
||||
x.TenantId == request.TenantId &&
|
||||
x.ConnectionKey == normalizedConnectionKey &&
|
||||
x.Issuer == request.Identity.Issuer &&
|
||||
x.SubjectHash == subjectHash,
|
||||
cancellationToken);
|
||||
if (conflictingEntity is not null)
|
||||
return new ExternalIdentityLinkReplaceResult.Conflict(oldLink, ToModel(conflictingEntity));
|
||||
|
||||
var user = await dbContext.Users.SingleOrDefaultAsync(
|
||||
x => x.Id == request.UserId && x.TenantId == request.TenantId,
|
||||
cancellationToken);
|
||||
if (user is null)
|
||||
throw new InvalidOperationException("The requested Elsa user does not exist or is outside the target tenant.");
|
||||
|
||||
var deleted = await dbContext.ExternalIdentityLinks
|
||||
.Where(x => x.Id == request.LinkId && x.TenantId == request.TenantId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
if (deleted == 0)
|
||||
return new ExternalIdentityLinkReplaceResult.NotFound();
|
||||
|
||||
var replacementEntity = new PersistedExternalIdentityLink
|
||||
{
|
||||
Id = identityGenerator.GenerateId(),
|
||||
TenantId = request.TenantId,
|
||||
ConnectionKey = normalizedConnectionKey,
|
||||
Issuer = request.Identity.Issuer,
|
||||
SubjectHash = subjectHash,
|
||||
UserId = user.Id,
|
||||
CreatedAt = clock.UtcNow
|
||||
};
|
||||
dbContext.ExternalIdentityLinks.Add(replacementEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new ExternalIdentityLinkReplaceResult.Success(oldLink, ToModel(replacementEntity));
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new ExternalIdentityLinkReplaceResult.NotFound();
|
||||
}
|
||||
catch (DbUpdateException) when (oldLink is not null)
|
||||
{
|
||||
var conflictingLink = await FindLinkAsync(request.TenantId, normalizedConnectionKey, request.Identity, cancellationToken);
|
||||
if (conflictingLink is not null && !string.Equals(conflictingLink.Id, request.LinkId, StringComparison.Ordinal))
|
||||
return new ExternalIdentityLinkReplaceResult.Conflict(oldLink, conflictingLink);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Page<ExternalIdentityLink>> FindAsync(ExternalIdentityLinkFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ using Elsa.Common.Services;
|
|||
using Elsa.ExternalAuthentication.Contracts;
|
||||
using Elsa.ExternalAuthentication.Features;
|
||||
using Elsa.ExternalAuthentication.Models;
|
||||
using Elsa.ExternalAuthentication.Notifications;
|
||||
using Elsa.ExternalAuthentication.Policies;
|
||||
using Elsa.ExternalAuthentication.Services;
|
||||
using Elsa.Identity.Contracts;
|
||||
using Elsa.Identity.Entities;
|
||||
using Elsa.Identity.Providers;
|
||||
using Elsa.Identity.Services;
|
||||
using Elsa.Mediator.Contracts;
|
||||
using Elsa.Workflows;
|
||||
using FastEndpoints;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
|
@ -29,6 +31,7 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime
|
|||
private HttpClient? _client;
|
||||
private ITenantAccessor _tenant = null!;
|
||||
private TestConnectionRegistry _connections = null!;
|
||||
private INotificationSender _notifications = null!;
|
||||
private bool _wasSecurityEnabled;
|
||||
|
||||
protected HttpClient Client => _client!;
|
||||
|
|
@ -47,7 +50,12 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime
|
|||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddSingleton<MemoryStore<User>>();
|
||||
builder.Services.AddSingleton<IIdentityGenerator, GuidIdentityGenerator>();
|
||||
builder.Services.AddSingleton<ISystemClock, SystemClock>();
|
||||
builder.Services.AddSingleton<ISystemClock>(new SteppingSystemClock(
|
||||
new DateTimeOffset(2026, 7, 26, 10, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 26, 10, 1, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 26, 10, 2, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 26, 10, 3, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 26, 10, 4, 0, TimeSpan.Zero)));
|
||||
builder.Services.AddSingleton<IExternalAuthenticationHandleHasher, HmacExternalAuthenticationHandleHasher>();
|
||||
builder.Services.AddSingleton<InMemoryExternalIdentityProvisionerState>();
|
||||
builder.Services.AddScoped<IUserStore, MemoryUserStore>();
|
||||
|
|
@ -61,6 +69,8 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime
|
|||
_tenant = Substitute.For<ITenantAccessor>();
|
||||
_tenant.TenantId.Returns("tenant-a");
|
||||
builder.Services.AddSingleton(_tenant);
|
||||
_notifications = Substitute.For<INotificationSender>();
|
||||
builder.Services.AddSingleton(_notifications);
|
||||
builder.Services.AddScoped<ExternalIdentityLinkManagementService>();
|
||||
_app = builder.Build();
|
||||
_app.Use(async (context, next) =>
|
||||
|
|
@ -174,7 +184,113 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime
|
|||
Assert.Equal(HttpStatusCode.NotFound, (await PrelinkAsync("other-tenant-subject", "user-b")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceCreatesANewLinkAndResetsLifecycleMetadata()
|
||||
{
|
||||
var prelinked = await (await PrelinkAsync("subject-old")).Content.ReadFromJsonAsync<LinkDocument>();
|
||||
|
||||
var response = await ReplaceAsync(prelinked!.Id, "subject-new", "user-c", "fabrikam", "https://replacement.example/path/");
|
||||
var replacement = await response.Content.ReadFromJsonAsync<LinkDocument>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
Assert.NotNull(replacement);
|
||||
Assert.NotEqual(prelinked.Id, replacement!.Id);
|
||||
Assert.Equal("user-c", replacement.UserId);
|
||||
Assert.Equal("fabrikam", replacement.ConnectionKey);
|
||||
Assert.Equal("https://replacement.example/path", replacement.Issuer);
|
||||
Assert.True(replacement.CreatedAt > prelinked.CreatedAt);
|
||||
Assert.Null(replacement.LastSignedInAt);
|
||||
|
||||
var links = await Client.GetFromJsonAsync<LinkList>("/external-authentication/identity-links");
|
||||
var persisted = Assert.Single(links!.Items);
|
||||
Assert.Equal(replacement.Id, persisted.Id);
|
||||
|
||||
await using var scope = _app!.Services.CreateAsyncScope();
|
||||
var provisioner = scope.ServiceProvider.GetRequiredService<IExternalIdentityProvisioner>();
|
||||
Assert.Null(await provisioner.FindLinkAsync("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims)));
|
||||
Assert.Equal(replacement.Id, (await provisioner.FindLinkAsync("tenant-a", "fabrikam", new ExternalIdentity("https://replacement.example/path", "subject-new", EmptyClaims)))!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceConflictLeavesTheOldLinkUntouchedEvenWhenTheTupleBelongsToTheSameUser()
|
||||
{
|
||||
var old = await (await PrelinkAsync("subject-old")).Content.ReadFromJsonAsync<LinkDocument>();
|
||||
var conflicting = await (await PrelinkAsync("subject-conflict")).Content.ReadFromJsonAsync<LinkDocument>();
|
||||
|
||||
var response = await ReplaceAsync(old!.Id, "subject-conflict");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
var links = await Client.GetFromJsonAsync<LinkList>("/external-authentication/identity-links");
|
||||
Assert.Equal(2, links!.Items.Count);
|
||||
Assert.Contains(links.Items, x => x.Id == old.Id && x.UserId == old.UserId && x.ConnectionKey == old.ConnectionKey);
|
||||
Assert.Contains(links.Items, x => x.Id == conflicting!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceUsesTheOldIdAsATenantBoundConcurrencyGuard()
|
||||
{
|
||||
var old = await (await PrelinkAsync("subject-old")).Content.ReadFromJsonAsync<LinkDocument>();
|
||||
|
||||
_tenant.TenantId.Returns("tenant-b");
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await ReplaceAsync(old!.Id, "cross-tenant-subject", "user-b")).StatusCode);
|
||||
_tenant.TenantId.Returns("tenant-a");
|
||||
|
||||
var responses = await Task.WhenAll(
|
||||
ReplaceAsync(old.Id, "winner-a"),
|
||||
ReplaceAsync(old.Id, "winner-b"));
|
||||
|
||||
Assert.Single(responses, x => x.StatusCode == HttpStatusCode.Created);
|
||||
var missing = Assert.Single(responses, x => x.StatusCode == HttpStatusCode.NotFound);
|
||||
Assert.Equal("not_found", (await missing.Content.ReadFromJsonAsync<ErrorDocument>())!.Error);
|
||||
var links = await Client.GetFromJsonAsync<LinkList>("/external-authentication/identity-links");
|
||||
Assert.Single(links!.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceAuditsSuccessAndConflictButNotValidationFailures()
|
||||
{
|
||||
var old = await (await PrelinkAsync("subject-old")).Content.ReadFromJsonAsync<LinkDocument>();
|
||||
var conflicting = await (await PrelinkAsync("subject-conflict", "user-c")).Content.ReadFromJsonAsync<LinkDocument>();
|
||||
_notifications.ClearReceivedCalls();
|
||||
|
||||
var successfulResponse = await ReplaceAsync(old!.Id, "subject-new", "user-c", "fabrikam");
|
||||
var replacement = await successfulResponse.Content.ReadFromJsonAsync<LinkDocument>();
|
||||
var replacementToConflict = await (await PrelinkAsync("subject-another")).Content.ReadFromJsonAsync<LinkDocument>();
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await ReplaceAsync(replacementToConflict!.Id, "subject-conflict", "user-c")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await ReplaceAsync(replacement!.Id, "subject-invalid", issuer: "http://issuer.example")).StatusCode);
|
||||
|
||||
var notifications = _notifications.ReceivedCalls()
|
||||
.Select(x => x.GetArguments()[0])
|
||||
.OfType<ExternalIdentityLinkReplaced>()
|
||||
.ToArray();
|
||||
Assert.Collection(
|
||||
notifications,
|
||||
succeeded =>
|
||||
{
|
||||
Assert.Equal(SecurityEventOutcome.Succeeded, succeeded.Context.Outcome);
|
||||
Assert.Equal("admin", succeeded.Context.ActorId);
|
||||
Assert.Equal("tenant-a", succeeded.Context.TenantId);
|
||||
Assert.Equal(old.Id, succeeded.OldLinkId);
|
||||
Assert.Equal(replacement.Id, succeeded.NewLinkId);
|
||||
Assert.Equal("user-a", succeeded.OldUserId);
|
||||
Assert.Equal("user-c", succeeded.NewUserId);
|
||||
Assert.Equal("contoso", succeeded.OldConnectionKey);
|
||||
Assert.Equal("fabrikam", succeeded.NewConnectionKey);
|
||||
Assert.Null(succeeded.ConflictingLinkId);
|
||||
},
|
||||
failed =>
|
||||
{
|
||||
Assert.Equal(SecurityEventOutcome.Failed, failed.Context.Outcome);
|
||||
Assert.Equal(replacementToConflict.Id, failed.OldLinkId);
|
||||
Assert.Null(failed.NewLinkId);
|
||||
Assert.Equal(conflicting!.Id, failed.ConflictingLinkId);
|
||||
Assert.Equal("user-c", failed.ConflictingUserId);
|
||||
Assert.Equal("contoso", failed.ConflictingConnectionKey);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> PrelinkAsync(string subject, string userId = "user-a") => await _client!.PostAsJsonAsync("/external-authentication/identity-links", new { userId, connectionKey = "contoso", issuer = "https://issuer.example/", subject });
|
||||
private async Task<HttpResponseMessage> ReplaceAsync(string linkId, string subject, string userId = "user-a", string connectionKey = "contoso", string issuer = "https://issuer.example/") => await _client!.PostAsJsonAsync($"/external-authentication/identity-links/{linkId}/replace", new { userId, connectionKey, issuer, subject });
|
||||
|
||||
protected async Task SeedUserAsync(string id, string name, string tenantId)
|
||||
{
|
||||
|
|
@ -182,8 +298,11 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime
|
|||
await scope.ServiceProvider.GetRequiredService<IUserStore>().SaveAsync(new User { Id = id, Name = name, TenantId = tenantId });
|
||||
}
|
||||
|
||||
private sealed record LinkDocument(string Id, string UserId);
|
||||
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> EmptyClaims { get; } = new Dictionary<string, IReadOnlyCollection<string>>();
|
||||
|
||||
private sealed record LinkDocument(string Id, string UserId, string ConnectionKey, string Issuer, DateTimeOffset CreatedAt, DateTimeOffset? LastSignedInAt);
|
||||
private sealed record LinkList(IReadOnlyCollection<LinkDocument> Items, string? NextCursor);
|
||||
private sealed record ErrorDocument(string Error, string Message);
|
||||
|
||||
private sealed class TestConnectionRegistry : IIdentityProviderConnectionRegistry
|
||||
{
|
||||
|
|
@ -196,14 +315,14 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime
|
|||
return ValueTask.FromResult(new EffectiveConnectionRegistry([connection], [], "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) ? CreateConnection() : 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);
|
||||
public ValueTask<EffectiveIdentityProviderConnection?> FindByIdAsync(string targetTenantId, string connectionId, CancellationToken cancellationToken = default) => ValueTask.FromResult<EffectiveIdentityProviderConnection?>(string.Equals(targetTenantId, "tenant-a", StringComparison.Ordinal) && string.Equals(connectionId, "connection-a", StringComparison.Ordinal) ? CreateConnection() : null);
|
||||
|
||||
private EffectiveIdentityProviderConnection CreateConnection() => new(new IdentityProviderConnection
|
||||
private EffectiveIdentityProviderConnection CreateConnection(string key = "contoso") => new(new IdentityProviderConnection
|
||||
{
|
||||
Id = "connection-a",
|
||||
Id = $"connection-{key}",
|
||||
TenantId = UseHostConnection ? ConnectionScope.HostTenantId : "tenant-a",
|
||||
Key = "contoso",
|
||||
Key = key,
|
||||
AdapterType = "test",
|
||||
AdapterSettingsVersion = 1,
|
||||
DisplayName = "Contoso",
|
||||
|
|
@ -212,4 +331,10 @@ public partial class ExternalIdentityLinkTests : IAsyncLifetime
|
|||
ClaimProjection = ClaimProjection.Empty
|
||||
}, ConnectionSourceOwnership.Database, new ConnectionScope(ConnectionScopeKind.Tenant, "tenant-a"), ConnectionValidity.Valid, false, "test");
|
||||
}
|
||||
|
||||
private sealed class SteppingSystemClock(params DateTimeOffset[] instants) : ISystemClock
|
||||
{
|
||||
private int _index;
|
||||
public DateTimeOffset UtcNow => instants[Math.Min(_index++, instants.Length - 1)];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using Elsa.Common.Services;
|
|||
using Elsa.ExternalAuthentication.Contracts;
|
||||
using Elsa.ExternalAuthentication.Models;
|
||||
using Elsa.ExternalAuthentication.Services;
|
||||
using Elsa.Identity.Entities;
|
||||
using Elsa.Persistence.EFCore.Modules.ExternalAuthentication;
|
||||
using Elsa.Persistence.EFCore.Modules.Identity;
|
||||
using Elsa.Workflows;
|
||||
|
|
@ -155,6 +156,93 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|||
Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvisionerAtomicallyReplacesLinksAndPreservesTheOldLinkOnConflict()
|
||||
{
|
||||
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
||||
var provisioner = new EFCoreExternalIdentityProvisioner(_dbContextFactory, Substitute.For<Elsa.Identity.Contracts.IRoleProvider>(), hasher, new GuidIdentityGenerator(), _clock);
|
||||
await using (var dbContext = await _dbContextFactory.CreateDbContextAsync())
|
||||
{
|
||||
dbContext.Users.AddRange(
|
||||
new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" },
|
||||
new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var old = (await provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
||||
var conflicting = (await provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-conflict", EmptyClaims), null, "user-b"))).Link;
|
||||
|
||||
var conflict = Assert.IsType<ExternalIdentityLinkReplaceResult.Conflict>(await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-conflict", EmptyClaims))));
|
||||
Assert.Equal(conflicting.Id, conflict.ConflictingLink.Id);
|
||||
Assert.IsType<ExternalIdentityLinkReplaceResult.NotFound>(await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-b", old.Id, "user-b", "contoso", new ExternalIdentity("https://issuer.example", "cross-tenant", EmptyClaims))));
|
||||
|
||||
await using (var dbContext = await _dbContextFactory.CreateDbContextAsync())
|
||||
{
|
||||
Assert.Contains(await dbContext.ExternalIdentityLinks.ToListAsync(), x => x.Id == old.Id);
|
||||
}
|
||||
|
||||
var sameTupleReplacement = Assert.IsType<ExternalIdentityLinkReplaceResult.Success>(
|
||||
await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims))));
|
||||
Assert.NotEqual(old.Id, sameTupleReplacement.NewLink.Id);
|
||||
|
||||
var replaced = Assert.IsType<ExternalIdentityLinkReplaceResult.Success>(await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", sameTupleReplacement.NewLink.Id, "user-b", "fabrikam", new ExternalIdentity("https://replacement.example", "subject-new", EmptyClaims))));
|
||||
Assert.NotEqual(sameTupleReplacement.NewLink.Id, replaced.NewLink.Id);
|
||||
Assert.Equal("user-b", replaced.NewLink.UserId);
|
||||
Assert.Equal("fabrikam", replaced.NewLink.ConnectionKey);
|
||||
Assert.Null(replaced.NewLink.LastSignedInAt);
|
||||
|
||||
await using (var dbContext = await _dbContextFactory.CreateDbContextAsync())
|
||||
{
|
||||
var links = await dbContext.ExternalIdentityLinks.ToListAsync();
|
||||
Assert.DoesNotContain(links, x => x.Id == old.Id);
|
||||
Assert.DoesNotContain(links, x => x.Id == sameTupleReplacement.NewLink.Id);
|
||||
Assert.Contains(links, x => x.Id == replaced.NewLink.Id);
|
||||
Assert.Contains(links, x => x.Id == conflicting.Id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DurableConcurrentReplacementUsesTheOldLinkIdAsAnAtomicGuard()
|
||||
{
|
||||
var databasePath = Path.Combine(Path.GetTempPath(), $"elsa-external-identity-links-{Guid.NewGuid():N}.db");
|
||||
await using var services = new ServiceCollection().BuildServiceProvider();
|
||||
try
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<IdentityElsaDbContext>()
|
||||
.UseSqlite($"Data Source={databasePath};Default Timeout=30")
|
||||
.Options;
|
||||
var factory = new TestDbContextFactory(options, services);
|
||||
await using (var dbContext = await factory.CreateDbContextAsync())
|
||||
{
|
||||
await dbContext.Database.EnsureCreatedAsync();
|
||||
dbContext.Users.Add(new User { Id = "user-a", Name = "alice-concurrent", TenantId = "tenant-a" });
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
||||
var firstNode = new EFCoreExternalIdentityProvisioner(factory, Substitute.For<Elsa.Identity.Contracts.IRoleProvider>(), hasher, new GuidIdentityGenerator(), _clock);
|
||||
var secondNode = new EFCoreExternalIdentityProvisioner(factory, Substitute.For<Elsa.Identity.Contracts.IRoleProvider>(), hasher, new GuidIdentityGenerator(), _clock);
|
||||
var old = (await firstNode.CreateLinkOrGetExistingAsync(
|
||||
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
||||
|
||||
var results = await Task.WhenAll(
|
||||
firstNode.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims))).AsTask(),
|
||||
secondNode.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-b", EmptyClaims))).AsTask());
|
||||
|
||||
Assert.Single(results.OfType<ExternalIdentityLinkReplaceResult.Success>());
|
||||
Assert.Single(results.OfType<ExternalIdentityLinkReplaceResult.NotFound>());
|
||||
await using var verificationContext = await factory.CreateDbContextAsync();
|
||||
Assert.Single(await verificationContext.ExternalIdentityLinks.ToListAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
File.Delete(databasePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> EmptyClaims { get; } = new Dictionary<string, IReadOnlyCollection<string>>();
|
||||
|
||||
private static IdentityProviderConnection CreateConnection(string id = "connection-a") => new()
|
||||
{
|
||||
Id = id,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ public class SecurityNotificationTests
|
|||
{
|
||||
typeof(IdentityProviderConnectionChanged), typeof(IdentityProviderConnectionLifecycleChanged),
|
||||
typeof(IdentityProviderConnectionSecretBindingChanged), typeof(IdentityProviderConnectionTested),
|
||||
typeof(IdentityProviderConnectionPreviewed), typeof(ExternalIdentityLinkChanged),
|
||||
typeof(IdentityProviderConnectionPreviewed), typeof(ExternalIdentityLinkChanged), typeof(ExternalIdentityLinkReplaced),
|
||||
typeof(ExternalAuthenticationSessionRevoked), typeof(ExternalAuthenticationConnectionSessionsRevoked),
|
||||
typeof(ExternalSignInCompleted), typeof(ExternalAuthenticationOutcomeRecorded)
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue