diff --git a/doc/wiki/build-run-operate.md b/doc/wiki/build-run-operate.md index 99aabe337..90ba5214e 100644 --- a/doc/wiki/build-run-operate.md +++ b/doc/wiki/build-run-operate.md @@ -123,10 +123,10 @@ See [Program.cs](../../src/apps/Elsa.Server.Web/Program.cs). With default route prefix `elsa/api`, runtime admin endpoints include: -- `GET /elsa/api/admin/workflow-runtime/status` -- `POST /elsa/api/admin/workflow-runtime/pause` -- `POST /elsa/api/admin/workflow-runtime/resume` -- `POST /elsa/api/admin/workflow-runtime/force-drain` +- `GET /elsa/api/admin/workflow-runtime/status`: requires `read:workflow-runtime`; `ManageWorkflowRuntime` is also accepted for backward compatibility. +- `POST /elsa/api/admin/workflow-runtime/pause`: requires `ManageWorkflowRuntime`. +- `POST /elsa/api/admin/workflow-runtime/resume`: requires `ManageWorkflowRuntime`. +- `POST /elsa/api/admin/workflow-runtime/force-drain`: requires `ManageWorkflowRuntime`. Structured log diagnostics endpoints include: diff --git a/doc/wiki/workflow-runtime.md b/doc/wiki/workflow-runtime.md index 33e7f4a6c..d3d539fc3 100644 --- a/doc/wiki/workflow-runtime.md +++ b/doc/wiki/workflow-runtime.md @@ -159,10 +159,10 @@ Ingress source adapters are currently registered by modules such as HTTP and Sch The workflow API includes runtime admin endpoints: -- `GET /elsa/api/admin/workflow-runtime/status` -- `POST /elsa/api/admin/workflow-runtime/pause` -- `POST /elsa/api/admin/workflow-runtime/resume` -- `POST /elsa/api/admin/workflow-runtime/force-drain` +- `GET /elsa/api/admin/workflow-runtime/status`: requires `read:workflow-runtime`; `ManageWorkflowRuntime` is also accepted for backward compatibility. +- `POST /elsa/api/admin/workflow-runtime/pause`: requires `ManageWorkflowRuntime`. +- `POST /elsa/api/admin/workflow-runtime/resume`: requires `ManageWorkflowRuntime`. +- `POST /elsa/api/admin/workflow-runtime/force-drain`: requires `ManageWorkflowRuntime`. Endpoint code lives under [Elsa.Workflows.Api/Endpoints/RuntimeAdmin](../../src/modules/Elsa.Workflows.Api/Endpoints/RuntimeAdmin). The service behind these endpoints is [WorkflowRuntimeAdminService](../../src/modules/Elsa.Workflows.Runtime/Services/WorkflowRuntimeAdminService.cs). diff --git a/src/common/Elsa.Api.Common/PermissionNames.cs b/src/common/Elsa.Api.Common/PermissionNames.cs index c56266d90..951010aa9 100644 --- a/src/common/Elsa.Api.Common/PermissionNames.cs +++ b/src/common/Elsa.Api.Common/PermissionNames.cs @@ -16,10 +16,15 @@ public static class PermissionNames public const string ExecutePythonExpressions = "exec:python-expressions"; /// - /// Permission required to pause, resume, force-drain, or query the workflow runtime's graceful-shutdown status. + /// Permission required to pause, resume, or force-drain the workflow runtime. /// public const string ManageWorkflowRuntime = "ManageWorkflowRuntime"; + /// + /// Permission required to query workflow runtime status. + /// + public const string ReadWorkflowRuntime = "read:workflow-runtime"; + /// /// Permission required to list or inspect bookmark queue dead-letter items. /// diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/RuntimeAdmin/Status/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/RuntimeAdmin/Status/Endpoint.cs index 4cd740836..5667447f7 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/RuntimeAdmin/Status/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/RuntimeAdmin/Status/Endpoint.cs @@ -15,7 +15,7 @@ internal sealed class StatusEndpoint(IWorkflowRuntimeAdminService admin) : ElsaE public override void Configure() { Get("/admin/workflow-runtime/status"); - ConfigurePermissions(PermissionNames.ManageWorkflowRuntime); + ConfigurePermissions(PermissionNames.ReadWorkflowRuntime, PermissionNames.ManageWorkflowRuntime); } public override async Task HandleAsync(CancellationToken ct) diff --git a/test/unit/Elsa.Workflows.Api.UnitTests/Endpoints/RuntimeAdmin/RuntimeAdminAuthorizationTests.cs b/test/unit/Elsa.Workflows.Api.UnitTests/Endpoints/RuntimeAdmin/RuntimeAdminAuthorizationTests.cs new file mode 100644 index 000000000..aceb4b08c --- /dev/null +++ b/test/unit/Elsa.Workflows.Api.UnitTests/Endpoints/RuntimeAdmin/RuntimeAdminAuthorizationTests.cs @@ -0,0 +1,82 @@ +using System.Reflection; +using Elsa.Workflows.Runtime; +using FastEndpoints; +using NSubstitute; +using WorkflowsApiFeature = Elsa.Workflows.Api.Features.WorkflowsApiFeature; + +namespace Elsa.Workflows.Api.UnitTests.Endpoints.RuntimeAdmin; + +public class RuntimeAdminAuthorizationTests +{ + [Fact] + public void StatusEndpoint_AllowsReadWorkflowRuntimePermission() + { + var permissions = GetConfiguredPermissions("Elsa.Workflows.Api.Endpoints.RuntimeAdmin.Status.StatusEndpoint"); + + Assert.Contains(PermissionNames.All, permissions); + Assert.Contains(PermissionNames.ReadWorkflowRuntime, permissions); + Assert.Contains(PermissionNames.ManageWorkflowRuntime, permissions); + } + + [Theory] + [InlineData("Elsa.Workflows.Api.Endpoints.RuntimeAdmin.Pause.PauseEndpoint")] + [InlineData("Elsa.Workflows.Api.Endpoints.RuntimeAdmin.Resume.ResumeEndpoint")] + [InlineData("Elsa.Workflows.Api.Endpoints.RuntimeAdmin.ForceDrain.ForceDrainEndpoint")] + public void MutatingEndpoints_RequireManageWorkflowRuntimePermission(string endpointTypeName) + { + var permissions = GetConfiguredPermissions(endpointTypeName); + + Assert.Contains(PermissionNames.ManageWorkflowRuntime, permissions); + Assert.DoesNotContain(PermissionNames.ReadWorkflowRuntime, permissions); + } + + private static IReadOnlyCollection GetConfiguredPermissions(string endpointTypeName) + { + var endpointType = typeof(WorkflowsApiFeature).Assembly.GetType(endpointTypeName, throwOnError: true)!; + var endpoint = Activator.CreateInstance( + endpointType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + [Substitute.For()], + null)!; + var (requestDtoType, responseDtoType) = GetEndpointDtoTypes(endpointType); + var definition = new EndpointDefinition(endpointType, requestDtoType, responseDtoType); + + endpointType + .GetProperty("Definition", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)! + .SetValue(endpoint, definition); + + endpointType.GetMethod("Configure")!.Invoke(endpoint, null); + + var permissions = definition + .GetType() + .GetProperty("AllowedPermissions", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)! + .GetValue(definition); + + return Assert.IsAssignableFrom>(permissions).ToArray(); + } + + private static (Type RequestDtoType, Type ResponseDtoType) GetEndpointDtoTypes(Type endpointType) + { + var type = endpointType; + + while (type.BaseType != null) + { + type = type.BaseType; + + if (!type.IsGenericType) + continue; + + var genericTypeDefinition = type.GetGenericTypeDefinition(); + var genericArguments = type.GetGenericArguments(); + + if (genericTypeDefinition == typeof(Abstractions.ElsaEndpoint<,>)) + return (genericArguments[0], genericArguments[1]); + + if (genericTypeDefinition == typeof(Abstractions.ElsaEndpointWithoutRequest<>)) + return (typeof(EmptyRequest), genericArguments[0]); + } + + throw new InvalidOperationException($"Unsupported endpoint type '{endpointType.FullName}'."); + } +}