Merge remote-tracking branch 'origin/main' into codex/issue-8028-role-remediation-contract

This commit is contained in:
Sipke Schoorstra 2026-09-06 04:11:47 +02:00
commit f9d41ee85b
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
6 changed files with 251 additions and 14 deletions

View file

@ -21,6 +21,7 @@ public class RoleFilter
/// <summary>
/// Gets or sets the tenant to filter for. The tenant-agnostic sentinel is always included, matching
/// the Entity Framework query filter, so a shared platform role remains visible from every tenant.
/// Legacy records without a tenant remain visible to the default tenant for backwards compatibility.
/// </summary>
public string? TenantId { get; set; }
@ -34,8 +35,9 @@ public class RoleFilter
var filter = this;
if (filter.Id != null) queryable = queryable.Where(x => x.Id == filter.Id);
if (filter.Ids != null) queryable = queryable.Where(x => filter.Ids.Contains(x.Id));
if (filter.TenantId != null) queryable = queryable.Where(x => x.TenantId == filter.TenantId || x.TenantId == Tenant.AgnosticTenantId);
if (filter.TenantId != null)
queryable = queryable.Where(x => x.TenantId == filter.TenantId || x.TenantId == Tenant.AgnosticTenantId || (x.TenantId == null && filter.TenantId == Tenant.DefaultTenantId));
return queryable;
}
}
}

View file

@ -26,22 +26,22 @@ public class MemoryRoleStore : IRoleStore
/// <inheritdoc />
public Task AddAsync(Role role, CancellationToken cancellationToken = default)
{
_store.Save(role, x => x.Id);
_store.Save(role, GetStorageKey);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteAsync(RoleFilter filter, CancellationToken cancellationToken = default)
{
var ids = _store.Query(query => Filter(query, filter)).Select(x => x.Id).Distinct().ToList();
_store.DeleteWhere(x => ids.Contains(x.Id));
var roles = _store.Query(query => Filter(query, filter)).ToList();
_store.DeleteMany(roles, GetStorageKey);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveAsync(Role role, CancellationToken cancellationToken = default)
{
_store.Save(role, x => x.Id);
_store.Save(role, GetStorageKey);
return Task.CompletedTask;
}
@ -62,13 +62,19 @@ public class MemoryRoleStore : IRoleStore
/// <remarks>
/// The ambient tenant is applied here rather than left to callers. Isolation previously existed only
/// on the Entity Framework path, and only when multitenancy was enabled, so a deployment running the
/// default in-memory stores had none at all.
/// default in-memory stores had none at all. Null tenant IDs are retained only for the default tenant
/// for backwards compatibility with records created before tenant assignment was added.
/// </remarks>
private IQueryable<Role> Filter(IQueryable<Role> queryable, RoleFilter filter)
{
var tenantId = _tenantAccessor.TenantId;
queryable = queryable.Where(x => x.TenantId == tenantId || x.TenantId == Tenant.AgnosticTenantId || x.TenantId == null);
queryable = queryable.Where(x => x.TenantId == tenantId || x.TenantId == Tenant.AgnosticTenantId || (x.TenantId == null && tenantId == Tenant.DefaultTenantId));
return filter.Apply(queryable);
}
}
private static string GetStorageKey(Role role) => GetStorageKey(role.TenantId, role.Id);
private static string GetStorageKey(string? tenantId, string roleId) =>
$"{tenantId?.Length ?? -1}:{tenantId}{roleId.Length}:{roleId}";
}

View file

