Skip to content

Commit 163d6e6

Browse files
authored
[codex] Authorize workflow instance SignalR observation (#7504)
* Authorize workflow instance SignalR subscriptions * Address SignalR observation review feedback * Align workflow hub permission checks with FastEndpoints * Address workflow instance hub review feedback
1 parent cec3281 commit 163d6e6

4 files changed

Lines changed: 287 additions & 6 deletions

File tree

Elsa.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets", "src\modules
345345
EndProject
346346
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.UnitTests", "test\unit\Elsa.Secrets.UnitTests\Elsa.Secrets.UnitTests.csproj", "{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}"
347347
EndProject
348+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Workflows.Api.UnitTests", "test\unit\Elsa.Workflows.Api.UnitTests\Elsa.Workflows.Api.UnitTests.csproj", "{81CFD2E0-2E5E-4810-ADB8-A08301199166}"
349+
EndProject
348350
Global
349351
GlobalSection(SolutionConfigurationPlatforms) = preSolution
350352
Debug|Any CPU = Debug|Any CPU
@@ -1373,6 +1375,18 @@ Global
13731375
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Release|x64.Build.0 = Release|Any CPU
13741376
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Release|x86.ActiveCfg = Release|Any CPU
13751377
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9}.Release|x86.Build.0 = Release|Any CPU
1378+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
1379+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Debug|Any CPU.Build.0 = Debug|Any CPU
1380+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Debug|x64.ActiveCfg = Debug|Any CPU
1381+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Debug|x64.Build.0 = Debug|Any CPU
1382+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Debug|x86.ActiveCfg = Debug|Any CPU
1383+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Debug|x86.Build.0 = Debug|Any CPU
1384+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Release|Any CPU.ActiveCfg = Release|Any CPU
1385+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Release|Any CPU.Build.0 = Release|Any CPU
1386+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Release|x64.ActiveCfg = Release|Any CPU
1387+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Release|x64.Build.0 = Release|Any CPU
1388+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Release|x86.ActiveCfg = Release|Any CPU
1389+
{81CFD2E0-2E5E-4810-ADB8-A08301199166}.Release|x86.Build.0 = Release|Any CPU
13761390
EndGlobalSection
13771391
GlobalSection(SolutionProperties) = preSolution
13781392
HideSolutionNode = FALSE
@@ -1495,6 +1509,7 @@ Global
14951509
{93E9213A-694D-4AB4-870E-05E44F793133} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
14961510
{09B4B78B-FE02-44E2-8667-E182AF921C54} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
14971511
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9} = {18453B51-25EB-4317-A4B3-B10518252E92}
1512+
{81CFD2E0-2E5E-4810-ADB8-A08301199166} = {18453B51-25EB-4317-A4B3-B10518252E92}
14981513
EndGlobalSection
14991514
GlobalSection(ExtensibilityGlobals) = postSolution
15001515
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

