fix(persistence): honor tenant isolation in Memory workflow stores (#8100)
* fix(persistence): honor tenant isolation in Memory workflow stores Apply the EF SetTenantIdFilter admission rule on Memory definition, trigger, and bookmark query paths so ambient tenant and TenantAgnostic match IgnoreQueryFilters instead of leaking cross-tenant rows. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(persistence): stop Memory definition delete from wiping other tenants DeleteAsync collected logical DefinitionIds from tenant-visible rows, then removed every in-memory row with those IDs. Shared DefinitionIds across tenants therefore deleted tenant B when tenant A deleted. Keep the all-versions-of-DefinitionId Memory delete, but apply the same TenantVisibility rule (or TenantAgnostic bypass) to the final removal. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
37b1a45316
commit
f4762308a2
35
src/modules/Elsa.Common/Multitenancy/TenantVisibility.cs
Normal file
35
src/modules/Elsa.Common/Multitenancy/TenantVisibility.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using Elsa.Common.Entities;
|
||||
|
||||
namespace Elsa.Common.Multitenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Memory-store counterpart of EF Core <c>SetTenantIdFilter</c>.
|
||||
/// A row is visible when its <see cref="Entity.TenantId"/> matches the ambient tenant,
|
||||
/// is <see cref="Tenant.AgnosticTenantId"/>, or is null while the ambient tenant is the default.
|
||||
/// </summary>
|
||||
public static class TenantVisibility
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns whether <paramref name="entityTenantId"/> is visible under <paramref name="ambientTenantId"/>.
|
||||
/// </summary>
|
||||
public static bool IsVisible(string? entityTenantId, string ambientTenantId) =>
|
||||
entityTenantId == ambientTenantId
|
||||
|| entityTenantId == Tenant.AgnosticTenantId
|
||||
|| entityTenantId is null && ambientTenantId == Tenant.DefaultTenantId;
|
||||
|
||||
/// <summary>
|
||||
/// Restricts <paramref name="queryable"/> to rows visible to <paramref name="ambientTenantId"/>,
|
||||
/// unless <paramref name="tenantAgnostic"/> is set (EF <c>IgnoreQueryFilters</c>).
|
||||
/// </summary>
|
||||
public static IQueryable<T> WhereVisibleToTenant<T>(this IQueryable<T> queryable, string ambientTenantId, bool tenantAgnostic = false)
|
||||
where T : Entity
|
||||
{
|
||||
if (tenantAgnostic)
|
||||
return queryable;
|
||||
|
||||
return queryable.Where(entity =>
|
||||
entity.TenantId == ambientTenantId
|
||||
|| entity.TenantId == Tenant.AgnosticTenantId
|
||||
|| entity.TenantId == null && ambientTenantId == Tenant.DefaultTenantId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using Elsa.Common.Models;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Workflows.Management.Entities;
|
||||
|
|
@ -10,7 +11,7 @@ namespace Elsa.Workflows.Management.Stores;
|
|||
/// <summary>
|
||||
/// A memory implementation of <see cref="IWorkflowDefinitionStore"/>.
|
||||
/// </summary>
|
||||
public class MemoryWorkflowDefinitionStore(MemoryStore<WorkflowDefinition> store) : IWorkflowDefinitionStore
|
||||
public class MemoryWorkflowDefinitionStore(MemoryStore<WorkflowDefinition> store, ITenantAccessor? tenantAccessor = null) : IWorkflowDefinitionStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<WorkflowDefinition?> FindAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default)
|
||||
|
|
@ -147,7 +148,9 @@ public class MemoryWorkflowDefinitionStore(MemoryStore<WorkflowDefinition> store
|
|||
lock (store.Sync)
|
||||
{
|
||||
var workflowDefinitionIds = store.Query(query => Filter(query, filter)).Select(x => x.DefinitionId).Distinct().ToList();
|
||||
store.DeleteWhere(x => workflowDefinitionIds.Contains(x.DefinitionId));
|
||||
store.DeleteWhere(x =>
|
||||
workflowDefinitionIds.Contains(x.DefinitionId)
|
||||
&& (filter.TenantAgnostic || TenantVisibility.IsVisible(x.TenantId, CurrentTenantId)));
|
||||
return Task.FromResult(workflowDefinitionIds.LongCount());
|
||||
}
|
||||
}
|
||||
|
|
@ -162,17 +165,31 @@ public class MemoryWorkflowDefinitionStore(MemoryStore<WorkflowDefinition> store
|
|||
/// <inheritdoc />
|
||||
public Task<long> CountDistinctAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(store.Count(x => true, x => x.DefinitionId));
|
||||
var count = store.Query(query => query.WhereVisibleToTenant(CurrentTenantId))
|
||||
.Select(x => x.DefinitionId)
|
||||
.Distinct()
|
||||
.LongCount();
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> GetIsNameUnique(string name, string? definitionId = default, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var exists = store.Any(x => x.Name == name && x.DefinitionId != definitionId);
|
||||
var exists = store.Any(x =>
|
||||
x.Name == name
|
||||
&& x.DefinitionId != definitionId
|
||||
&& TenantVisibility.IsVisible(x.TenantId, CurrentTenantId));
|
||||
return Task.FromResult(!exists);
|
||||
}
|
||||
|
||||
private IQueryable<WorkflowDefinition> Filter(IQueryable<WorkflowDefinition> queryable, WorkflowDefinitionFilter filter) => filter.Apply(queryable);
|
||||
/// <remarks>
|
||||
/// Ambient tenant is applied here rather than in <see cref="WorkflowDefinitionFilter.Apply"/>.
|
||||
/// EF owns that via <c>SetTenantIdFilter</c> / <c>IgnoreQueryFilters</c>; Memory must compensate.
|
||||
/// </remarks>
|
||||
private IQueryable<WorkflowDefinition> Filter(IQueryable<WorkflowDefinition> queryable, WorkflowDefinitionFilter filter) =>
|
||||
filter.Apply(queryable.WhereVisibleToTenant(CurrentTenantId, filter.TenantAgnostic));
|
||||
|
||||
private string CurrentTenantId => tenantAccessor?.TenantId ?? Tenant.DefaultTenantId;
|
||||
|
||||
private string GetId(WorkflowDefinition workflowDefinition) => workflowDefinition.Id;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using Elsa.Common.Models;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Workflows.Runtime.Entities;
|
||||
|
|
@ -9,7 +10,7 @@ namespace Elsa.Workflows.Runtime.Stores;
|
|||
|
||||
/// <inheritdoc />
|
||||
[UsedImplicitly]
|
||||
public class MemoryBookmarkStore(MemoryStore<StoredBookmark> store) : IBookmarkStore
|
||||
public class MemoryBookmarkStore(MemoryStore<StoredBookmark> store, ITenantAccessor? tenantAccessor = null) : IBookmarkStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ValueTask SaveAsync(StoredBookmark record, CancellationToken cancellationToken = default)
|
||||
|
|
@ -54,5 +55,12 @@ public class MemoryBookmarkStore(MemoryStore<StoredBookmark> store) : IBookmarkS
|
|||
return store.DeleteMany(ids);
|
||||
}
|
||||
|
||||
private static IQueryable<StoredBookmark> Filter(IQueryable<StoredBookmark> query, BookmarkFilter filter) => filter.Apply(query);
|
||||
/// <remarks>
|
||||
/// Ambient tenant is applied here rather than in <see cref="BookmarkFilter.Apply"/>.
|
||||
/// EF owns that via <c>SetTenantIdFilter</c> / <c>IgnoreQueryFilters</c>; Memory must compensate.
|
||||
/// </remarks>
|
||||
private IQueryable<StoredBookmark> Filter(IQueryable<StoredBookmark> query, BookmarkFilter filter) =>
|
||||
filter.Apply(query.WhereVisibleToTenant(CurrentTenantId, filter.TenantAgnostic));
|
||||
|
||||
private string CurrentTenantId => tenantAccessor?.TenantId ?? Tenant.DefaultTenantId;
|
||||
}
|
||||
|
|
@ -63,14 +63,14 @@ public class MemoryTriggerStore : ITriggerStore
|
|||
/// <inheritdoc />
|
||||
public ValueTask<StoredTrigger?> FindAsync(TriggerFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = _store.Query(filter.Apply).FirstOrDefault();
|
||||
var entity = _store.Query(query => Filter(query, filter)).FirstOrDefault();
|
||||
return new(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IEnumerable<StoredTrigger>> FindManyAsync(TriggerFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = _store.Query(filter.Apply);
|
||||
var entities = _store.Query(query => Filter(query, filter));
|
||||
return new(entities);
|
||||
}
|
||||
|
||||
|
|
@ -81,8 +81,8 @@ public class MemoryTriggerStore : ITriggerStore
|
|||
|
||||
public ValueTask<Page<StoredTrigger>> FindManyAsync<TOrderBy>(TriggerFilter filter, PageArgs pageArgs, StoredTriggerOrder<TOrderBy> order, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = _store.Query(filter.Apply).LongCount();
|
||||
var result = _store.Query(query => filter.Apply(query).OrderBy(order).Paginate(pageArgs)).ToList();
|
||||
var count = _store.Query(query => Filter(query, filter)).LongCount();
|
||||
var result = _store.Query(query => Filter(query, filter).OrderBy(order).Paginate(pageArgs)).ToList();
|
||||
return ValueTask.FromResult(Page.Of(result, count));
|
||||
}
|
||||
|
||||
|
|
@ -121,11 +121,20 @@ public class MemoryTriggerStore : ITriggerStore
|
|||
{
|
||||
lock (_store.Sync)
|
||||
{
|
||||
var ids = _store.Query(filter.Apply).Select(x => x.Id).ToList();
|
||||
var ids = _store.Query(query => Filter(query, filter)).Select(x => x.Id).ToList();
|
||||
return new(_store.DeleteMany(ids));
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Ambient tenant is applied here rather than in <see cref="TriggerFilter.Apply"/>.
|
||||
/// EF owns that via <c>SetTenantIdFilter</c> / <c>IgnoreQueryFilters</c>; Memory must compensate.
|
||||
/// </remarks>
|
||||
private IQueryable<StoredTrigger> Filter(IQueryable<StoredTrigger> queryable, TriggerFilter filter) =>
|
||||
filter.Apply(queryable.WhereVisibleToTenant(CurrentTenantId, filter.TenantAgnostic));
|
||||
|
||||
private string CurrentTenantId => _tenantAccessor?.TenantId ?? Tenant.DefaultTenantId;
|
||||
|
||||
private void EnsureLogicalKeyAvailable(StoredTrigger record)
|
||||
{
|
||||
var logicalKey = GetLogicalKey(record);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
using Elsa.Common.Entities;
|
||||
using Elsa.Common.Multitenancy;
|
||||
|
||||
namespace Elsa.Common.UnitTests.Multitenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors EF <c>SetTenantIdFilter</c>: ambient match, <c>*</c>, and null-on-default-tenant.
|
||||
/// </summary>
|
||||
public class TenantVisibilityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("tenant-a", "tenant-a", true)]
|
||||
[InlineData("tenant-a", "tenant-b", false)]
|
||||
[InlineData(Tenant.AgnosticTenantId, "tenant-a", true)]
|
||||
[InlineData(Tenant.AgnosticTenantId, Tenant.DefaultTenantId, true)]
|
||||
[InlineData(null, Tenant.DefaultTenantId, true)]
|
||||
[InlineData(null, "tenant-a", false)]
|
||||
[InlineData(Tenant.DefaultTenantId, Tenant.DefaultTenantId, true)]
|
||||
[InlineData(Tenant.DefaultTenantId, "tenant-a", false)]
|
||||
public void IsVisible_MatchesSetTenantIdFilter(string? entityTenantId, string ambientTenantId, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, TenantVisibility.IsVisible(entityTenantId, ambientTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhereVisibleToTenant_WhenNotAgnostic_HidesOtherTenants()
|
||||
{
|
||||
var queryable = new[]
|
||||
{
|
||||
Entity("tenant-a"),
|
||||
Entity("tenant-b"),
|
||||
Entity(Tenant.AgnosticTenantId),
|
||||
Entity(null)
|
||||
}.AsQueryable();
|
||||
|
||||
var visible = queryable.WhereVisibleToTenant("tenant-a").Select(x => x.TenantId).ToList();
|
||||
|
||||
Assert.Equal(2, visible.Count);
|
||||
Assert.Contains("tenant-a", visible);
|
||||
Assert.Contains(Tenant.AgnosticTenantId, visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhereVisibleToTenant_WhenAgnostic_ReturnsAllRows()
|
||||
{
|
||||
var queryable = new[]
|
||||
{
|
||||
Entity("tenant-a"),
|
||||
Entity("tenant-b")
|
||||
}.AsQueryable();
|
||||
|
||||
var visible = queryable.WhereVisibleToTenant("tenant-a", tenantAgnostic: true).ToList();
|
||||
|
||||
Assert.Equal(2, visible.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhereVisibleToTenant_WhenAmbientIsDefault_IncludesNullTenantId()
|
||||
{
|
||||
var queryable = new[]
|
||||
{
|
||||
Entity(null),
|
||||
Entity("tenant-a")
|
||||
}.AsQueryable();
|
||||
|
||||
var visible = queryable.WhereVisibleToTenant(Tenant.DefaultTenantId).ToList();
|
||||
|
||||
Assert.Single(visible);
|
||||
Assert.Null(visible[0].TenantId);
|
||||
}
|
||||
|
||||
private static TestEntity Entity(string? tenantId) => new()
|
||||
{
|
||||
Id = tenantId ?? "null",
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
private sealed class TestEntity : Entity;
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.Testing.Shared.Multitenancy;
|
||||
using Elsa.Workflows.Management.Entities;
|
||||
using Elsa.Workflows.Management.Filters;
|
||||
using Elsa.Workflows.Management.Stores;
|
||||
|
||||
namespace Elsa.Workflows.Management.UnitTests.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Memory must honor ambient tenant + <see cref="WorkflowDefinitionFilter.TenantAgnostic"/>
|
||||
/// the same way EF does via <c>SetTenantIdFilter</c> / <c>IgnoreQueryFilters</c>.
|
||||
/// </summary>
|
||||
public class MemoryWorkflowDefinitionStoreTenantIsolationTests
|
||||
{
|
||||
[Fact(DisplayName = "FindManyAsync hides other tenants and keeps * visible")]
|
||||
public async Task FindManyAsync_WhenNotTenantAgnostic_HidesOtherTenants()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = (await store.FindManyAsync(new WorkflowDefinitionFilter())).ToList();
|
||||
|
||||
Assert.Equal(2, found.Count);
|
||||
Assert.Contains(found, x => x.Id == "def-a");
|
||||
Assert.Contains(found, x => x.Id == "def-star");
|
||||
Assert.DoesNotContain(found, x => x.Id == "def-b");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindManyAsync with TenantAgnostic returns every tenant")]
|
||||
public async Task FindManyAsync_WhenTenantAgnostic_ReturnsAllTenants()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = (await store.FindManyAsync(new WorkflowDefinitionFilter { TenantAgnostic = true })).ToList();
|
||||
|
||||
Assert.Equal(3, found.Count);
|
||||
Assert.Contains(found, x => x.Id == "def-a");
|
||||
Assert.Contains(found, x => x.Id == "def-b");
|
||||
Assert.Contains(found, x => x.Id == "def-star");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindAsync does not return another tenant's row by Id")]
|
||||
public async Task FindAsync_WhenOtherTenant_ReturnsNull()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = await store.FindAsync(new WorkflowDefinitionFilter { Id = "def-b" });
|
||||
|
||||
Assert.Null(found);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "AnyAsync is false when only another tenant matches")]
|
||||
public async Task AnyAsync_WhenOnlyOtherTenantMatches_ReturnsFalse()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var exists = await store.AnyAsync(new WorkflowDefinitionFilter { Id = "def-b" });
|
||||
|
||||
Assert.False(exists);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CountDistinctAsync counts only the current tenant's definition IDs")]
|
||||
public async Task CountDistinctAsync_CountsOnlyVisibleDefinitions()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var count = await store.CountDistinctAsync();
|
||||
|
||||
Assert.Equal(2, count);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GetIsNameUnique allows the same name in another tenant")]
|
||||
public async Task GetIsNameUnique_AllowsSameNameInAnotherTenant()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await store.SaveAsync(Definition("def-b", "order", "tenant-b"));
|
||||
|
||||
var unique = await store.GetIsNameUnique("order");
|
||||
|
||||
Assert.True(unique);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GetIsNameUnique is false when the current tenant already has the name")]
|
||||
public async Task GetIsNameUnique_WhenCurrentTenantOwnsTheName_ReturnsFalse()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await store.SaveAsync(Definition("def-a", "order", "tenant-a"));
|
||||
|
||||
var unique = await store.GetIsNameUnique("order");
|
||||
|
||||
Assert.False(unique);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DeleteAsync does not remove another tenant's rows")]
|
||||
public async Task DeleteAsync_DoesNotDeleteOtherTenantRows()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var deleted = await store.DeleteAsync(new WorkflowDefinitionFilter());
|
||||
var remaining = (await store.FindManyAsync(new WorkflowDefinitionFilter { TenantAgnostic = true })).ToList();
|
||||
|
||||
Assert.Equal(2, deleted);
|
||||
Assert.Single(remaining);
|
||||
Assert.Equal("def-b", remaining[0].Id);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DeleteAsync with a shared DefinitionId leaves the other tenant's versions")]
|
||||
public async Task DeleteAsync_WhenDefinitionIdIsShared_LeavesOtherTenantRows()
|
||||
{
|
||||
var backing = new MemoryStore<WorkflowDefinition>();
|
||||
var tenantA = new MemoryWorkflowDefinitionStore(backing, new TestTenantAccessor("tenant-a"));
|
||||
var tenantB = new MemoryWorkflowDefinitionStore(backing, new TestTenantAccessor("tenant-b"));
|
||||
await tenantA.SaveAsync(Definition("def-a", "Order", "tenant-a", definitionId: "order"));
|
||||
await tenantB.SaveAsync(Definition("def-b", "Order", "tenant-b", definitionId: "order"));
|
||||
|
||||
var deleted = await tenantA.DeleteAsync(new WorkflowDefinitionFilter { DefinitionId = "order" });
|
||||
var remainingForA = (await tenantA.FindManyAsync(new WorkflowDefinitionFilter { DefinitionId = "order" })).ToList();
|
||||
var remainingForB = (await tenantB.FindManyAsync(new WorkflowDefinitionFilter { DefinitionId = "order" })).ToList();
|
||||
|
||||
Assert.Equal(1, deleted);
|
||||
Assert.Empty(remainingForA);
|
||||
Assert.Single(remainingForB);
|
||||
Assert.Equal("def-b", remainingForB[0].Id);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindManyAsync on the default tenant includes null TenantId rows")]
|
||||
public async Task FindManyAsync_WhenAmbientIsDefault_IncludesNullTenantId()
|
||||
{
|
||||
var store = CreateStore(Tenant.DefaultTenantId);
|
||||
await store.SaveAsync(Definition("def-null", "Null", tenantId: null));
|
||||
await store.SaveAsync(Definition("def-a", "A", "tenant-a"));
|
||||
|
||||
var found = (await store.FindManyAsync(new WorkflowDefinitionFilter())).ToList();
|
||||
|
||||
Assert.Single(found);
|
||||
Assert.Equal("def-null", found[0].Id);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindManyAsync on a named tenant hides null TenantId rows")]
|
||||
public async Task FindManyAsync_WhenAmbientIsNamed_HidesNullTenantId()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await store.SaveAsync(Definition("def-null", "Null", tenantId: null));
|
||||
await store.SaveAsync(Definition("def-a", "A", "tenant-a"));
|
||||
|
||||
var found = (await store.FindManyAsync(new WorkflowDefinitionFilter())).ToList();
|
||||
|
||||
Assert.Single(found);
|
||||
Assert.Equal("def-a", found[0].Id);
|
||||
}
|
||||
|
||||
private static MemoryWorkflowDefinitionStore CreateStore(string tenantId) =>
|
||||
new(new MemoryStore<WorkflowDefinition>(), new TestTenantAccessor(tenantId));
|
||||
|
||||
private static async Task SeedMixedTenantsAsync(MemoryWorkflowDefinitionStore store)
|
||||
{
|
||||
await store.SaveAsync(Definition("def-a", "A", "tenant-a"));
|
||||
await store.SaveAsync(Definition("def-b", "B", "tenant-b"));
|
||||
await store.SaveAsync(Definition("def-star", "Star", Tenant.AgnosticTenantId));
|
||||
}
|
||||
|
||||
private static WorkflowDefinition Definition(string id, string name, string? tenantId, string? definitionId = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
DefinitionId = definitionId ?? id,
|
||||
Name = name,
|
||||
TenantId = tenantId,
|
||||
Version = 1,
|
||||
IsLatest = true,
|
||||
MaterializerName = "Json"
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.Testing.Shared.Multitenancy;
|
||||
using Elsa.Workflows.Runtime.Entities;
|
||||
using Elsa.Workflows.Runtime.Filters;
|
||||
using Elsa.Workflows.Runtime.Stores;
|
||||
|
||||
namespace Elsa.Workflows.Runtime.UnitTests.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Memory must honor ambient tenant + <see cref="BookmarkFilter.TenantAgnostic"/>
|
||||
/// the same way EF does via <c>SetTenantIdFilter</c> / <c>IgnoreQueryFilters</c>.
|
||||
/// </summary>
|
||||
public class MemoryBookmarkStoreTenantIsolationTests
|
||||
{
|
||||
[Fact(DisplayName = "FindManyAsync hides other tenants and keeps * visible")]
|
||||
public async Task FindManyAsync_WhenNotTenantAgnostic_HidesOtherTenants()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = (await store.FindManyAsync(new BookmarkFilter())).ToList();
|
||||
|
||||
Assert.Equal(2, found.Count);
|
||||
Assert.Contains(found, x => x.Id == "bm-a");
|
||||
Assert.Contains(found, x => x.Id == "bm-star");
|
||||
Assert.DoesNotContain(found, x => x.Id == "bm-b");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindManyAsync with TenantAgnostic returns every tenant")]
|
||||
public async Task FindManyAsync_WhenTenantAgnostic_ReturnsAllTenants()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = (await store.FindManyAsync(new BookmarkFilter { TenantAgnostic = true })).ToList();
|
||||
|
||||
Assert.Equal(3, found.Count);
|
||||
Assert.Contains(found, x => x.Id == "bm-a");
|
||||
Assert.Contains(found, x => x.Id == "bm-b");
|
||||
Assert.Contains(found, x => x.Id == "bm-star");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindAsync does not return another tenant's row by Id")]
|
||||
public async Task FindAsync_WhenOtherTenant_ReturnsNull()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = await store.FindAsync(new BookmarkFilter { BookmarkId = "bm-b" });
|
||||
|
||||
Assert.Null(found);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DeleteAsync does not remove another tenant's rows")]
|
||||
public async Task DeleteAsync_DoesNotDeleteOtherTenantRows()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var deleted = await store.DeleteAsync(new BookmarkFilter());
|
||||
var remaining = (await store.FindManyAsync(new BookmarkFilter { TenantAgnostic = true })).ToList();
|
||||
|
||||
Assert.Equal(2, deleted);
|
||||
Assert.Single(remaining);
|
||||
Assert.Equal("bm-b", remaining[0].Id);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindManyAsync on the default tenant includes null TenantId rows")]
|
||||
public async Task FindManyAsync_WhenAmbientIsDefault_IncludesNullTenantId()
|
||||
{
|
||||
var store = CreateStore(Tenant.DefaultTenantId);
|
||||
await store.SaveAsync(Bookmark("bm-null", tenantId: null));
|
||||
await store.SaveAsync(Bookmark("bm-a", tenantId: "tenant-a"));
|
||||
|
||||
var found = (await store.FindManyAsync(new BookmarkFilter())).ToList();
|
||||
|
||||
Assert.Single(found);
|
||||
Assert.Equal("bm-null", found[0].Id);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindManyAsync on a named tenant hides null TenantId rows")]
|
||||
public async Task FindManyAsync_WhenAmbientIsNamed_HidesNullTenantId()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await store.SaveAsync(Bookmark("bm-null", tenantId: null));
|
||||
await store.SaveAsync(Bookmark("bm-a", tenantId: "tenant-a"));
|
||||
|
||||
var found = (await store.FindManyAsync(new BookmarkFilter())).ToList();
|
||||
|
||||
Assert.Single(found);
|
||||
Assert.Equal("bm-a", found[0].Id);
|
||||
}
|
||||
|
||||
private static MemoryBookmarkStore CreateStore(string tenantId) =>
|
||||
new(new MemoryStore<StoredBookmark>(), new TestTenantAccessor(tenantId));
|
||||
|
||||
private static async Task SeedMixedTenantsAsync(MemoryBookmarkStore store)
|
||||
{
|
||||
await store.SaveAsync(Bookmark("bm-a", tenantId: "tenant-a"));
|
||||
await store.SaveAsync(Bookmark("bm-b", tenantId: "tenant-b"));
|
||||
await store.SaveAsync(Bookmark("bm-star", tenantId: Tenant.AgnosticTenantId));
|
||||
}
|
||||
|
||||
private static StoredBookmark Bookmark(string id, string? tenantId) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
Hash = id,
|
||||
WorkflowInstanceId = "instance-1",
|
||||
Name = "Elsa.HttpEndpoint"
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.Testing.Shared.Multitenancy;
|
||||
using Elsa.Workflows.Runtime.Entities;
|
||||
using Elsa.Workflows.Runtime.Filters;
|
||||
using Elsa.Workflows.Runtime.Stores;
|
||||
|
||||
namespace Elsa.Workflows.Runtime.UnitTests.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Memory must honor ambient tenant + <see cref="TriggerFilter.TenantAgnostic"/>
|
||||
/// the same way EF does via <c>SetTenantIdFilter</c> / <c>IgnoreQueryFilters</c>.
|
||||
/// </summary>
|
||||
public class MemoryTriggerStoreTenantIsolationTests
|
||||
{
|
||||
[Fact(DisplayName = "FindManyAsync hides other tenants and keeps * visible")]
|
||||
public async Task FindManyAsync_WhenNotTenantAgnostic_HidesOtherTenants()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = (await store.FindManyAsync(new TriggerFilter())).ToList();
|
||||
|
||||
Assert.Equal(2, found.Count);
|
||||
Assert.Contains(found, x => x.Id == "id-a");
|
||||
Assert.Contains(found, x => x.Id == "id-star");
|
||||
Assert.DoesNotContain(found, x => x.Id == "id-b");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindManyAsync with TenantAgnostic returns every tenant")]
|
||||
public async Task FindManyAsync_WhenTenantAgnostic_ReturnsAllTenants()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = (await store.FindManyAsync(new TriggerFilter { TenantAgnostic = true })).ToList();
|
||||
|
||||
Assert.Equal(3, found.Count);
|
||||
Assert.Contains(found, x => x.Id == "id-a");
|
||||
Assert.Contains(found, x => x.Id == "id-b");
|
||||
Assert.Contains(found, x => x.Id == "id-star");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindAsync does not return another tenant's row by Id")]
|
||||
public async Task FindAsync_WhenOtherTenant_ReturnsNull()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = await store.FindAsync(new TriggerFilter { Id = "id-b" });
|
||||
|
||||
Assert.Null(found);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DeleteManyAsync does not remove another tenant's rows")]
|
||||
public async Task DeleteManyAsync_DoesNotDeleteOtherTenantRows()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var deleted = await store.DeleteManyAsync(new TriggerFilter());
|
||||
var remaining = (await store.FindManyAsync(new TriggerFilter { TenantAgnostic = true })).ToList();
|
||||
|
||||
Assert.Equal(2, deleted);
|
||||
Assert.Single(remaining);
|
||||
Assert.Equal("id-b", remaining[0].Id);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindManyAsync on the default tenant includes null TenantId rows")]
|
||||
public async Task FindManyAsync_WhenAmbientIsDefault_IncludesNullTenantId()
|
||||
{
|
||||
var store = new MemoryTriggerStore(new MemoryStore<StoredTrigger>());
|
||||
var unassigned = Trigger("id-null");
|
||||
unassigned.TenantId = null;
|
||||
var other = Trigger("id-a", hash: "hash-a");
|
||||
other.TenantId = "tenant-a";
|
||||
await store.SaveAsync(unassigned);
|
||||
await store.SaveAsync(other);
|
||||
|
||||
var found = (await store.FindManyAsync(new TriggerFilter())).ToList();
|
||||
|
||||
Assert.Single(found);
|
||||
Assert.Equal("id-null", found[0].Id);
|
||||
}
|
||||
|
||||
private static MemoryTriggerStore CreateStore(string tenantId) =>
|
||||
new(new MemoryStore<StoredTrigger>(), new TestTenantAccessor(tenantId));
|
||||
|
||||
private static async Task SeedMixedTenantsAsync(MemoryTriggerStore store)
|
||||
{
|
||||
await store.SaveAsync(Trigger("id-a", tenantId: "tenant-a", hash: "hash-a"));
|
||||
await store.SaveAsync(Trigger("id-b", tenantId: "tenant-b", hash: "hash-b"));
|
||||
await store.SaveAsync(Trigger("id-star", tenantId: Tenant.AgnosticTenantId, hash: "hash-star"));
|
||||
}
|
||||
|
||||
private static StoredTrigger Trigger(string id, string? tenantId = null, string hash = "hash-1") =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
WorkflowDefinitionId = "workflow-1",
|
||||
WorkflowDefinitionVersionId = "v1",
|
||||
ActivityId = id,
|
||||
Hash = hash,
|
||||
Name = "Elsa.HttpEndpoint"
|
||||
};
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@ public class MemoryTriggerStoreTests
|
|||
|
||||
await store.ReplaceAsync([], [tenantA, tenantB]);
|
||||
|
||||
var stored = (await store.FindManyAsync(new TriggerFilter())).ToList();
|
||||
var stored = (await store.FindManyAsync(new TriggerFilter { TenantAgnostic = true })).ToList();
|
||||
Assert.Equal(2, stored.Count);
|
||||
Assert.Contains(stored, x => x.Id == "id-a");
|
||||
Assert.Contains(stored, x => x.Id == "id-b");
|
||||
|
|
|
|||
Loading…
Reference in a new issue