Merge remote-tracking branch 'origin/main' into codex/issue-8028-role-remediation-contract
This commit is contained in:
commit
f9d41ee85b
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue