fix(identity): enforce per-tenant uniqueness in Memory identity stores (#8108)

* fix(identity): enforce per-tenant uniqueness in Memory identity stores

Mirror EF PerTenantIdentityUniqueness on Memory user, role, and application
stores so a second Id cannot claim the same Name or ClientId within a tenant.
Same-Id upserts may still rename themselves.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(identity): keep Memory identity finds from mutating stored rows

Clone user, role, and application rows on read so a rejected Save of a
Find result cannot leave a colliding name or client id in the store.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Sipke Schoorstra 2026-09-13 15:26:08 +02:00 committed by GitHub
parent 21e12b3fbd
commit 1126f536f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 442 additions and 9 deletions

View file

@ -32,9 +32,14 @@ public class MemoryApplicationStore : IApplicationStore
/// <inheritdoc />
public Task SaveAsync(Application application, CancellationToken cancellationToken = default)
{
ApplyCurrentTenant(application);
lock (_store.Sync)
{
ApplyCurrentTenant(application);
MemoryIdentityUniqueness.EnsureAvailable(_store, application, x => x.Name, "name");
MemoryIdentityUniqueness.EnsureAvailable(_store, application, x => x.ClientId, "client id");
_store.Save(application, x => x.Id);
}
return Task.CompletedTask;
}
@ -53,7 +58,7 @@ public class MemoryApplicationStore : IApplicationStore
/// <inheritdoc />
public Task<Application?> FindAsync(ApplicationFilter filter, CancellationToken cancellationToken = default)
{
var result = _store.Query(query => Filter(query, filter)).FirstOrDefault();
var result = _store.Query(query => Filter(query, filter)).Select(Clone).FirstOrDefault();
return Task.FromResult(result);
}
@ -69,4 +74,18 @@ public class MemoryApplicationStore : IApplicationStore
entity.TenantId ??= _tenantAccessor.TenantId;
}
private static Application Clone(Application application) =>
new()
{
Id = application.Id,
Name = application.Name,
ClientId = application.ClientId,
TenantId = application.TenantId,
HashedApiKey = application.HashedApiKey,
HashedApiKeySalt = application.HashedApiKeySalt,
HashedClientSecret = application.HashedClientSecret,
HashedClientSecretSalt = application.HashedClientSecretSalt,
Roles = application.Roles.ToList()
};
}

View file

@ -0,0 +1,35 @@
using Elsa.Common.Entities;
using Elsa.Common.Services;
namespace Elsa.Identity.Services;
/// <summary>
/// Memory counterpart of the EF <c>PerTenantIdentityUniqueness</c> indexes on
/// <c>(TenantId, Name)</c> and <c>(TenantId, ClientId)</c>.
/// </summary>
internal static class MemoryIdentityUniqueness
{
/// <summary>
/// Throws when another Id in the same tenant already owns <paramref name="keySelector"/>.
/// Same-Id upserts are allowed so a row can rename itself.
/// </summary>
public static void EnsureAvailable<T>(
MemoryStore<T> store,
T entity,
Func<T, string?> keySelector,
string keyName)
where T : Entity
{
var key = keySelector(entity);
var existing = store.Find(candidate =>
candidate.TenantId == entity.TenantId
&& candidate.Id != entity.Id
&& keySelector(candidate) == key);
if (existing is not null)
{
throw new InvalidOperationException(
$"A {typeof(T).Name.ToLowerInvariant()} already exists with {keyName} '{key}' in tenant '{entity.TenantId}'.");
}
}
}

View file

@ -26,7 +26,7 @@ public class MemoryRoleStore : IRoleStore, IRoleStoreWithAtomicDelete
/// <inheritdoc />
public Task AddAsync(Role role, CancellationToken cancellationToken = default)
{
_store.Save(role, GetStorageKey);
Save(role);
return Task.CompletedTask;
}
@ -55,21 +55,30 @@ public class MemoryRoleStore : IRoleStore, IRoleStoreWithAtomicDelete
/// <inheritdoc />
public Task SaveAsync(Role role, CancellationToken cancellationToken = default)
{
_store.Save(role, GetStorageKey);
Save(role);
return Task.CompletedTask;
}
private void Save(Role role)
{
lock (_store.Sync)
{
MemoryIdentityUniqueness.EnsureAvailable(_store, role, x => x.Name, "name");
_store.Save(role, GetStorageKey);
}
}
/// <inheritdoc />
public Task<Role?> FindAsync(RoleFilter filter, CancellationToken cancellationToken = default)
{
var result = _store.Query(query => Filter(query, filter)).FirstOrDefault();
var result = _store.Query(query => Filter(query, filter)).Select(Clone).FirstOrDefault();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<IEnumerable<Role>> FindManyAsync(RoleFilter filter, CancellationToken cancellationToken = default)
{
var result = _store.Query(query => Filter(query, filter)).ToList().AsEnumerable();
var result = _store.Query(query => Filter(query, filter)).Select(Clone).ToList().AsEnumerable();
return Task.FromResult(result);
}
@ -87,6 +96,15 @@ public class MemoryRoleStore : IRoleStore, IRoleStoreWithAtomicDelete
return filter.Apply(queryable);
}
private static Role Clone(Role role) =>
new()
{
Id = role.Id,
Name = role.Name,
TenantId = role.TenantId,
Permissions = role.Permissions.ToList()
};
private static string GetStorageKey(Role role) => GetStorageKey(role.TenantId, role.Id);
private static string GetStorageKey(string? tenantId, string roleId) =>

View file

@ -32,9 +32,13 @@ public class MemoryUserStore : IUserStore
/// <inheritdoc />
public Task SaveAsync(User user, CancellationToken cancellationToken = default)
{
ApplyCurrentTenant(user);
lock (_store.Sync)
{
ApplyCurrentTenant(user);
MemoryIdentityUniqueness.EnsureAvailable(_store, user, x => x.Name, "name");
_store.Save(user, x => x.Id);
}
return Task.CompletedTask;
}
@ -53,14 +57,14 @@ public class MemoryUserStore : IUserStore
/// <inheritdoc />
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
var result = _store.Query(query => Filter(query, filter)).ToList();
var result = _store.Query(query => Filter(query, filter)).Select(Clone).ToList();
return Task.FromResult<IEnumerable<User>>(result);
}
/// <inheritdoc />
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
var result = _store.Query(query => Filter(query, filter)).FirstOrDefault();
var result = _store.Query(query => Filter(query, filter)).Select(Clone).FirstOrDefault();
return Task.FromResult(result);
}
@ -76,4 +80,15 @@ public class MemoryUserStore : IUserStore
entity.TenantId ??= _tenantAccessor.TenantId;
}
private static User Clone(User user) =>
new()
{
Id = user.Id,
Name = user.Name,
TenantId = user.TenantId,
HashedPassword = user.HashedPassword,
HashedPasswordSalt = user.HashedPasswordSalt,
Roles = user.Roles.ToList()
};
}

View file