src/modules/Elsa.Workflows.Api/RealTime/Hubs/WorkflowInstanceHub.cs

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
using Elsa.Common.Multitenancy;
2+
using Elsa.Extensions;
13
using Elsa.Workflows.Api.RealTime.Contracts;
2-
using Elsa.Workflows.Runtime;
4+
using Elsa.Workflows.Management;
5+
using Elsa.Workflows.Management.Entities;
6+
using Elsa.Workflows.Management.Filters;
7+
using FastEndpoints.Security;
38
using JetBrains.Annotations;
49
using Microsoft.AspNetCore.Authorization;
510
using Microsoft.AspNetCore.SignalR;
@@ -13,12 +18,17 @@ namespace Elsa.Workflows.Api.RealTime.Hubs;
1318
[Authorize]
1419
public class WorkflowInstanceHub : Hub<IWorkflowInstanceClient>
1520
{
16-
private readonly IWorkflowRuntime _workflowRuntime;
21+
private const string ReadWorkflowInstancesPermission = "read:workflow-instances";
22+
private const string ReadAllPermission = "read:*";
23+
private static readonly string[] ReadPermissions = [PermissionNames.All, ReadAllPermission, ReadWorkflowInstancesPermission];
24+
private readonly IWorkflowInstanceStore _workflowInstanceStore;
25+
private readonly ITenantAccessor? _tenantAccessor;
1726

1827
/// <inheritdoc />
19-
public WorkflowInstanceHub(IWorkflowRuntime workflowRuntime)
28+
public WorkflowInstanceHub(IWorkflowInstanceStore workflowInstanceStore, ITenantAccessor? tenantAccessor = null)
2029
{
21-
_workflowRuntime = workflowRuntime;
30+
_workflowInstanceStore = workflowInstanceStore;
31+
_tenantAccessor = tenantAccessor;
2232
}
2333

2434
/// <summary>
@@ -27,7 +37,39 @@ public WorkflowInstanceHub(IWorkflowRuntime workflowRuntime)
2737
/// <param name="instanceId">The ID of the workflow instance to observe.</param>
2838
public async Task ObserveInstanceAsync(string instanceId)
2939
{
40+
if (!CanReadWorkflowInstances())
41+
throw new HubException("Access denied.");
42+
43+
var workflowInstance = await _workflowInstanceStore.FindAsync(new WorkflowInstanceFilter { Id = instanceId }, Context.ConnectionAborted);
44+
45+
if (!CanAccessTenant(workflowInstance, _tenantAccessor))
46+
throw new HubException("Access denied.");
47+
3048
// Join the user to the workflow instance group.
31-
await Groups.AddToGroupAsync(Context.ConnectionId, instanceId);
49+
await Groups.AddToGroupAsync(Context.ConnectionId, instanceId, Context.ConnectionAborted);
50+
}
51+
52+
private bool CanReadWorkflowInstances()
53+
{
54+
var user = Context.User;
55+
56+
if (user?.Identity?.IsAuthenticated != true)
57+
return false;
58+
59+
return ReadPermissions.Any(user.HasPermission);
60+
}
61+
62+
private static bool CanAccessTenant(WorkflowInstance? workflowInstance, ITenantAccessor? tenantAccessor)
63+
{
64+
if (workflowInstance == null)
65+
return false;
66+
67+
if (tenantAccessor == null)
68+
return true;
69+
70+
var workflowInstanceTenantId = workflowInstance.TenantId.NormalizeTenantId();
71+
var currentTenantId = tenantAccessor.TenantId.NormalizeTenantId();
72+
73+
return workflowInstanceTenantId == Tenant.AgnosticTenantId || workflowInstanceTenantId == currentTenantId;
3274
}
33-
}
75+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<Include>[Elsa.Workflows.Api]*</Include>
5+
</PropertyGroup>
6+
7+
<ItemGroup>
8+
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
9+
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Api\Elsa.Workflows.Api.csproj" />
10+
</ItemGroup>
11+
12+
</Project>
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
using System.Security.Claims;
2+
using Elsa.Common.Multitenancy;
3+
using Elsa.Workflows.Api.RealTime.Hubs;
4+
using Elsa.Workflows.Management;
5+
using Elsa.Workflows.Management.Entities;
6+
using Elsa.Workflows.Management.Filters;
7+
using FastEndpoints;
8+
using Microsoft.AspNetCore.SignalR;
9+
using NSubstitute;
10+
11+
namespace Elsa.Workflows.Api.UnitTests.RealTime.Hubs;
12+
13+
[Collection(nameof(WorkflowInstanceHubTestsCollection))]
14+
public class WorkflowInstanceHubTests : IDisposable
15+
{
16+
private const string ConnectionId = "connection-1";
17+
private const string WorkflowInstanceId = "workflow-instance-1";
18+
private const string TenantId = "tenant-a";
19+
private const string OtherTenantId = "tenant-b";
20+
private const string ReadWorkflowInstancesPermission = "read:workflow-instances";
21+
private const string CustomPermissionsClaimType = "elsa-permissions";
22+
private readonly IWorkflowInstanceStore _workflowInstanceStore;
23+
private readonly ITenantAccessor _tenantAccessor;
24+
private readonly IGroupManager _groups;
25+
private readonly HubCallerContext _context;
26+
private readonly WorkflowInstanceHub _hub;
27+
private readonly string _originalPermissionsClaimType;
28+
private readonly CancellationTokenSource _connectionAbortedTokenSource = new();
29+
private WorkflowInstance? _workflowInstance;
30+
31+
public WorkflowInstanceHubTests()
32+
{
33+
_originalPermissionsClaimType = GetPermissionsClaimType();
34+
UsePermissionsClaimType("permissions");
35+
_workflowInstanceStore = Substitute.For<IWorkflowInstanceStore>();
36+
_tenantAccessor = Substitute.For<ITenantAccessor>();
37+
_groups = Substitute.For<IGroupManager>();
38+
_context = Substitute.For<HubCallerContext>();
39+
_workflowInstance = CreateWorkflowInstance(TenantId);
40+
_hub = new WorkflowInstanceHub(_workflowInstanceStore, _tenantAccessor)
41+
{
42+
Context = _context,
43+
Groups = _groups
44+
};
45+
46+
_tenantAccessor.TenantId.Returns(TenantId);
47+
_context.ConnectionId.Returns(ConnectionId);
48+
_context.ConnectionAborted.Returns(_connectionAbortedTokenSource.Token);
49+
UseUser(ReadWorkflowInstancesPermission);
50+
StubWorkflowInstance();
51+
}
52+
53+
public void Dispose()
54+
{
55+
UsePermissionsClaimType(_originalPermissionsClaimType);
56+
_connectionAbortedTokenSource.Dispose();
57+
}
58+
59+
[Fact]
60+
public async Task ObserveInstanceAsync_WithReadPermissionAndMatchingTenant_JoinsInstanceGroup()
61+
{
62+
await _hub.ObserveInstanceAsync(WorkflowInstanceId);
63+
64+
await _groups.Received(1).AddToGroupAsync(ConnectionId, WorkflowInstanceId, _connectionAbortedTokenSource.Token);
65+
}
66+
67+
[Theory]
68+
[InlineData("*")]
69+
[InlineData("read:*")]
70+
public async Task ObserveInstanceAsync_WithWildcardReadPermission_JoinsInstanceGroup(string permission)
71+
{
72+
UseUser(permission);
73+
74+
await _hub.ObserveInstanceAsync(WorkflowInstanceId);
75+
76+
await _groups.Received(1).AddToGroupAsync(ConnectionId, WorkflowInstanceId, _connectionAbortedTokenSource.Token);
77+
}
78+
79+
[Fact]
80+
public async Task ObserveInstanceAsync_UsesConfiguredFastEndpointsPermissionClaimType()
81+
{
82+
UsePermissionsClaimType(CustomPermissionsClaimType);
83+
UseUserClaim(CustomPermissionsClaimType, ReadWorkflowInstancesPermission);
84+
85+
await _hub.ObserveInstanceAsync(WorkflowInstanceId);
86+
87+
await _groups.Received(1).AddToGroupAsync(ConnectionId, WorkflowInstanceId, _connectionAbortedTokenSource.Token);
88+
}
89+
90+
[Fact]
91+
public async Task ObserveInstanceAsync_WithoutReadPermission_DoesNotJoinInstanceGroup()
92+
{
93+
UseUser("write:workflow-instances");
94+
95+
await Assert.ThrowsAsync<HubException>(() => _hub.ObserveInstanceAsync(WorkflowInstanceId));
96+
97+
await _groups.DidNotReceive().AddToGroupAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
98+
}
99+
100+
[Fact]
101+
public async Task ObserveInstanceAsync_WithDifferentTenant_DoesNotJoinInstanceGroup()
102+
{
103+
UseWorkflowInstanceTenant(OtherTenantId);
104+
105+
await Assert.ThrowsAsync<HubException>(() => _hub.ObserveInstanceAsync(WorkflowInstanceId));
106+
107+
await _groups.DidNotReceive().AddToGroupAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
108+
}
109+
110+
[Fact]
111+
public async Task ObserveInstanceAsync_WhenInstanceIsNotVisible_DoesNotJoinInstanceGroup()
112+
{
113+
UseWorkflowInstance(null);
114+
115+
await Assert.ThrowsAsync<HubException>(() => _hub.ObserveInstanceAsync(WorkflowInstanceId));
116+
117+
await _groups.DidNotReceive().AddToGroupAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
118+
}
119+
120+
[Fact]
121+
public async Task ObserveInstanceAsync_WithTenantAgnosticInstance_JoinsInstanceGroup()
122+
{
123+
UseWorkflowInstanceTenant(Tenant.AgnosticTenantId);
124+
125+
await _hub.ObserveInstanceAsync(WorkflowInstanceId);
126+
127+
await _groups.Received(1).AddToGroupAsync(ConnectionId, WorkflowInstanceId, _connectionAbortedTokenSource.Token);
128+
}
129+
130+
[Fact]
131+
public async Task ObserveInstanceAsync_WithoutTenantAccessor_JoinsVisibleInstanceGroup()
132+
{
133+
var hub = new WorkflowInstanceHub(_workflowInstanceStore)
134+
{
135+
Context = _context,
136+
Groups = _groups
137+
};
138+
139+
await hub.ObserveInstanceAsync(WorkflowInstanceId);
140+
141+
await _groups.Received(1).AddToGroupAsync(ConnectionId, WorkflowInstanceId, _connectionAbortedTokenSource.Token);
142+
}
143+
144+
[Fact]
145+
public async Task ObserveInstanceAsync_WithNonCanonicalCurrentTenant_NormalizesBeforeComparing()
146+
{
147+
_tenantAccessor.TenantId.Returns(string.Empty);
148+
UseWorkflowInstanceTenant(null);
149+
150+
await _hub.ObserveInstanceAsync(WorkflowInstanceId);
151+
152+
await _groups.Received(1).AddToGroupAsync(ConnectionId, WorkflowInstanceId, _connectionAbortedTokenSource.Token);
153+
}
154+
155+
private void UseUser(params string[] permissions)
156+
{
157+
var claims = permissions.Select(x => new Claim("permissions", x));
158+
var identity = new ClaimsIdentity(claims, "Test");
159+
_context.User.Returns(new ClaimsPrincipal(identity));
160+
}
161+
162+
private void UseUserClaim(string claimType, params string[] permissions)
163+
{
164+
var claims = permissions.Select(x => new Claim(claimType, x));
165+
var identity = new ClaimsIdentity(claims, "Test");
166+
_context.User.Returns(new ClaimsPrincipal(identity));
167+
}
168+
169+
private void UseWorkflowInstanceTenant(string? tenantId)
170+
{
171+
UseWorkflowInstance(CreateWorkflowInstance(tenantId));
172+
}
173+
174+
private void UseWorkflowInstance(WorkflowInstance? workflowInstance)
175+
{
176+
_workflowInstance = workflowInstance;
177+
StubWorkflowInstance();
178+
}
179+
180+
private void StubWorkflowInstance()
181+
{
182+
_workflowInstanceStore
183+
.FindAsync(Arg.Is<WorkflowInstanceFilter>(x => x.Id == WorkflowInstanceId), Arg.Any<CancellationToken>())
184+
.Returns(_ => new ValueTask<WorkflowInstance?>(_workflowInstance));
185+
}
186+
187+
private static WorkflowInstance CreateWorkflowInstance(string? tenantId)
188+
{
189+
return new()
190+
{
191+
Id = WorkflowInstanceId,
192+
TenantId = tenantId,
193+
DefinitionId = "definition-1",
194+
DefinitionVersionId = "definition-version-1"
195+
};
196+
}
197+
198+
private static void UsePermissionsClaimType(string claimType)
199+
{
200+
var property = typeof(SecurityOptions).GetProperty(nameof(SecurityOptions.PermissionsClaimType))!;
201+
property.SetValue(new Config().Security, claimType);
202+
}
203+
204+
private static string GetPermissionsClaimType()
205+
{
206+
var property = typeof(SecurityOptions).GetProperty(nameof(SecurityOptions.PermissionsClaimType))!;
207+
return (string)property.GetValue(new Config().Security)!;
208+
}
209+
}
210+
211+
[CollectionDefinition(nameof(WorkflowInstanceHubTestsCollection), DisableParallelization = true)]
212+
public class WorkflowInstanceHubTestsCollection;

0 commit comments

Comments
 (0)