Harden external identity race compensation

This commit is contained in:
Sipke Schoorstra 2026-08-02 23:11:04 +02:00
parent d10dbdd482
commit 286a0d83d1
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
7 changed files with 542 additions and 38 deletions

View file

@ -63,26 +63,42 @@ public sealed class EFCoreExternalIdentityProvisioner(
CreatedAt = clock.UtcNow
};
var saveAttempted = false;
try
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
dbContext.ExternalIdentityLinks.Add(link);
saveAttempted = true;
await dbContext.SaveChangesAsync(cancellationToken);
await EnsureLinkedUserStillExistsAsync(dbContext, link, user, wasCreated, cancellationToken);
return new ProvisioningResult(user.Id, ToModel(link), wasCreated, true);
}
catch (DbUpdateException linkException)
{
// IX_ExternalIdentityLink_Identity arbitrates concurrent first sign-ins for the same identity tuple.
var winner = await FindLinkAsync(request.TenantId, request.ConnectionKey, request.Identity, cancellationToken);
var winner = await FindLinkAsync(request.TenantId, request.ConnectionKey, request.Identity, CancellationToken.None);
if (winner?.Id == link.Id)
{
await EnsureLinkedUserStillExistsAsync(link, user, wasCreated, CancellationToken.None);
return new ProvisioningResult(user.Id, winner, wasCreated, true);
}
if (wasCreated)
await RemoveStrandedUserAsync(user, linkException, cancellationToken);
await RemoveStrandedUserAsync(user, linkException, CancellationToken.None);
if (winner is null)
throw;
return new ProvisioningResult(winner.UserId, winner, false);
}
catch (Exception linkException)
{
if (saveAttempted && await LinkExistsAsync(link.Id, CancellationToken.None))
await EnsureLinkedUserStillExistsAsync(link, user, wasCreated, CancellationToken.None);
else if (wasCreated)
await RemoveStrandedUserAsync(user, linkException, CancellationToken.None);
throw;
}
// Once the link is durable, cancellation must not interrupt the complementary user check and cleanup.
await EnsureLinkedUserStillExistsAsync(link, user, wasCreated, CancellationToken.None);
return new ProvisioningResult(user.Id, ToModel(link), wasCreated, true);
}
public async ValueTask<ExternalIdentityLinkReplaceResult> ReplaceAsync(ExternalIdentityLinkReplaceRequest request, CancellationToken cancellationToken = default)
@ -91,12 +107,17 @@ public sealed class EFCoreExternalIdentityProvisioner(
var normalizedConnectionKey = ConnectionRevisionCalculator.NormalizeKey(request.ConnectionKey);
var subjectHash = handleHasher.Hash(request.Identity.Subject);
ExternalIdentityLink? oldLink = null;
PersistedExternalIdentityLink? oldEntity = null;
PersistedExternalIdentityLink? replacementEntity = null;
User? replacementUser = null;
var commitAttempted = false;
var replacementCommitted = false;
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(
oldEntity = await dbContext.ExternalIdentityLinks.AsNoTracking().SingleOrDefaultAsync(
x => x.Id == request.LinkId && x.TenantId == request.TenantId,
cancellationToken);
if (oldEntity is null)
@ -115,7 +136,7 @@ public sealed class EFCoreExternalIdentityProvisioner(
// The identity aggregate lives in its own store, so the target user is verified through its contract.
// Checked here rather than up front to preserve the "unknown link wins over unknown user" result ordering.
var (user, _) = await _userProvisioningService.ResolveAsync(
(replacementUser, _) = await _userProvisioningService.ResolveAsync(
new ProvisioningRequest(request.TenantId, normalizedConnectionKey, request.Identity, null, request.UserId),
cancellationToken: cancellationToken);
@ -125,37 +146,48 @@ public sealed class EFCoreExternalIdentityProvisioner(
if (deleted == 0)
return new ExternalIdentityLinkReplaceResult.NotFound();
var replacementEntity = new PersistedExternalIdentityLink
replacementEntity = new PersistedExternalIdentityLink
{
Id = identityGenerator.GenerateId(),
TenantId = request.TenantId,
ConnectionKey = normalizedConnectionKey,
Issuer = request.Identity.Issuer,
SubjectHash = subjectHash,
UserId = user.Id,
UserId = replacementUser.Id,
CreatedAt = clock.UtcNow
};
dbContext.ExternalIdentityLinks.Add(replacementEntity);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
if (!await _userProvisioningService.ExistsAsync(user, false, cancellationToken))
{
await CompensateReplacementAsync(oldEntity, replacementEntity, cancellationToken);
throw new InvalidOperationException("The Elsa user was deleted while its external identity link was being replaced.");
}
commitAttempted = true;
// The replacement write has succeeded; cancellation must not make commit outcome ambiguous.
await transaction.CommitAsync(CancellationToken.None);
replacementCommitted = true;
await EnsureReplacementUserStillExistsAsync(oldEntity, replacementEntity, replacementUser, CancellationToken.None);
return new ExternalIdentityLinkReplaceResult.Success(oldLink, ToModel(replacementEntity));
}
catch (DbUpdateConcurrencyException)
catch (DbUpdateConcurrencyException) when (!commitAttempted)
{
return new ExternalIdentityLinkReplaceResult.NotFound();
}
catch (DbUpdateException) when (oldLink is not null)
catch (DbUpdateException) when (!commitAttempted && oldLink is not null)
{
var conflictingLink = await FindLinkAsync(request.TenantId, normalizedConnectionKey, request.Identity, cancellationToken);
var conflictingLink = await FindLinkAsync(request.TenantId, normalizedConnectionKey, request.Identity, CancellationToken.None);
if (conflictingLink is not null && !string.Equals(conflictingLink.Id, request.LinkId, StringComparison.Ordinal))
return new ExternalIdentityLinkReplaceResult.Conflict(oldLink, conflictingLink);
throw;
}
catch (Exception) when (commitAttempted && !replacementCommitted && oldLink is not null && oldEntity is not null && replacementEntity is not null && replacementUser is not null)
{
// The transaction scope has been disposed before this handler runs, so probing cannot contend with it.
var durableLink = await FindLinkAsync(request.TenantId, normalizedConnectionKey, request.Identity, CancellationToken.None);
if (durableLink?.Id != replacementEntity.Id)
throw;
replacementCommitted = true;
await EnsureReplacementUserStillExistsAsync(oldEntity, replacementEntity, replacementUser, CancellationToken.None);
return new ExternalIdentityLinkReplaceResult.Success(oldLink, durableLink);
}
}
public async ValueTask<Page<ExternalIdentityLink>> FindAsync(ExternalIdentityLinkFilter filter, CancellationToken cancellationToken = default)
@ -182,7 +214,6 @@ public sealed class EFCoreExternalIdentityProvisioner(
}
private async ValueTask EnsureLinkedUserStillExistsAsync(
ExternalAuthenticationElsaDbContext dbContext,
PersistedExternalIdentityLink link,
User user,
bool wasCreated,
@ -191,15 +222,30 @@ public sealed class EFCoreExternalIdentityProvisioner(
if (await _userProvisioningService.ExistsAsync(user, wasCreated, cancellationToken))
return;
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await dbContext.ExternalIdentityLinks.Where(x => x.Id == link.Id).ExecuteDeleteAsync(cancellationToken);
throw new InvalidOperationException("The Elsa user was deleted while its external identity link was being created.");
}
private async ValueTask EnsureReplacementUserStillExistsAsync(
PersistedExternalIdentityLink oldLink,
PersistedExternalIdentityLink replacementLink,
User replacementUser,
CancellationToken cancellationToken)
{
if (await _userProvisioningService.ExistsAsync(replacementUser, false, cancellationToken))
return;
await CompensateReplacementAsync(oldLink, replacementLink, cancellationToken);
throw new InvalidOperationException("The Elsa user was deleted while its external identity link was being replaced.");
}
private async ValueTask CompensateReplacementAsync(
PersistedExternalIdentityLink oldLink,
PersistedExternalIdentityLink replacementLink,
CancellationToken cancellationToken)
{
var commitAttempted = false;
try
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@ -218,35 +264,70 @@ public sealed class EFCoreExternalIdentityProvisioner(
LastSignedInAt = oldLink.LastSignedInAt
});
await dbContext.SaveChangesAsync(cancellationToken);
commitAttempted = true;
await transaction.CommitAsync(cancellationToken);
var previousUser = new User { Id = oldLink.UserId, TenantId = oldLink.TenantId };
if (!await _userProvisioningService.ExistsAsync(previousUser, false, cancellationToken))
{
await using var cleanupContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await cleanupContext.ExternalIdentityLinks.Where(x => x.Id == oldLink.Id).ExecuteDeleteAsync(cancellationToken);
}
}
catch (Exception compensationException)
{
// The transaction scope has been disposed before this handler runs. A lost commit acknowledgement must
// not turn a successfully restored previous link into data loss.
var restorationCommitted = commitAttempted &&
await LinkExistsAsync(oldLink.Id, cancellationToken) &&
!await LinkExistsAsync(replacementLink.Id, cancellationToken);
if (!restorationCommitted)
{
await RemoveReplacementLinksOrThrowAsync(oldLink.Id, replacementLink.Id, compensationException, cancellationToken);
throw new InvalidOperationException(
"The replacement link was removed after its target user was deleted, but the previous link could not be restored.",
compensationException);
}
}
// An indeterminate user-directory failure must not be mistaken for a failed link restoration.
// Only remove the restored link when the directory positively reports that its user is gone.
var previousUser = new User { Id = oldLink.UserId, TenantId = oldLink.TenantId };
if (!await _userProvisioningService.ExistsAsync(previousUser, false, cancellationToken))
{
try
{
await using var cleanupContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await cleanupContext.ExternalIdentityLinks
.Where(x => x.Id == replacementLink.Id || x.Id == oldLink.Id)
.ExecuteDeleteAsync(cancellationToken);
await cleanupContext.ExternalIdentityLinks.Where(x => x.Id == oldLink.Id).ExecuteDeleteAsync(cancellationToken);
}
catch (Exception cleanupException)
{
throw new AggregateException(
"A replacement-compensation link refers to a deleted user and could not be removed. No credentials were issued.",
compensationException,
await RemoveReplacementLinksOrThrowAsync(oldLink.Id, replacementLink.Id, cleanupException, cancellationToken);
throw new InvalidOperationException(
"The restored previous link was removed after its user was deleted, but its first cleanup attempt failed.",
cleanupException);
}
}
}
throw new InvalidOperationException(
"The replacement link was removed after its target user was deleted, but the previous link could not be restored.",
compensationException);
private async ValueTask<bool> LinkExistsAsync(string linkId, CancellationToken cancellationToken)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.ExternalIdentityLinks.AsNoTracking().AnyAsync(x => x.Id == linkId, cancellationToken);
}
private async ValueTask RemoveReplacementLinksOrThrowAsync(
string oldLinkId,
string replacementLinkId,
Exception operationException,
CancellationToken cancellationToken)
{
try
{
await using var cleanupContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await cleanupContext.ExternalIdentityLinks
.Where(x => x.Id == replacementLinkId || x.Id == oldLinkId)
.ExecuteDeleteAsync(cancellationToken);
}
catch (Exception cleanupException)
{
throw new AggregateException(
"A replacement-compensation link refers to a deleted user and could not be removed. No credentials were issued.",
operationException,
cleanupException);
}
}

View file

@ -64,6 +64,9 @@ public sealed class ExternalIdentityUserProvisioningService(
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
var persistedUser = await userStore.FindAsync(new UserFilter { Id = user.Id }, CancellationToken.None);
if (persistedUser is not null)
await userStore.DeleteAsync(new UserFilter { Id = user.Id }, CancellationToken.None);
throw;
}
catch

View file

@ -66,7 +66,7 @@ public sealed class InMemoryExternalIdentityProvisioner(
clock.UtcNow,
null);
state.Links[key] = link;
if (!await _userProvisioningService.ExistsAsync(user, wasCreated, cancellationToken))
if (!await _userProvisioningService.ExistsAsync(user, wasCreated, CancellationToken.None))
{
state.Links.Remove(key);
throw new InvalidOperationException("The Elsa user was deleted while its external identity link was being created.");
@ -114,10 +114,13 @@ public sealed class InMemoryExternalIdentityProvisioner(
null);
state.Links.Remove(oldEntry.Key);
state.Links[replacementKey] = replacement;
if (!await _userProvisioningService.ExistsAsync(user, false, cancellationToken))
if (!await _userProvisioningService.ExistsAsync(user, false, CancellationToken.None))
{
state.Links.Remove(replacementKey);
state.Links[oldEntry.Key] = oldEntry.Value;
var previousUser = new User { Id = oldEntry.Value.UserId, TenantId = oldEntry.Value.TenantId };
if (!await _userProvisioningService.ExistsAsync(previousUser, false, CancellationToken.None))
state.Links.Remove(oldEntry.Key);
throw new InvalidOperationException("The Elsa user was deleted while its external identity link was being replaced.");
}
return new ExternalIdentityLinkReplaceResult.Success(oldEntry.Value, replacement);

View file

@ -32,13 +32,13 @@ public sealed class UserDeletionCoordinator(
}
catch
{
await userStore.SaveAsync(user, cancellationToken);
await userStore.SaveAsync(user, CancellationToken.None);
throw;
}
if (dependencies.Count > 0)
{
await userStore.SaveAsync(user, cancellationToken);
await userStore.SaveAsync(user, CancellationToken.None);
return new UserDeletionOperationResult.Blocked(dependencies);
}

View file

@ -1,3 +1,4 @@
using System.Data.Common;
using System.Text.Json;
using Elsa.Common;
using Elsa.Common.Services;
@ -311,6 +312,27 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
Assert.Empty(await _userStore.FindManyAsync(new UserFilter()));
}
[Fact]
public async Task ProvisionerRemovesTheJustInTimeUserWhenPublicationIsCancelled()
{
using var cancellationTokenSource = new CancellationTokenSource();
var users = new CancelAfterSaveUserStore(new MemoryUserStore(new MemoryStore<User>()), cancellationTokenSource);
using var hasher = new HmacExternalAuthenticationHandleHasher();
var provisioner = CreateProvisioner(hasher, userStore: users);
var request = new ProvisioningRequest(
"tenant-a",
"connection-a",
new ExternalIdentity("https://issuer.example", "subject-cancelled-publication", EmptyClaims),
new UserCreationProposal("external"));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
provisioner.CreateLinkOrGetExistingAsync(request, cancellationTokenSource.Token).AsTask());
Assert.Empty(await users.FindManyAsync(new UserFilter()));
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
}
[Fact]
public async Task ProvisionerFailsWhenAJustInTimeUserCannotBeCompensated()
{
@ -360,6 +382,30 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
}
[Fact]
public async Task ProvisionerReconcilesAmbiguousPublicationForExistingUser()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new DeleteLinkedUserAfterSaveAndThrowInterceptor(_userStore))
.Options;
using var hasher = new HmacExternalAuthenticationHandleHasher();
var provisioner = CreateProvisioner(hasher, new TestDbContextFactory(options, _services));
var request = new ProvisioningRequest(
"tenant-a",
"connection-a",
new ExternalIdentity("https://issuer.example", "subject-ambiguous-existing-user", EmptyClaims),
null,
"user-a");
await Assert.ThrowsAsync<InvalidOperationException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-a" }));
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
}
[Fact]
public async Task ProvisionerAtomicallyReplacesLinksAndPreservesTheOldLinkOnConflict()
{
@ -453,6 +499,150 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
}
[Fact]
public async Task ProvisionerPreservesRestoredLinkWhenPreviousUserLookupFails()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var originalProvisioner = CreateProvisioner(hasher);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var racingProvider = new DeleteThenThrowUserProvider(new StoreBasedUserProvider(_userStore), _userStore);
var racingProvisioner = CreateProvisioner(hasher, userProvider: racingProvider);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
Assert.Contains("lookup failure", exception.Message, StringComparison.Ordinal);
Assert.NotNull(await _userStore.FindAsync(new UserFilter { Id = "user-a" }));
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-b" }));
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
Assert.Equal(old.Id, durableLink.Id);
Assert.Equal("user-a", durableLink.UserId);
}
[Fact]
public async Task ProvisionerFallsBackWhenInvalidRestoredLinkCleanupFailsOnce()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new FailSelectedLinkDeleteInterceptor(3))
.Options;
using var hasher = new HmacExternalAuthenticationHandleHasher();
var factory = new TestDbContextFactory(options, _services);
var originalProvisioner = CreateProvisioner(hasher, factory);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2, 3);
var racingProvisioner = CreateProvisioner(hasher, factory, userProvider: racingProvider);
await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
}
[Fact]
public async Task ProvisionerDoesNotMisclassifyPostCommitUserLookupFailureAsConflict()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var originalProvisioner = CreateProvisioner(hasher);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var failingProvider = new ThrowOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), 2);
var failingProvisioner = CreateProvisioner(hasher, userProvider: failingProvider);
await Assert.ThrowsAsync<DbUpdateException>(() => failingProvisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
Assert.NotEqual(old.Id, durableLink.Id);
Assert.Equal("user-b", durableLink.UserId);
}
[Fact]
public async Task ProvisionerReconcilesReplacementWhenCommitAcknowledgementIsLost()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var originalProvisioner = CreateProvisioner(hasher);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new ThrowAfterSelectedCommitInterceptor(1))
.Options;
var provisioner = CreateProvisioner(hasher, new TestDbContextFactory(options, _services));
var result = Assert.IsType<ExternalIdentityLinkReplaceResult.Success>(await provisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))));
Assert.NotEqual(old.Id, result.NewLink.Id);
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
Assert.Equal(result.NewLink.Id, durableLink.Id);
Assert.Equal("user-b", durableLink.UserId);
}
[Fact]
public async Task ProvisionerPreservesRestoredLinkWhenCompensationCommitAcknowledgementIsLost()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var originalProvisioner = CreateProvisioner(hasher);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new ThrowAfterSelectedCommitInterceptor(2))
.Options;
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2);
var provisioner = CreateProvisioner(hasher, new TestDbContextFactory(options, _services), userProvider: racingProvider);
await Assert.ThrowsAsync<InvalidOperationException>(() => provisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
Assert.Equal(old.Id, durableLink.Id);
Assert.Equal("user-a", durableLink.UserId);
}
[Fact]
public async Task DurableConcurrentReplacementUsesTheOldLinkIdAsAnAtomicGuard()
{
@ -597,6 +787,20 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
}
}
private sealed class DeleteLinkedUserAfterSaveAndThrowInterceptor(IUserStore users) : SaveChangesInterceptor
{
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
var userId = eventData.Context!.ChangeTracker.Entries<PersistedExternalIdentityLink>()
.Single().Entity.UserId;
await users.DeleteAsync(new UserFilter { Id = userId }, CancellationToken.None);
throw new InvalidOperationException("Simulated ambiguous post-save failure.");
}
}
private sealed class DeleteFailingUserStore(IUserStore inner) : IUserStore
{
public Task SaveAsync(User user, CancellationToken cancellationToken = default) => inner.SaveAsync(user, cancellationToken);
@ -605,6 +809,53 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
}
private sealed class CancelAfterSaveUserStore(IUserStore inner, CancellationTokenSource cancellationTokenSource) : IUserStore
{
public async Task SaveAsync(User user, CancellationToken cancellationToken = default)
{
await inner.SaveAsync(user, cancellationToken);
cancellationTokenSource.Cancel();
}
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.DeleteAsync(filter, cancellationToken);
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindManyAsync(filter, cancellationToken);
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
}
private sealed class FailSelectedLinkDeleteInterceptor(int failureCount) : DbCommandInterceptor
{
private int _deleteCount;
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (command.CommandText.Contains("DELETE FROM \"ExternalIdentityLinks\"", StringComparison.Ordinal) &&
Interlocked.Increment(ref _deleteCount) == failureCount)
throw new InvalidOperationException("Simulated external identity link cleanup failure.");
return ValueTask.FromResult(result);
}
}
private sealed class ThrowAfterSelectedCommitInterceptor(int failureCount) : DbTransactionInterceptor
{
private int _commitCount;
public override Task TransactionCommittedAsync(
DbTransaction transaction,
TransactionEndEventData eventData,
CancellationToken cancellationToken = default)
{
if (Interlocked.Increment(ref _commitCount) == failureCount)
throw new InvalidOperationException("Simulated lost transaction commit acknowledgement.");
return Task.CompletedTask;
}
}
private sealed class DeleteOnSelectedFindUserProvider(IUserProvider inner, IUserStore users, params int[] deletionCounts) : IUserProvider
{
private readonly HashSet<int> _deletionCounts = deletionCounts.ToHashSet();
@ -622,4 +873,38 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
return user;
}
}
private sealed class DeleteThenThrowUserProvider(IUserProvider inner, IUserStore users) : IUserProvider
{
private int _findCount;
public async Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
var user = await inner.FindAsync(filter, cancellationToken);
var findCount = Interlocked.Increment(ref _findCount);
if (user is not null && findCount == 2)
{
await users.DeleteAsync(new UserFilter { Id = user.Id }, cancellationToken);
return null;
}
if (findCount == 3)
throw new InvalidOperationException("Simulated user-directory lookup failure.");
return user;
}
}
private sealed class ThrowOnSelectedFindUserProvider(IUserProvider inner, int failureCount) : IUserProvider
{
private int _findCount;
public async Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
var user = await inner.FindAsync(filter, cancellationToken);
if (Interlocked.Increment(ref _findCount) == failureCount)
throw new DbUpdateException("Simulated post-commit user-directory failure.");
return user;
}
}
}