@ -0,0 +1,130 @@
using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using Elsa.Identity.Services;
using Elsa.Testing.Shared.Multitenancy;
namespace Elsa.Identity.UnitTests.Services;
/// <summary>
/// Memory must enforce the same per-tenant application uniqueness that EF Core
/// already enforces via <c>PerTenantIdentityUniqueness</c> on
/// <c>(TenantId, Name)</c> and <c>(TenantId, ClientId)</c>.
/// </summary>
public class MemoryApplicationStoreUniquenessTests
{
[Fact(DisplayName = "SaveAsync rejects a different Id that repeats a name in the same tenant")]
public async Task SaveAsync_WhenNameExistsUnderAnotherId_Throws()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateApplication("app-1", "Studio", "client-1", "tenant-a"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(CreateApplication("app-2", "Studio", "client-2", "tenant-a")));
Assert.Contains("already exists", exception.Message);
Assert.Contains("name", exception.Message);
var stored = await store.FindAsync(new ApplicationFilter { Id = "app-1" });
Assert.NotNull(stored);
Assert.Null(await store.FindAsync(new ApplicationFilter { Id = "app-2" }));
}
[Fact(DisplayName = "SaveAsync rejects a different Id that repeats a client id in the same tenant")]
public async Task SaveAsync_WhenClientIdExistsUnderAnotherId_Throws()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateApplication("app-1", "Studio", "client-1", "tenant-a"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(CreateApplication("app-2", "Designer", "client-1", "tenant-a")));
Assert.Contains("already exists", exception.Message);
Assert.Contains("client id", exception.Message);
var stored = await store.FindAsync(new ApplicationFilter { Id = "app-1" });
Assert.NotNull(stored);
Assert.Null(await store.FindAsync(new ApplicationFilter { Id = "app-2" }));
}
[Fact(DisplayName = "SaveAsync allows the same Id to update its own name and client id")]
public async Task SaveAsync_WhenSameIdUpdatesNameAndClientId_Succeeds()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateApplication("app-1", "Studio", "client-1", "tenant-a"));
await store.SaveAsync(CreateApplication("app-1", "Designer", "client-2", "tenant-a"));
var stored = await store.FindAsync(new ApplicationFilter { Id = "app-1" });
Assert.Equal("Designer", stored!.Name);
Assert.Equal("client-2", stored.ClientId);
}
[Fact(DisplayName = "SaveAsync allows the same name and client id in different tenants")]
public async Task SaveAsync_WhenNameAndClientIdRepeatInAnotherTenant_Succeeds()
{
var backing = new MemoryStore<Application>();
var tenantA = new MemoryApplicationStore(backing, new TestTenantAccessor("tenant-a"));
var tenantB = new MemoryApplicationStore(backing, new TestTenantAccessor("tenant-b"));
await tenantA.SaveAsync(CreateApplication("app-a", "Studio", "client-1", "tenant-a"));
await tenantB.SaveAsync(CreateApplication("app-b", "Studio", "client-1", "tenant-b"));
Assert.Equal("Studio", (await tenantA.FindAsync(new ApplicationFilter { Id = "app-a" }))!.Name);
Assert.Equal("Studio", (await tenantB.FindAsync(new ApplicationFilter { Id = "app-b" }))!.Name);
}
[Fact(DisplayName = "SaveAsync rejects renaming onto a name another Id already owns")]
public async Task SaveAsync_WhenRenamingOntoAnotherIdsName_Throws()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateApplication("app-1", "Studio", "client-1", "tenant-a"));
await store.SaveAsync(CreateApplication("app-2", "Designer", "client-2", "tenant-a"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(CreateApplication("app-2", "Studio", "client-2", "tenant-a")));
Assert.Contains("already exists", exception.Message);
Assert.Equal("Designer", (await store.FindAsync(new ApplicationFilter { Id = "app-2" }))!.Name);
}
[Fact(DisplayName = "SaveAsync treats a stamped ambient tenant as the uniqueness tenant")]
public async Task SaveAsync_WhenTenantIdUnset_UsesStampedAmbientTenantForUniqueness()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateApplication("app-1", "Studio", "client-1", tenantId: null));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(CreateApplication("app-2", "Studio", "client-2", tenantId: null)));
Assert.Contains("already exists", exception.Message);
Assert.Equal("tenant-a", (await store.FindAsync(new ApplicationFilter { Id = "app-1" }))!.TenantId);
}
[Fact(DisplayName = "SaveAsync allows the same name for * and a tenant-scoped application")]
public async Task SaveAsync_WhenAgnosticAndTenantScopedShareName_Succeeds()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateApplication("app-star", "Studio", "client-star", Tenant.AgnosticTenantId));
await store.SaveAsync(CreateApplication("app-a", "Studio", "client-a", "tenant-a"));
Assert.NotNull(await store.FindAsync(new ApplicationFilter { Id = "app-star" }));
Assert.NotNull(await store.FindAsync(new ApplicationFilter { Id = "app-a" }));
}
private static MemoryApplicationStore CreateStore(string tenantId) =>
new(new MemoryStore<Application>(), new TestTenantAccessor(tenantId));
private static Application CreateApplication(string id, string name, string clientId, string? tenantId) =>
new()
{
Id = id,
Name = name,
ClientId = clientId,
HashedApiKey = "",
HashedApiKeySalt = "",
HashedClientSecret = "",
HashedClientSecretSalt = "",
TenantId = tenantId
};
}

View file