@ -1,3 +1,4 @@
using Elsa.Common.Multitenancy;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
@ -8,7 +9,7 @@ namespace Elsa.Identity.Services;
/// <summary>
/// Default implementation of <see cref="IRoleManager"/>.
/// </summary>
public class RoleManager(IRoleStore roleStore, IRoleProvider roleProvider) : IRoleManager
public class RoleManager(IRoleStore roleStore, IRoleProvider roleProvider, ITenantAccessor tenantAccessor) : IRoleManager
{
/// <inheritdoc />
public async Task<CreateRoleResult> CreateRoleAsync(
@ -26,6 +27,8 @@ public class RoleManager(IRoleStore roleStore, IRoleProvider roleProvider) : IRo
{
Id = roleId,
Name = name,
// The in-memory path does not run EF's ApplyTenantId saving handler.
TenantId = tenantAccessor.TenantId,
Permissions = permissions ?? new List<string>()
};

View file

@ -10,6 +10,9 @@ namespace Elsa.Workflows.Activities;
/// <summary>
/// Execute a set of activities in sequence.
/// </summary>
/// <remarks>
/// Rescheduled direct children retain completion callback ownership by this sequence.
/// </remarks>
[Category("Workflows")]
[Activity("Elsa", "Workflows", "Execute a set of activities in sequence.")]
[PublicAPI]
@ -22,6 +25,7 @@ public class Sequence : Container
public Sequence([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
{
OnSignalReceived<BreakSignal>(OnBreakSignalReceived);
OnSignalReceived<ScheduleChildActivity>(OnScheduleChildActivityAsync);
}
/// <inheritdoc />
@ -68,8 +72,27 @@ public class Sequence : Container
await HandleItemAsync(targetContext, childContext);
}
private async ValueTask OnScheduleChildActivityAsync(ScheduleChildActivity signal, SignalContext context)
{
var sequenceContext = context.ReceiverActivityExecutionContext;
var childActivity = signal.ActivityExecutionContext?.Activity ?? signal.Activity;
if (childActivity == null || !Activities.Contains(childActivity))
{
return;
}
context.StopPropagation();
await sequenceContext.ScheduleActivityAsync(childActivity, new ScheduleWorkOptions
{
ExistingActivityExecutionContext = signal.ActivityExecutionContext,
CompletionCallback = OnChildCompleted,
Input = signal.Input
});
}
private void OnBreakSignalReceived(BreakSignal signal, SignalContext signalContext)
{
signalContext.ReceiverActivityExecutionContext.SetIsBreaking();
}
}
}

View file

@ -0,0 +1,139 @@
using Elsa.Alterations.AlterationTypes;
using Elsa.Alterations.Core.Contracts;
using Elsa.Alterations.Extensions;
using Elsa.Common.Models;
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Models;
using Elsa.Workflows.IncidentStrategies;
using Elsa.Workflows.Models;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Activities;
using Elsa.Workflows.Runtime.Messages;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Alterations.IntegrationTests;
public sealed class RetrySequenceTests : IAsyncLifetime
{
private readonly IServiceProvider _services;
private readonly CapturingTextWriter _output = new();
private readonly RetryProbe _probe = new();
public RetrySequenceTests(ITestOutputHelper output)
{
_services = new TestApplicationBuilder(output)
.WithCapturingTextWriter(_output)
.ConfigureServices(services => services.AddSingleton(_probe))
.ConfigureElsa(elsa => elsa.UseAlterations())
.AddWorkflow<RetrySequenceWorkflow>()
.AddWorkflow<RetryStandaloneSequenceWorkflow>()
.AddActivitiesFrom<RetryBookmarkActivity>()
.Build();
}
public Task InitializeAsync() => _services.PopulateRegistriesAsync();
public async Task DisposeAsync() => await ((IAsyncDisposable)_services).DisposeAsync();
[Theory]
[InlineData(nameof(RetrySequenceWorkflow))]
[InlineData(nameof(RetryStandaloneSequenceWorkflow))]
public async Task RetriedChildCompletesItsSequenceAndPreservesTheNextBookmark(string definitionId)
{
var runtime = _services.GetRequiredService<IWorkflowRuntime>();
var client = await runtime.CreateClientAsync();
await client.CreateInstanceAsync(new()
{
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Published)
});
var firstRun = await client.RunInstanceAsync(RunWorkflowInstanceRequest.Empty);
var faultedState = await client.ExportStateAsync();
var incident = Assert.Single(faultedState.Incidents);
Assert.Equal("Retry", incident.ActivityId);
var faultedContext = Assert.Single(faultedState.ActivityExecutionContexts, x => x.Status == ActivityStatus.Faulted);
var sequenceContextId = faultedContext.ParentContextId;
Assert.Equal(1, _probe.Attempts);
Assert.Empty(_output.Lines);
var alterations = new IAlteration[] { new ScheduleActivity { ActivityInstanceId = faultedContext.Id } };
var results = await _services.GetRequiredService<IAlterationRunner>().RunAsync([firstRun.WorkflowInstanceId], alterations);
Assert.True(Assert.Single(results).IsSuccessful);
var alteredState = await client.ExportStateAsync();
var callbacks = alteredState.CompletionCallbacks
.Where(x => x.ChildNodeId == faultedContext.ScheduledActivityNodeId)
.ToList();
Assert.NotEmpty(callbacks);
Assert.All(callbacks, callback => Assert.Equal(sequenceContextId, callback.OwnerInstanceId));
await client.RunInstanceAsync(RunWorkflowInstanceRequest.Empty);
var retriedState = await client.ExportStateAsync();
var retryBookmark = Assert.Single(retriedState.Bookmarks);
Assert.Equal(faultedContext.Id, retryBookmark.ActivityInstanceId);
Assert.Equal(2, _probe.Attempts);
var afterRetry = await client.RunInstanceAsync(new() { BookmarkId = retryBookmark.Id });
var nextState = await client.ExportStateAsync();
var nextBookmark = Assert.Single(nextState.Bookmarks);
Assert.Equal(WorkflowStatus.Running, afterRetry.Status);
Assert.Equal(["Sequence finished"], _output.Lines);
Assert.Equal(faultedState.Incidents.Count, nextState.Incidents.Count);
Assert.NotEqual(retryBookmark.ActivityInstanceId, nextBookmark.ActivityInstanceId);
var finalRun = await client.RunInstanceAsync(new() { BookmarkId = nextBookmark.Id });
Assert.Equal(WorkflowStatus.Finished, finalRun.Status);
Assert.Equal(["Sequence finished", "Done"], _output.Lines);
Assert.Equal(2, _probe.Attempts);
Assert.Empty((await client.ExportStateAsync()).Bookmarks);
}
public sealed class RetryProbe
{
public int Attempts { get; set; }
}
public sealed class RetryBookmarkActivity : Activity
{
protected override void Execute(ActivityExecutionContext context)
{
if (++context.GetRequiredService<RetryProbe>().Attempts == 1)
{
throw new InvalidOperationException("Transient test failure");
}
context.CreateBookmark(new CreateBookmarkArgs());
}
}
public class RetrySequenceWorkflow : WorkflowBase
{
protected virtual bool UseFlowchart => true;
protected override void Build(IWorkflowBuilder builder)
{
builder.WorkflowOptions.IncidentStrategyType = typeof(ContinueWithIncidentsStrategy);
var sequence = new Sequence
{
Activities = { new RetryBookmarkActivity { Id = "Retry" }, new WriteLine("Sequence finished") }
};
var next = new Event("Next");
var end = new WriteLine("Done");
builder.Root = UseFlowchart
? new Flowchart
{
Activities = { sequence, next, end },
Connections = { new Connection(sequence, next), new Connection(next, end) }
}
: new Sequence { Activities = { sequence, next, end } };
}
}
public sealed class RetryStandaloneSequenceWorkflow : RetrySequenceWorkflow
{
protected override bool UseFlowchart => false;
}
}