View file

@ -1,4 +1,5 @@
using Elsa.Common.Services;
using Elsa.ExternalAuthentication.Contracts;
using Elsa.ExternalAuthentication.Models;
using Elsa.ExternalAuthentication.Services;
using Elsa.Identity.Contracts;
@ -41,6 +42,47 @@ public class InMemoryExternalIdentityProvisionerTests
Assert.Empty((await provisioner.FindAsync(new ExternalIdentityLinkFilter { TenantId = "tenant-a" })).Items);
}
[Fact]
public async Task RemovesRestoredLinkWhenBothReplacementUsersAreDeleted()
{
var users = new MemoryUserStore(new MemoryStore<User>());
await users.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await users.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var state = new InMemoryExternalIdentityProvisionerState();
var originalProvisioner = CreateProvisioner(users, new StoreBasedUserProvider(users), hasher, state);
var identity = new ExternalIdentity("https://issuer.example", "subject-a", new Dictionary<string, IReadOnlyCollection<string>>());
var oldLink = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", identity, null, "user-a"))).Link;
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(users), users, 2, 3);
var racingProvisioner = CreateProvisioner(users, racingProvider, hasher, state);
await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
oldLink.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-b", new Dictionary<string, IReadOnlyCollection<string>>()))).AsTask());
Assert.Null(await users.FindAsync(new UserFilter { Id = "user-a" }));
Assert.Null(await users.FindAsync(new UserFilter { Id = "user-b" }));
Assert.Empty((await racingProvisioner.FindAsync(new ExternalIdentityLinkFilter { TenantId = "tenant-a" })).Items);
}
private static InMemoryExternalIdentityProvisioner CreateProvisioner(
IUserStore users,
IUserProvider provider,
IExternalAuthenticationHandleHasher hasher,
InMemoryExternalIdentityProvisionerState state) => new(
users,
provider,
Substitute.For<IRoleProvider>(),
new GuidIdentityGenerator(),
new TestSystemClock(DateTimeOffset.UtcNow),
hasher,
state);
private sealed class DeleteAfterResolveUserProvider(IUserProvider inner, IUserStore users) : IUserProvider
{
private int _findCount;
@ -53,4 +95,22 @@ public class InMemoryExternalIdentityProvisionerTests
return user;
}
}
private sealed class DeleteOnSelectedFindUserProvider(IUserProvider inner, IUserStore users, params int[] deletionCounts) : IUserProvider
{
private readonly HashSet<int> _deletionCounts = deletionCounts.ToHashSet();
private int _findCount;
public async Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
var user = await inner.FindAsync(filter, cancellationToken);
if (user is not null && _deletionCounts.Contains(Interlocked.Increment(ref _findCount)))
{
await users.DeleteAsync(new UserFilter { Id = user.Id }, cancellationToken);
return null;
}
return user;
}
}
}