@ -0,0 +1,111 @@
using Elsa.Common.Services;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using Elsa.Identity.Services;
using Elsa.Testing.Shared.Multitenancy;
namespace Elsa.Identity.UnitTests.Services;
/// <summary>
/// Memory must enforce the same per-tenant role name uniqueness that EF Core
/// already enforces via <c>PerTenantIdentityUniqueness</c> on <c>(TenantId, Name)</c>.
/// </summary>
public class MemoryRoleStoreUniquenessTests
{
[Fact(DisplayName = "SaveAsync rejects a different Id that repeats a name in the same tenant")]
public async Task SaveAsync_WhenNameExistsUnderAnotherId_Throws()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateRole("role-1", "Operators", "tenant-a"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(CreateRole("role-2", "Operators", "tenant-a")));
Assert.Contains("already exists", exception.Message);
var stored = (await store.FindManyAsync(new RoleFilter())).ToList();
Assert.Equal("role-1", Assert.Single(stored).Id);
}
[Fact(DisplayName = "AddAsync rejects a different Id that repeats a name in the same tenant")]
public async Task AddAsync_WhenNameExistsUnderAnotherId_Throws()
{
var store = CreateStore("tenant-a");
await store.AddAsync(CreateRole("role-1", "Operators", "tenant-a"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.AddAsync(CreateRole("role-2", "Operators", "tenant-a")));
Assert.Contains("already exists", exception.Message);
var stored = (await store.FindManyAsync(new RoleFilter())).ToList();
Assert.Equal("role-1", Assert.Single(stored).Id);
}
[Fact(DisplayName = "SaveAsync allows the same Id to update its own name")]
public async Task SaveAsync_WhenSameIdUpdatesName_Succeeds()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateRole("role-1", "Operators", "tenant-a"));
await store.SaveAsync(CreateRole("role-1", "Operators A", "tenant-a"));
var stored = await store.FindAsync(new RoleFilter { Id = "role-1" });
Assert.Equal("Operators A", stored!.Name);
}
[Fact(DisplayName = "SaveAsync allows the same name in different tenants")]
public async Task SaveAsync_WhenNameRepeatsInAnotherTenant_Succeeds()
{
var backing = new MemoryStore<Role>();
var tenantA = new MemoryRoleStore(backing, new TestTenantAccessor("tenant-a"));
var tenantB = new MemoryRoleStore(backing, new TestTenantAccessor("tenant-b"));
await tenantA.SaveAsync(CreateRole("operators", "Operators", "tenant-a"));
await tenantB.SaveAsync(CreateRole("operators", "Operators", "tenant-b"));
Assert.Equal("Operators", (await tenantA.FindAsync(new RoleFilter { Id = "operators" }))!.Name);
Assert.Equal("Operators", (await tenantB.FindAsync(new RoleFilter { Id = "operators" }))!.Name);
}
[Fact(DisplayName = "SaveAsync leaves the stored name unchanged when a Find result is renamed onto a collision")]
public async Task SaveAsync_WhenFoundRoleRenamedOntoCollision_LeavesStoredNameUnchanged()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateRole("role-1", "Operators", "tenant-a"));
await store.SaveAsync(CreateRole("role-2", "Reviewers", "tenant-a"));
var found = await store.FindAsync(new RoleFilter { Id = "role-2" });
found!.Name = "Operators";
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => store.SaveAsync(found));
Assert.Contains("already exists", exception.Message);
var stored = await store.FindAsync(new RoleFilter { Id = "role-2" });
Assert.Equal("Reviewers", stored!.Name);
}
[Fact(DisplayName = "SaveAsync rejects renaming onto a name another Id already owns")]
public async Task SaveAsync_WhenRenamingOntoAnotherIdsName_Throws()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateRole("role-1", "Operators", "tenant-a"));
await store.SaveAsync(CreateRole("role-2", "Reviewers", "tenant-a"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(CreateRole("role-2", "Operators", "tenant-a")));
Assert.Contains("already exists", exception.Message);
Assert.Equal("Reviewers", (await store.FindAsync(new RoleFilter { Id = "role-2" }))!.Name);
}
private static MemoryRoleStore CreateStore(string tenantId) =>
new(new MemoryStore<Role>(), new TestTenantAccessor(tenantId));
private static Role CreateRole(string id, string name, string? tenantId) =>
new()
{
Id = id,
Name = name,
TenantId = tenantId,
Permissions = []
};
}