View file

@ -1,3 +1,4 @@
using Elsa.Common.Multitenancy;
using Elsa.Testing.Shared.Multitenancy;
using Elsa.Common.Services;
using Elsa.Identity.Entities;
@ -8,13 +9,75 @@ namespace Elsa.Identity.UnitTests.Services;
public class RoleManagerTests
{
private readonly TestTenantAccessor _tenantAccessor;
private readonly MemoryRoleStore _roleStore;
private readonly RoleManager _manager;
public RoleManagerTests()
{
_roleStore = new MemoryRoleStore(new MemoryStore<Role>(), TestTenantAccessor.Default);
_manager = new RoleManager(_roleStore, new StoreBasedRoleProvider(_roleStore));
_tenantAccessor = new TestTenantAccessor("tenant-a");
_roleStore = new MemoryRoleStore(new MemoryStore<Role>(), _tenantAccessor);
_manager = new RoleManager(_roleStore, new StoreBasedRoleProvider(_roleStore), _tenantAccessor);
}
[Fact]
public async Task CreateListUpdateAndDeleteAreIsolatedForRolesWithTheSameNameAcrossTenants()
{
var roleA = await _manager.CreateRoleAsync("Operators", ["tenant-a:permission"]);
Assert.Equal("tenant-a", roleA.Role.TenantId);
Assert.Single(await _roleStore.FindManyAsync(new() { TenantId = "tenant-a" }));
using (_tenantAccessor.PushContext(new Tenant { Id = "tenant-b", Name = "Tenant B" }))
{
var roleB = await _manager.CreateRoleAsync("Operators", ["tenant-b:permission"]);
Assert.Equal(roleA.Role.Id, roleB.Role.Id);
Assert.Equal("tenant-b", roleB.Role.TenantId);
Assert.Equal(["tenant-b:permission"], roleB.Role.Permissions);
var tenantBRoles = await _roleStore.FindManyAsync(new() { TenantId = "tenant-b" });
Assert.Single(tenantBRoles);
Assert.Equal(roleB.Role.Id, tenantBRoles.Single().Id);
tenantBRoles.Single().Name = "Operators B";
await _roleStore.SaveAsync(tenantBRoles.Single());
Assert.Equal("Operators B", (await _roleStore.FindAsync(new() { Id = roleB.Role.Id }))!.Name);
}
var tenantARole = await _roleStore.FindAsync(new() { Id = roleA.Role.Id });
Assert.NotNull(tenantARole);
Assert.Equal("Operators", tenantARole.Name);
Assert.Equal(["tenant-a:permission"], tenantARole.Permissions);
tenantARole.Name = "Operators A";
await _roleStore.SaveAsync(tenantARole);
Assert.Equal("Operators A", (await _roleStore.FindAsync(new() { Id = roleA.Role.Id }))!.Name);
await _roleStore.DeleteAsync(new() { Id = roleA.Role.Id });
Assert.Empty(await _roleStore.FindManyAsync(new() { TenantId = "tenant-a" }));
using (_tenantAccessor.PushContext(new Tenant { Id = "tenant-b", Name = "Tenant B" }))
{
var remainingTenantBRole = await _roleStore.FindAsync(new() { Id = roleA.Role.Id });
Assert.NotNull(remainingTenantBRole);
Assert.Equal("Operators B", remainingTenantBRole.Name);
}
}
[Fact]
public async Task DefaultTenantListsLegacyRolesWithoutATenantId()
{
var tenantAccessor = new TestTenantAccessor();
var roleStore = new MemoryRoleStore(new MemoryStore<Role>(), tenantAccessor);
await roleStore.SaveAsync(new Role { Id = "legacy", Name = "Legacy", Permissions = [] });
var roles = await roleStore.FindManyAsync(new() { TenantId = Tenant.DefaultTenantId });
Assert.Single(roles);
Assert.Equal("legacy", roles.Single().Id);
}
[Fact]
@ -24,6 +87,7 @@ public class RoleManagerTests
{
Id = "admin",
Name = "Admin",
TenantId = "tenant-a",
Permissions = [PermissionNames.All]
});
@ -38,7 +102,7 @@ public class RoleManagerTests
[Fact]
public async Task CreateRoleRejectsProvidedAdminRoleIdCollision()
{
var manager = new RoleManager(_roleStore, new AdminRoleProvider());
var manager = new RoleManager(_roleStore, new AdminRoleProvider(), _tenantAccessor);
await Assert.ThrowsAsync<InvalidOperationException>(() => manager.CreateRoleAsync("Replacement", [], "admin"));
}