View file

@ -0,0 +1,72 @@
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using Elsa.Identity.Services;
namespace Elsa.Identity.UnitTests.Services;
public class UserDeletionCoordinatorTests
{
[Fact]
public async Task CancellationAfterDeletionDoesNotCancelUserRestoration()
{
using var cancellationTokenSource = new CancellationTokenSource();
var user = new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" };
var store = new CancellationAwareUserStore(user);
var coordinator = new UserDeletionCoordinator(store, [new CancelAfterDeletionContributor(cancellationTokenSource)]);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
coordinator.DeleteAsync(user.Id, cancellationTokenSource.Token).AsTask());
Assert.Same(user, store.User);
Assert.Equal(CancellationToken.None, store.RestorationToken);
}
private sealed class CancelAfterDeletionContributor(CancellationTokenSource cancellationTokenSource) : IUserDeletionDependencyContributor
{
private int _inspectionCount;
public string Source => "test";
public ValueTask<UserDeletionDependency?> InspectAsync(User user, CancellationToken cancellationToken = default)
{
if (Interlocked.Increment(ref _inspectionCount) == 2)
{
cancellationTokenSource.Cancel();
throw new OperationCanceledException(cancellationTokenSource.Token);
}
return ValueTask.FromResult<UserDeletionDependency?>(null);
}
}
private sealed class CancellationAwareUserStore(User user) : IUserStore
{
public User? User { get; private set; } = user;
public CancellationToken? RestorationToken { get; private set; }
public Task SaveAsync(User userToSave, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
User = userToSave;
RestorationToken = cancellationToken;
return Task.CompletedTask;
}
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
User = null;
return Task.CompletedTask;
}
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) =>
Task.FromResult(User is null ? Enumerable.Empty<User>() : [User]);
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(User);
}
}
}