View file

@ -0,0 +1,105 @@
using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using Elsa.Identity.Services;
using Elsa.Testing.Shared.Multitenancy;
namespace Elsa.Identity.UnitTests.Services;
/// <summary>
/// Memory must enforce the same per-tenant user name uniqueness that EF Core
/// already enforces via <c>PerTenantIdentityUniqueness</c> on <c>(TenantId, Name)</c>.
/// </summary>
public class MemoryUserStoreUniquenessTests
{
[Fact(DisplayName = "SaveAsync rejects a different Id that repeats a name in the same tenant")]
public async Task SaveAsync_WhenNameExistsUnderAnotherId_Throws()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateUser("user-1", "alice", "tenant-a"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(CreateUser("user-2", "alice", "tenant-a")));
Assert.Contains("already exists", exception.Message);
var stored = (await store.FindManyAsync(new UserFilter())).ToList();
Assert.Equal("user-1", Assert.Single(stored).Id);
}
[Fact(DisplayName = "SaveAsync allows the same Id to update its own name")]
public async Task SaveAsync_WhenSameIdUpdatesName_Succeeds()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateUser("user-1", "alice", "tenant-a"));
await store.SaveAsync(CreateUser("user-1", "bob", "tenant-a"));
var stored = await store.FindAsync(new UserFilter { Id = "user-1" });
Assert.Equal("bob", stored!.Name);
}
[Fact(DisplayName = "SaveAsync allows the same name in different tenants")]
public async Task SaveAsync_WhenNameRepeatsInAnotherTenant_Succeeds()
{
var backing = new MemoryStore<User>();
var tenantA = new MemoryUserStore(backing, new TestTenantAccessor("tenant-a"));
var tenantB = new MemoryUserStore(backing, new TestTenantAccessor("tenant-b"));
await tenantA.SaveAsync(CreateUser("user-a", "alice", "tenant-a"));
await tenantB.SaveAsync(CreateUser("user-b", "alice", "tenant-b"));
Assert.Equal("alice", (await tenantA.FindAsync(new UserFilter { Id = "user-a" }))!.Name);
Assert.Equal("alice", (await tenantB.FindAsync(new UserFilter { Id = "user-b" }))!.Name);
}
[Fact(DisplayName = "SaveAsync rejects renaming onto a name another Id already owns")]
public async Task SaveAsync_WhenRenamingOntoAnotherIdsName_Throws()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateUser("user-1", "alice", "tenant-a"));
await store.SaveAsync(CreateUser("user-2", "bob", "tenant-a"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(CreateUser("user-2", "alice", "tenant-a")));
Assert.Contains("already exists", exception.Message);
Assert.Equal("bob", (await store.FindAsync(new UserFilter { Id = "user-2" }))!.Name);
}
[Fact(DisplayName = "SaveAsync treats a stamped ambient tenant as the uniqueness tenant")]
public async Task SaveAsync_WhenTenantIdUnset_UsesStampedAmbientTenantForUniqueness()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateUser("user-1", "alice", tenantId: null));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
store.SaveAsync(CreateUser("user-2", "alice", tenantId: null)));
Assert.Contains("already exists", exception.Message);
Assert.Equal("tenant-a", (await store.FindAsync(new UserFilter { Id = "user-1" }))!.TenantId);
}
[Fact(DisplayName = "SaveAsync allows the same name for * and a tenant-scoped user")]
public async Task SaveAsync_WhenAgnosticAndTenantScopedShareName_Succeeds()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(CreateUser("user-star", "alice", Tenant.AgnosticTenantId));
await store.SaveAsync(CreateUser("user-a", "alice", "tenant-a"));
Assert.NotNull(await store.FindAsync(new UserFilter { Id = "user-star" }));
Assert.NotNull(await store.FindAsync(new UserFilter { Id = "user-a" }));
}
private static MemoryUserStore CreateStore(string tenantId) =>
new(new MemoryStore<User>(), new TestTenantAccessor(tenantId));
private static User CreateUser(string id, string name, string? tenantId) =>
new()
{
Id = id,
Name = name,
TenantId = tenantId
};
}