diff --git a/Elsa.sln b/Elsa.sln
index d5b8e012b..1e94d989d 100644
--- a/Elsa.sln
+++ b/Elsa.sln
@@ -519,6 +519,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Hosts.SmokeTests", "te
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Labels.UnitTests", "test\unit\Elsa.Labels.UnitTests\Elsa.Labels.UnitTests.csproj", "{ED8D7C78-154D-4124-8C0A-E63785A862E5}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Alterations.Core.UnitTests", "test\unit\Elsa.Alterations.Core.UnitTests\Elsa.Alterations.Core.UnitTests.csproj", "{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -2483,6 +2485,18 @@ Global
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Release|x64.Build.0 = Release|Any CPU
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Release|x86.ActiveCfg = Release|Any CPU
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Release|x86.Build.0 = Release|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|x64.Build.0 = Debug|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|x86.Build.0 = Debug|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|x64.ActiveCfg = Release|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|x64.Build.0 = Release|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|x86.ActiveCfg = Release|Any CPU
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -2689,6 +2703,7 @@ Global
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F} = {18453B51-25EB-4317-A4B3-B10518252E92}
{0BE4BC01-D02D-4185-A433-E33780584261} = {90031D64-CA0F-46D0-9AF4-8DC023A5FFCD}
{ED8D7C78-154D-4124-8C0A-E63785A862E5} = {18453B51-25EB-4317-A4B3-B10518252E92}
+ {EAF427BE-7A07-42D7-A223-70FB1FC91BB2} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}
diff --git a/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationJobStore.cs b/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationJobStore.cs
index 1af9f76f0..e4d541e76 100644
--- a/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationJobStore.cs
+++ b/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationJobStore.cs
@@ -1,6 +1,8 @@
using Elsa.Alterations.Core.Contracts;
using Elsa.Alterations.Core.Entities;
using Elsa.Alterations.Core.Filters;
+using Elsa.Common.Entities;
+using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
namespace Elsa.Alterations.Core.Stores;
@@ -8,29 +10,54 @@ namespace Elsa.Alterations.Core.Stores;
///
/// A memory-based store for alteration jobs.
///
+///
+/// Ambient tenant is applied here rather than in callers.
+/// EF owns that via SetTenantIdFilter / ApplyTenantId; Memory must compensate.
+/// Alterations contracts have no TenantAgnostic flag, so isolation always applies (EF query filter).
+///
public class MemoryAlterationJobStore : IAlterationJobStore
{
private readonly MemoryStore _store;
+ private readonly ITenantAccessor? _tenantAccessor;
///
/// Initializes a new instance of the class.
///
- public MemoryAlterationJobStore(MemoryStore store)
+ public MemoryAlterationJobStore(MemoryStore store, ITenantAccessor? tenantAccessor = null)
{
_store = store;
+ _tenantAccessor = tenantAccessor;
}
///
public Task SaveAsync(AlterationJob job, CancellationToken cancellationToken = default)
{
- _store.Save(job, x => x.Id);
+ lock (_store.Sync)
+ {
+ ApplyCurrentTenant(job);
+ EnsureIdAvailable(job);
+ _store.Save(job, x => x.Id);
+ }
+
return Task.CompletedTask;
}
///
public Task SaveManyAsync(IEnumerable jobs, CancellationToken cancellationToken = default)
{
- _store.SaveMany(jobs, x => x.Id);
+ var list = jobs.ToList();
+
+ lock (_store.Sync)
+ {
+ foreach (var job in list)
+ ApplyCurrentTenant(job);
+
+ foreach (var job in list)
+ EnsureIdAvailable(job);
+
+ _store.SaveMany(list, x => x.Id);
+ }
+
return Task.CompletedTask;
}
@@ -62,6 +89,42 @@ public class MemoryAlterationJobStore : IAlterationJobStore
return Task.FromResult(count);
}
+ ///
+ /// Ambient tenant is applied here rather than in .
+ /// EF owns that via SetTenantIdFilter; Memory must compensate.
+ ///
+ private IQueryable Filter(IQueryable query, AlterationJobFilter filter) =>
+ filter.Apply(query.WhereVisibleToTenant(CurrentTenantId));
- private static IQueryable Filter(IQueryable query, AlterationJobFilter filter) => filter.Apply(query);
-}
\ No newline at end of file
+ private string CurrentTenantId => _tenantAccessor?.TenantId ?? Tenant.DefaultTenantId;
+
+ private bool IsVisible(Entity entity) => TenantVisibility.IsVisible(entity.TenantId, CurrentTenantId);
+
+ private void EnsureIdAvailable(AlterationJob job)
+ {
+ var existing = _store.Find(x => x.Id == job.Id);
+
+ if (existing is not null && !CanReplace(existing))
+ {
+ throw new InvalidOperationException(
+ $"An alteration job with ID '{job.Id}' already exists and is not visible to the current tenant.");
+ }
+ }
+
+ ///
+ /// * is visible to every tenant, but only an agnostic writer may replace it.
+ /// Named tenants may upsert their own visible rows.
+ ///
+ private bool CanReplace(Entity existing) =>
+ existing.TenantId == Tenant.AgnosticTenantId
+ ? CurrentTenantId == Tenant.AgnosticTenantId
+ : IsVisible(existing);
+
+ private void ApplyCurrentTenant(Entity entity)
+ {
+ if (entity.TenantId == Tenant.AgnosticTenantId || _tenantAccessor is null)
+ return;
+
+ entity.TenantId ??= _tenantAccessor.TenantId;
+ }
+}
diff --git a/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationPlanStore.cs b/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationPlanStore.cs
index 9657c5864..bfde620ea 100644
--- a/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationPlanStore.cs
+++ b/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationPlanStore.cs
@@ -1,6 +1,8 @@
using Elsa.Alterations.Core.Contracts;
using Elsa.Alterations.Core.Entities;
using Elsa.Alterations.Core.Filters;
+using Elsa.Common.Entities;
+using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
namespace Elsa.Alterations.Core.Stores;
@@ -8,22 +10,35 @@ namespace Elsa.Alterations.Core.Stores;
///
/// A memory-based store for alteration plans.
///
+///
+/// Ambient tenant is applied here rather than in callers.
+/// EF owns that via SetTenantIdFilter / ApplyTenantId; Memory must compensate.
+/// Alterations contracts have no TenantAgnostic flag, so isolation always applies (EF query filter).
+///
public class MemoryAlterationPlanStore : IAlterationPlanStore
{
private readonly MemoryStore _store;
+ private readonly ITenantAccessor? _tenantAccessor;
///
/// Initializes a new instance of the class.
///
- public MemoryAlterationPlanStore(MemoryStore store)
+ public MemoryAlterationPlanStore(MemoryStore store, ITenantAccessor? tenantAccessor = null)
{
_store = store;
+ _tenantAccessor = tenantAccessor;
}
-
+
///
public Task SaveAsync(AlterationPlan plan, CancellationToken cancellationToken = default)
{
- _store.Save(plan, x => x.Id);
+ lock (_store.Sync)
+ {
+ ApplyCurrentTenant(plan);
+ EnsureIdAvailable(plan);
+ _store.Save(plan, x => x.Id);
+ }
+
return Task.CompletedTask;
}
@@ -41,5 +56,42 @@ public class MemoryAlterationPlanStore : IAlterationPlanStore
return Task.FromResult(count);
}
- private static IQueryable Filter(IQueryable query, AlterationPlanFilter filter) => filter.Apply(query);
-}
\ No newline at end of file
+ ///
+ /// Ambient tenant is applied here rather than in .
+ /// EF owns that via SetTenantIdFilter; Memory must compensate.
+ ///
+ private IQueryable Filter(IQueryable query, AlterationPlanFilter filter) =>
+ filter.Apply(query.WhereVisibleToTenant(CurrentTenantId));
+
+ private string CurrentTenantId => _tenantAccessor?.TenantId ?? Tenant.DefaultTenantId;
+
+ private bool IsVisible(Entity entity) => TenantVisibility.IsVisible(entity.TenantId, CurrentTenantId);
+
+ private void EnsureIdAvailable(AlterationPlan plan)
+ {
+ var existing = _store.Find(x => x.Id == plan.Id);
+
+ if (existing is not null && !CanReplace(existing))
+ {
+ throw new InvalidOperationException(
+ $"An alteration plan with ID '{plan.Id}' already exists and is not visible to the current tenant.");
+ }
+ }
+
+ ///
+ /// * is visible to every tenant, but only an agnostic writer may replace it.
+ /// Named tenants may upsert their own visible rows.
+ ///
+ private bool CanReplace(Entity existing) =>
+ existing.TenantId == Tenant.AgnosticTenantId
+ ? CurrentTenantId == Tenant.AgnosticTenantId
+ : IsVisible(existing);
+
+ private void ApplyCurrentTenant(Entity entity)
+ {
+ if (entity.TenantId == Tenant.AgnosticTenantId || _tenantAccessor is null)
+ return;
+
+ entity.TenantId ??= _tenantAccessor.TenantId;
+ }
+}
diff --git a/test/unit/Elsa.Alterations.Core.UnitTests/Elsa.Alterations.Core.UnitTests.csproj b/test/unit/Elsa.Alterations.Core.UnitTests/Elsa.Alterations.Core.UnitTests.csproj
new file mode 100644
index 000000000..03e0a764d
--- /dev/null
+++ b/test/unit/Elsa.Alterations.Core.UnitTests/Elsa.Alterations.Core.UnitTests.csproj
@@ -0,0 +1,12 @@
+
+
+
+ [Elsa.Alterations.Core]*
+
+
+
+
+
+
+
+
diff --git a/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationJobStoreTenantIsolationTests.cs b/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationJobStoreTenantIsolationTests.cs
new file mode 100644
index 000000000..ab152522f
--- /dev/null
+++ b/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationJobStoreTenantIsolationTests.cs
@@ -0,0 +1,252 @@
+using Elsa.Alterations.Core.Entities;
+using Elsa.Alterations.Core.Enums;
+using Elsa.Alterations.Core.Filters;
+using Elsa.Alterations.Core.Stores;
+using Elsa.Common.Multitenancy;
+using Elsa.Common.Services;
+using Elsa.Testing.Shared.Multitenancy;
+
+namespace Elsa.Alterations.Core.UnitTests.Stores;
+
+///
+/// Memory alteration jobs must honor ambient tenant the same way EF does via
+/// SetTenantIdFilter / ApplyTenantId. Contracts have no TenantAgnostic flag.
+///
+public class MemoryAlterationJobStoreTenantIsolationTests
+{
+ [Fact(DisplayName = "FindManyAsync hides other tenants and keeps * visible")]
+ public async Task FindManyAsync_HidesOtherTenants()
+ {
+ var store = CreateStore("tenant-a");
+ await SeedMixedTenantsAsync(store);
+
+ var found = (await store.FindManyAsync(new AlterationJobFilter { PlanId = "plan-1" }, CancellationToken.None)).ToList();
+
+ Assert.Equal(2, found.Count);
+ Assert.Contains(found, x => x.Id == "job-a");
+ Assert.Contains(found, x => x.Id == "job-star");
+ Assert.DoesNotContain(found, x => x.Id == "job-b");
+ }
+
+ [Fact(DisplayName = "FindAsync does not return another tenant's row")]
+ public async Task FindAsync_WhenOtherTenant_ReturnsNull()
+ {
+ var store = CreateStore("tenant-a");
+ await SeedMixedTenantsAsync(store);
+
+ var found = await store.FindAsync(new AlterationJobFilter { Id = "job-b" });
+
+ Assert.Null(found);
+ }
+
+ [Fact(DisplayName = "FindManyIdsAsync hides other tenants")]
+ public async Task FindManyIdsAsync_HidesOtherTenants()
+ {
+ var store = CreateStore("tenant-a");
+ await SeedMixedTenantsAsync(store);
+
+ var found = (await store.FindManyIdsAsync(new AlterationJobFilter { PlanId = "plan-1" })).ToList();
+
+ Assert.Equal(2, found.Count);
+ Assert.Contains("job-a", found);
+ Assert.Contains("job-star", found);
+ Assert.DoesNotContain("job-b", found);
+ }
+
+ [Fact(DisplayName = "CountAsync hides other tenants and keeps * visible")]
+ public async Task CountAsync_HidesOtherTenants()
+ {
+ var store = CreateStore("tenant-a");
+ await SeedMixedTenantsAsync(store);
+
+ var count = await store.CountAsync(new AlterationJobFilter { PlanId = "plan-1" });
+
+ Assert.Equal(2, count);
+ }
+
+ [Fact(DisplayName = "SaveAsync refuses to overwrite another tenant's row by Id")]
+ public async Task SaveAsync_WhenOtherTenantOwnsId_ThrowsAndLeavesExisting()
+ {
+ var backing = new MemoryStore();
+ var tenantA = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-a"));
+ var tenantB = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-b"));
+ await tenantA.SaveAsync(Job("shared", "tenant-a"));
+
+ var ex = await Assert.ThrowsAsync(() => tenantB.SaveAsync(Job("shared", "tenant-b")));
+ var remaining = await tenantA.FindAsync(new AlterationJobFilter { Id = "shared" });
+
+ Assert.Contains("shared", ex.Message);
+ Assert.NotNull(remaining);
+ Assert.Equal("tenant-a", remaining.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveManyAsync refuses to overwrite another tenant's row by Id")]
+ public async Task SaveManyAsync_WhenOtherTenantOwnsId_ThrowsAndLeavesExisting()
+ {
+ var backing = new MemoryStore();
+ var tenantA = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-a"));
+ var tenantB = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-b"));
+ await tenantA.SaveAsync(Job("shared", "tenant-a"));
+
+ await Assert.ThrowsAsync(() => tenantB.SaveManyAsync([Job("shared", "tenant-b")]));
+ var remaining = await tenantA.FindAsync(new AlterationJobFilter { Id = "shared" });
+
+ Assert.NotNull(remaining);
+ Assert.Equal("tenant-a", remaining.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveAsync refuses to overwrite a tenant-agnostic row by Id")]
+ public async Task SaveAsync_WhenAgnosticRowExists_NamedTenantThrowsAndLeavesExisting()
+ {
+ var backing = new MemoryStore();
+ var agnostic = new MemoryAlterationJobStore(backing, new TestTenantAccessor(Tenant.AgnosticTenantId));
+ var tenantB = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-b"));
+ await agnostic.SaveAsync(Job("shared", Tenant.AgnosticTenantId));
+
+ var ex = await Assert.ThrowsAsync(() => tenantB.SaveAsync(Job("shared", "tenant-b")));
+ var remaining = await agnostic.FindAsync(new AlterationJobFilter { Id = "shared" });
+
+ Assert.Contains("shared", ex.Message);
+ Assert.NotNull(remaining);
+ Assert.Equal(Tenant.AgnosticTenantId, remaining.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveManyAsync refuses to overwrite a tenant-agnostic row by Id")]
+ public async Task SaveManyAsync_WhenAgnosticRowExists_NamedTenantThrowsAndLeavesExisting()
+ {
+ var backing = new MemoryStore();
+ var agnostic = new MemoryAlterationJobStore(backing, new TestTenantAccessor(Tenant.AgnosticTenantId));
+ var tenantB = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-b"));
+ await agnostic.SaveAsync(Job("shared", Tenant.AgnosticTenantId));
+
+ await Assert.ThrowsAsync(() => tenantB.SaveManyAsync([Job("shared", "tenant-b")]));
+ var remaining = await agnostic.FindAsync(new AlterationJobFilter { Id = "shared" });
+
+ Assert.NotNull(remaining);
+ Assert.Equal(Tenant.AgnosticTenantId, remaining.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveAsync still lets an agnostic writer update a * row")]
+ public async Task SaveAsync_WhenAmbientIsAgnostic_UpsertsAgnosticRow()
+ {
+ var store = CreateStore(Tenant.AgnosticTenantId);
+ await store.SaveAsync(Job("shared", Tenant.AgnosticTenantId));
+ var updated = Job("shared", Tenant.AgnosticTenantId);
+ updated.Status = AlterationJobStatus.Completed;
+
+ await store.SaveAsync(updated);
+
+ var found = await store.FindAsync(new AlterationJobFilter { Id = "shared" });
+ Assert.NotNull(found);
+ Assert.Equal(AlterationJobStatus.Completed, found.Status);
+ Assert.Equal(Tenant.AgnosticTenantId, found.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveAsync still upserts a visible same-tenant row")]
+ public async Task SaveAsync_WhenSameTenantOwnsId_Upserts()
+ {
+ var store = CreateStore("tenant-a");
+ await store.SaveAsync(Job("job-a", "tenant-a"));
+ var updated = Job("job-a", "tenant-a");
+ updated.Status = AlterationJobStatus.Completed;
+
+ await store.SaveAsync(updated);
+
+ var found = await store.FindAsync(new AlterationJobFilter { Id = "job-a" });
+ Assert.NotNull(found);
+ Assert.Equal(AlterationJobStatus.Completed, found.Status);
+ Assert.Equal("tenant-a", found.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveAsync stamps the ambient tenant when TenantId is unset")]
+ public async Task SaveAsync_WhenTenantIdUnset_StampsAmbientTenant()
+ {
+ var store = CreateStore("tenant-a");
+ var job = Job("job-new", tenantId: null);
+
+ await store.SaveAsync(job);
+
+ Assert.Equal("tenant-a", job.TenantId);
+ Assert.Equal("tenant-a", (await store.FindAsync(new AlterationJobFilter { Id = "job-new" }))!.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveManyAsync stamps the ambient tenant when TenantId is unset")]
+ public async Task SaveManyAsync_WhenTenantIdUnset_StampsAmbientTenant()
+ {
+ var store = CreateStore("tenant-a");
+ var jobs = new[]
+ {
+ Job("job-1", tenantId: null),
+ Job("job-2", tenantId: null)
+ };
+
+ await store.SaveManyAsync(jobs);
+
+ Assert.All(jobs, job => Assert.Equal("tenant-a", job.TenantId));
+ var found = (await store.FindManyAsync(new AlterationJobFilter { PlanId = "plan-1" }, CancellationToken.None)).ToList();
+ Assert.Equal(2, found.Count);
+ Assert.All(found, job => Assert.Equal("tenant-a", job.TenantId));
+ }
+
+ [Fact(DisplayName = "SaveAsync does not overwrite * or an explicit TenantId")]
+ public async Task SaveAsync_DoesNotOverwriteAgnosticOrExplicitTenantId()
+ {
+ var store = CreateStore("tenant-a");
+ var agnostic = Job("job-star", Tenant.AgnosticTenantId);
+ var explicitTenant = Job("job-a", "tenant-a");
+
+ await store.SaveAsync(agnostic);
+ await store.SaveAsync(explicitTenant);
+
+ Assert.Equal(Tenant.AgnosticTenantId, agnostic.TenantId);
+ Assert.Equal("tenant-a", explicitTenant.TenantId);
+ }
+
+ [Fact(DisplayName = "FindManyAsync on the default tenant includes null TenantId rows")]
+ public async Task FindManyAsync_WhenAmbientIsDefault_IncludesNullTenantId()
+ {
+ var backing = new MemoryStore();
+ backing.Save(Job("job-null", tenantId: null), x => x.Id);
+ backing.Save(Job("job-a", "tenant-a"), x => x.Id);
+ var store = new MemoryAlterationJobStore(backing, new TestTenantAccessor(Tenant.DefaultTenantId));
+
+ var found = (await store.FindManyAsync(new AlterationJobFilter { PlanId = "plan-1" }, CancellationToken.None)).ToList();
+
+ Assert.Single(found);
+ Assert.Equal("job-null", found[0].Id);
+ }
+
+ [Fact(DisplayName = "FindManyAsync on a named tenant hides null TenantId rows")]
+ public async Task FindManyAsync_WhenAmbientIsNamed_HidesNullTenantId()
+ {
+ var backing = new MemoryStore();
+ backing.Save(Job("job-null", tenantId: null), x => x.Id);
+ backing.Save(Job("job-a", "tenant-a"), x => x.Id);
+ var store = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-a"));
+
+ var found = (await store.FindManyAsync(new AlterationJobFilter { PlanId = "plan-1" }, CancellationToken.None)).ToList();
+
+ Assert.Single(found);
+ Assert.Equal("job-a", found[0].Id);
+ }
+
+ private static MemoryAlterationJobStore CreateStore(string tenantId) =>
+ new(new MemoryStore(), new TestTenantAccessor(tenantId));
+
+ private static async Task SeedMixedTenantsAsync(MemoryAlterationJobStore store)
+ {
+ await store.SaveAsync(Job("job-a", "tenant-a"));
+ await store.SaveAsync(Job("job-b", "tenant-b"));
+ await store.SaveAsync(Job("job-star", Tenant.AgnosticTenantId));
+ }
+
+ private static AlterationJob Job(string id, string? tenantId) =>
+ new()
+ {
+ Id = id,
+ PlanId = "plan-1",
+ WorkflowInstanceId = "instance-1",
+ Status = AlterationJobStatus.Pending,
+ TenantId = tenantId
+ };
+}
diff --git a/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationPlanStoreTenantIsolationTests.cs b/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationPlanStoreTenantIsolationTests.cs
new file mode 100644
index 000000000..2efc30483
--- /dev/null
+++ b/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationPlanStoreTenantIsolationTests.cs
@@ -0,0 +1,196 @@
+using Elsa.Alterations.Core.Entities;
+using Elsa.Alterations.Core.Enums;
+using Elsa.Alterations.Core.Filters;
+using Elsa.Alterations.Core.Stores;
+using Elsa.Common.Multitenancy;
+using Elsa.Common.Services;
+using Elsa.Testing.Shared.Multitenancy;
+
+namespace Elsa.Alterations.Core.UnitTests.Stores;
+
+///
+/// Memory alteration plans must honor ambient tenant the same way EF does via
+/// SetTenantIdFilter / ApplyTenantId. Contracts have no TenantAgnostic flag.
+///
+public class MemoryAlterationPlanStoreTenantIsolationTests
+{
+ [Fact(DisplayName = "FindAsync hides other tenants and keeps * visible")]
+ public async Task FindAsync_HidesOtherTenants()
+ {
+ var store = CreateStore("tenant-a");
+ await SeedMixedTenantsAsync(store);
+
+ var own = await store.FindAsync(new AlterationPlanFilter { Id = "plan-a" });
+ var star = await store.FindAsync(new AlterationPlanFilter { Id = "plan-star" });
+ var other = await store.FindAsync(new AlterationPlanFilter { Id = "plan-b" });
+
+ Assert.NotNull(own);
+ Assert.Equal("plan-a", own.Id);
+ Assert.NotNull(star);
+ Assert.Equal("plan-star", star.Id);
+ Assert.Null(other);
+ }
+
+ [Fact(DisplayName = "CountAsync hides other tenants and keeps * visible")]
+ public async Task CountAsync_HidesOtherTenants()
+ {
+ var store = CreateStore("tenant-a");
+ await SeedMixedTenantsAsync(store);
+
+ var count = await store.CountAsync(new AlterationPlanFilter());
+
+ Assert.Equal(2, count);
+ }
+
+ [Fact(DisplayName = "CountAsync does not count another tenant's row by Id")]
+ public async Task CountAsync_WhenOtherTenant_ReturnsZero()
+ {
+ var store = CreateStore("tenant-a");
+ await SeedMixedTenantsAsync(store);
+
+ var count = await store.CountAsync(new AlterationPlanFilter { Id = "plan-b" });
+
+ Assert.Equal(0, count);
+ }
+
+ [Fact(DisplayName = "SaveAsync refuses to overwrite another tenant's row by Id")]
+ public async Task SaveAsync_WhenOtherTenantOwnsId_ThrowsAndLeavesExisting()
+ {
+ var backing = new MemoryStore();
+ var tenantA = new MemoryAlterationPlanStore(backing, new TestTenantAccessor("tenant-a"));
+ var tenantB = new MemoryAlterationPlanStore(backing, new TestTenantAccessor("tenant-b"));
+ await tenantA.SaveAsync(Plan("shared", "tenant-a"));
+
+ var ex = await Assert.ThrowsAsync(() => tenantB.SaveAsync(Plan("shared", "tenant-b")));
+ var remaining = await tenantA.FindAsync(new AlterationPlanFilter { Id = "shared" });
+
+ Assert.Contains("shared", ex.Message);
+ Assert.NotNull(remaining);
+ Assert.Equal("tenant-a", remaining.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveAsync refuses to overwrite a tenant-agnostic row by Id")]
+ public async Task SaveAsync_WhenAgnosticRowExists_NamedTenantThrowsAndLeavesExisting()
+ {
+ var backing = new MemoryStore();
+ var agnostic = new MemoryAlterationPlanStore(backing, new TestTenantAccessor(Tenant.AgnosticTenantId));
+ var tenantB = new MemoryAlterationPlanStore(backing, new TestTenantAccessor("tenant-b"));
+ await agnostic.SaveAsync(Plan("shared", Tenant.AgnosticTenantId));
+
+ var ex = await Assert.ThrowsAsync(() => tenantB.SaveAsync(Plan("shared", "tenant-b")));
+ var remaining = await agnostic.FindAsync(new AlterationPlanFilter { Id = "shared" });
+
+ Assert.Contains("shared", ex.Message);
+ Assert.NotNull(remaining);
+ Assert.Equal(Tenant.AgnosticTenantId, remaining.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveAsync still lets an agnostic writer update a * row")]
+ public async Task SaveAsync_WhenAmbientIsAgnostic_UpsertsAgnosticRow()
+ {
+ var store = CreateStore(Tenant.AgnosticTenantId);
+ await store.SaveAsync(Plan("shared", Tenant.AgnosticTenantId));
+ var updated = Plan("shared", Tenant.AgnosticTenantId);
+ updated.Status = AlterationPlanStatus.Completed;
+
+ await store.SaveAsync(updated);
+
+ var found = await store.FindAsync(new AlterationPlanFilter { Id = "shared" });
+ Assert.NotNull(found);
+ Assert.Equal(AlterationPlanStatus.Completed, found.Status);
+ Assert.Equal(Tenant.AgnosticTenantId, found.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveAsync still upserts a visible same-tenant row")]
+ public async Task SaveAsync_WhenSameTenantOwnsId_Upserts()
+ {
+ var store = CreateStore("tenant-a");
+ await store.SaveAsync(Plan("plan-a", "tenant-a"));
+ var updated = Plan("plan-a", "tenant-a");
+ updated.Status = AlterationPlanStatus.Completed;
+
+ await store.SaveAsync(updated);
+
+ var found = await store.FindAsync(new AlterationPlanFilter { Id = "plan-a" });
+ Assert.NotNull(found);
+ Assert.Equal(AlterationPlanStatus.Completed, found.Status);
+ Assert.Equal("tenant-a", found.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveAsync stamps the ambient tenant when TenantId is unset")]
+ public async Task SaveAsync_WhenTenantIdUnset_StampsAmbientTenant()
+ {
+ var store = CreateStore("tenant-a");
+ var plan = Plan("plan-new", tenantId: null);
+
+ await store.SaveAsync(plan);
+
+ Assert.Equal("tenant-a", plan.TenantId);
+ Assert.Equal("tenant-a", (await store.FindAsync(new AlterationPlanFilter { Id = "plan-new" }))!.TenantId);
+ }
+
+ [Fact(DisplayName = "SaveAsync does not overwrite * or an explicit TenantId")]
+ public async Task SaveAsync_DoesNotOverwriteAgnosticOrExplicitTenantId()
+ {
+ var store = CreateStore("tenant-a");
+ var agnostic = Plan("plan-star", Tenant.AgnosticTenantId);
+ var explicitTenant = Plan("plan-a", "tenant-a");
+
+ await store.SaveAsync(agnostic);
+ await store.SaveAsync(explicitTenant);
+
+ Assert.Equal(Tenant.AgnosticTenantId, agnostic.TenantId);
+ Assert.Equal("tenant-a", explicitTenant.TenantId);
+ }
+
+ [Fact(DisplayName = "CountAsync on the default tenant includes null TenantId rows")]
+ public async Task CountAsync_WhenAmbientIsDefault_IncludesNullTenantId()
+ {
+ var store = StoreWithPreassignedRows(Tenant.DefaultTenantId);
+
+ var count = await store.CountAsync(new AlterationPlanFilter());
+ var found = await store.FindAsync(new AlterationPlanFilter { Id = "plan-null" });
+
+ Assert.Equal(1, count);
+ Assert.NotNull(found);
+ Assert.Equal("plan-null", found.Id);
+ }
+
+ [Fact(DisplayName = "CountAsync on a named tenant hides null TenantId rows")]
+ public async Task CountAsync_WhenAmbientIsNamed_HidesNullTenantId()
+ {
+ var store = StoreWithPreassignedRows("tenant-a");
+
+ var count = await store.CountAsync(new AlterationPlanFilter());
+ var found = await store.FindAsync(new AlterationPlanFilter { Id = "plan-a" });
+
+ Assert.Equal(1, count);
+ Assert.NotNull(found);
+ Assert.Equal("plan-a", found.Id);
+ }
+
+ private static MemoryAlterationPlanStore CreateStore(string tenantId) =>
+ new(new MemoryStore(), new TestTenantAccessor(tenantId));
+
+ private static MemoryAlterationPlanStore StoreWithPreassignedRows(string ambientTenantId)
+ {
+ var backing = new MemoryStore();
+ backing.Save(Plan("plan-null", tenantId: null), x => x.Id);
+ backing.Save(Plan("plan-a", "tenant-a"), x => x.Id);
+ return new MemoryAlterationPlanStore(backing, new TestTenantAccessor(ambientTenantId));
+ }
+
+ private static async Task SeedMixedTenantsAsync(MemoryAlterationPlanStore store)
+ {
+ await store.SaveAsync(Plan("plan-a", "tenant-a"));
+ await store.SaveAsync(Plan("plan-b", "tenant-b"));
+ await store.SaveAsync(Plan("plan-star", Tenant.AgnosticTenantId));
+ }
+
+ private static AlterationPlan Plan(string id, string? tenantId) =>
+ new()
+ {
+ Id = id,
+ TenantId = tenantId
+ };
+}