From 2fa1a9ef8ecd3c5c2f957c6707b2b9af187794dd Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 21 May 2026 00:50:25 +0200 Subject: [PATCH] [codex] Harden C# expression host-code execution (#7519) * Harden C# expression host-code execution * Address script authorization review feedback * Harden script authorization failure responses * Address code quality review feedback * Use explicit failure filter in script authorization * Centralize script activity type names * Address script authorization review feedback --- doc/wiki/expressions-and-scripting.md | 3 + src/apps/Elsa.Server.Web/Program.cs | 1 + src/apps/Elsa.Server.Web/appsettings.json | 3 + src/common/Elsa.Api.Common/PermissionNames.cs | 5 + .../TestApplicationBuilder.cs | 4 +- .../Activities/RunCSharp/RunCSharp.cs | 4 +- .../CSharpActivityDescriptorModifier.cs | 17 ++ .../Features/CSharpFeature.cs | 4 +- .../Options/CSharpOptions.cs | 8 +- .../CSharpExpressionDescriptorProvider.cs | 7 +- .../Services/CSharpEvaluator.cs | 5 +- .../ShellFeatures/CSharpFeature.cs | 5 +- .../Activities/RunPython/RunPython.cs | 4 +- .../PythonActivityDescriptorModifier.cs | 5 +- .../ExpressionDescriptors/List/Endpoint.cs | 14 +- .../BulkDispatch/Endpoint.cs | 8 +- .../BulkPublish/Endpoint.cs | 8 +- .../WorkflowDefinitions/Dispatch/Endpoint.cs | 8 +- .../Execute/GetEndpoint.cs | 4 +- .../Execute/PostEndpoint.cs | 4 +- .../Execute/WorkflowExecutionHelper.cs | 14 +- .../WorkflowDefinitions/Import/Endpoint.cs | 12 +- .../ImportFiles/Endpoint.cs | 12 +- .../WorkflowDefinitions/Post/Endpoint.cs | 8 +- .../WorkflowDefinitions/Publish/Endpoint.cs | 8 +- .../Features/WorkflowsApiFeature.cs | 2 +- ...nWorkflowDefinitionAuthorizationService.cs | 78 ------- ...owDefinitionScriptAuthorizationFailure.cs} | 8 +- ...lowDefinitionScriptAuthorizationService.cs | 106 ++++++++++ .../ShellFeatures/WorkflowsApiFeature.cs | 2 +- .../WorkflowScriptActivityTypeNames.cs | 32 +++ ...flowDefinitionAuthorizationServiceTests.cs | 110 ---------- ...finitionScriptAuthorizationServiceTests.cs | 198 ++++++++++++++++++ .../CSharp/CSharpHostCodeExecutionTests.cs | 54 +++++ 34 files changed, 507 insertions(+), 258 deletions(-) create mode 100644 src/modules/Elsa.Expressions.CSharp/ActivityDescriptorModifiers/CSharpActivityDescriptorModifier.cs delete mode 100644 src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationService.cs rename src/modules/Elsa.Workflows.Api/Security/{PythonWorkflowDefinitionAuthorizationFailure.cs => WorkflowDefinitionScriptAuthorizationFailure.cs} (59%) create mode 100644 src/modules/Elsa.Workflows.Api/Security/WorkflowDefinitionScriptAuthorizationService.cs create mode 100644 src/modules/Elsa.Workflows.Core/Constants/WorkflowScriptActivityTypeNames.cs delete mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Security/PythonWorkflowDefinitionAuthorizationServiceTests.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Security/WorkflowDefinitionScriptAuthorizationServiceTests.cs create mode 100644 test/unit/Elsa.Expressions.UnitTests/CSharp/CSharpHostCodeExecutionTests.cs diff --git a/doc/wiki/expressions-and-scripting.md b/doc/wiki/expressions-and-scripting.md index 912d31eb2..f49072967 100644 --- a/doc/wiki/expressions-and-scripting.md +++ b/doc/wiki/expressions-and-scripting.md @@ -55,11 +55,14 @@ Additional JavaScript libraries are in [Elsa.Expressions.JavaScript.Libraries](. ```csharp elsa.UseCSharp(options => { + options.AllowHostCodeExecution = true; options.DisableWrappers = disableVariableWrappers; options.AppendScript("string Greet(string name) => $\"Hello {name}!\";"); }); ``` +Roslyn C# scripting is privileged host-code execution, not a sandbox. Hosts must explicitly set `CSharpOptions.AllowHostCodeExecution` to `true` before C# expressions or `RunCSharp` can be authored or executed. API callers that author, publish, dispatch, or directly execute workflows containing C# must have the `exec:csharp-expressions` permission. + ## Python [PythonFeature](../../src/modules/Elsa.Expressions.Python/Features/PythonFeature.cs) registers pythonnet-based evaluation and configures `PythonGlobalInterpreterManager` as a hosted service. Python.NET execution is privileged host-code execution, not a sandbox. Python code can access host process capabilities through pythonnet and must only be enabled for trusted workflow authors. diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index 667108ce4..ac0403b53 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -86,6 +86,7 @@ services .UseScheduling() .UseCSharp(options => { + configuration.GetSection("Scripting:CSharp").Bind(options); options.DisableWrappers = disableVariableWrappers; options.AppendScript("string Greet(string name) => $\"Hello {name}!\";"); options.AppendScript("string SayHelloWorld() => Greet(\"World\");"); diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index d997223f0..a1b740393 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -84,6 +84,9 @@ ] }, "Scripting": { + "CSharp": { + "AllowHostCodeExecution": false + }, "Python": { "AllowHostCodeExecution": true, "PythonDllPath": "", diff --git a/src/common/Elsa.Api.Common/PermissionNames.cs b/src/common/Elsa.Api.Common/PermissionNames.cs index cf75a1662..902d1ca74 100644 --- a/src/common/Elsa.Api.Common/PermissionNames.cs +++ b/src/common/Elsa.Api.Common/PermissionNames.cs @@ -5,6 +5,11 @@ public static class PermissionNames public const string All = "*"; public const string ClaimType = "permissions"; + /// + /// Permission required to author or execute C# workflow expressions. + /// + public const string ExecuteCSharpExpressions = "exec:csharp-expressions"; + /// /// Permission required to author or execute Python.NET workflow expressions. /// diff --git a/src/common/Elsa.Testing.Shared.Integration/TestApplicationBuilder.cs b/src/common/Elsa.Testing.Shared.Integration/TestApplicationBuilder.cs index ac382f618..ccfc6dc80 100644 --- a/src/common/Elsa.Testing.Shared.Integration/TestApplicationBuilder.cs +++ b/src/common/Elsa.Testing.Shared.Integration/TestApplicationBuilder.cs @@ -36,7 +36,7 @@ public class TestApplicationBuilder _configureElsa += elsa => elsa .AddActivitiesFrom() .UseScheduling() - .UseCSharp() + .UseCSharp(options => options.AllowHostCodeExecution = true) .UseJavaScript() .UseLiquid() .UseWorkflowManagement() @@ -119,4 +119,4 @@ public class TestApplicationBuilder _configureElsa += elsa => elsa.UseFluentStorageProvider(storage => storage.BlobStorage = sp => StorageFactory.Blobs.DirectoryFiles(Path.Combine(workflowsDirectory))); return this; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.CSharp/Activities/RunCSharp/RunCSharp.cs b/src/modules/Elsa.Expressions.CSharp/Activities/RunCSharp/RunCSharp.cs index 0bd9ac3dd..60d2638d1 100644 --- a/src/modules/Elsa.Expressions.CSharp/Activities/RunCSharp/RunCSharp.cs +++ b/src/modules/Elsa.Expressions.CSharp/Activities/RunCSharp/RunCSharp.cs @@ -15,7 +15,7 @@ namespace Elsa.Expressions.CSharp.Activities; /// /// Executes C# code. /// -[Activity("Elsa", "Scripting", "Executes C# code", DisplayName = "Run C#")] +[Activity(WorkflowScriptActivityTypeNames.Namespace, WorkflowScriptActivityTypeNames.RunCSharpType, 1, "Executes C# code", "Scripting", DisplayName = "Run C#")] public class RunCSharp : CodeActivity { /// @@ -72,4 +72,4 @@ public class RunCSharp : CodeActivity // Complete the activity with the outcome. await context.CompleteActivityWithOutcomesAsync(outcomes); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.CSharp/ActivityDescriptorModifiers/CSharpActivityDescriptorModifier.cs b/src/modules/Elsa.Expressions.CSharp/ActivityDescriptorModifiers/CSharpActivityDescriptorModifier.cs new file mode 100644 index 000000000..5090fed67 --- /dev/null +++ b/src/modules/Elsa.Expressions.CSharp/ActivityDescriptorModifiers/CSharpActivityDescriptorModifier.cs @@ -0,0 +1,17 @@ +using Elsa.Expressions.CSharp.Options; +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Microsoft.Extensions.Options; + +namespace Elsa.Expressions.CSharp.ActivityDescriptorModifiers; + +internal class CSharpActivityDescriptorModifier(IOptions options) : IActivityDescriptorModifier +{ + public void Modify(ActivityDescriptor descriptor) + { + if (descriptor.TypeName != WorkflowScriptActivityTypeNames.RunCSharp) + return; + + descriptor.IsBrowsable = options.Value.AllowHostCodeExecution; + } +} diff --git a/src/modules/Elsa.Expressions.CSharp/Features/CSharpFeature.cs b/src/modules/Elsa.Expressions.CSharp/Features/CSharpFeature.cs index cd07da4ff..36caea9e3 100644 --- a/src/modules/Elsa.Expressions.CSharp/Features/CSharpFeature.cs +++ b/src/modules/Elsa.Expressions.CSharp/Features/CSharpFeature.cs @@ -1,5 +1,6 @@ using Elsa.Caching.Features; using Elsa.Common.Features; +using Elsa.Expressions.CSharp.ActivityDescriptorModifiers; using Elsa.Expressions.CSharp.Activities; using Elsa.Expressions.CSharp.Contracts; using Elsa.Expressions.CSharp.Options; @@ -42,6 +43,7 @@ public class CSharpFeature : FeatureBase Services .AddExpressionDescriptorProvider() .AddScoped() + .AddSingleton() ; // Handlers. @@ -53,4 +55,4 @@ public class CSharpFeature : FeatureBase // UI property handlers. Services.AddScoped(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.CSharp/Options/CSharpOptions.cs b/src/modules/Elsa.Expressions.CSharp/Options/CSharpOptions.cs index a173a9a77..b2ba42048 100644 --- a/src/modules/Elsa.Expressions.CSharp/Options/CSharpOptions.cs +++ b/src/modules/Elsa.Expressions.CSharp/Options/CSharpOptions.cs @@ -16,6 +16,12 @@ namespace Elsa.Expressions.CSharp.Options; /// public class CSharpOptions { + /// + /// Gets or sets whether workflow-authored Roslyn C# host-code execution is allowed. + /// Roslyn scripting is not a sandbox and can access host process capabilities. + /// + public bool AllowHostCodeExecution { get; set; } + /// /// A list of callbacks that is invoked when a C# expression is evaluated. Use this to configure the . /// @@ -101,4 +107,4 @@ public class CSharpOptions ConfigureScriptCallbacks.Add((s, c) => s.ContinueWith(script(c))); return this; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.CSharp/Providers/CSharpExpressionDescriptorProvider.cs b/src/modules/Elsa.Expressions.CSharp/Providers/CSharpExpressionDescriptorProvider.cs index 83e11d593..3dbaae3a9 100644 --- a/src/modules/Elsa.Expressions.CSharp/Providers/CSharpExpressionDescriptorProvider.cs +++ b/src/modules/Elsa.Expressions.CSharp/Providers/CSharpExpressionDescriptorProvider.cs @@ -1,12 +1,14 @@ using Elsa.Expressions.CSharp.Expressions; +using Elsa.Expressions.CSharp.Options; using Elsa.Expressions.Contracts; using Elsa.Expressions.Models; using Elsa.Extensions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Elsa.Expressions.CSharp.Providers; -internal class CSharpExpressionDescriptorProvider : IExpressionDescriptorProvider +internal class CSharpExpressionDescriptorProvider(IOptions options) : IExpressionDescriptorProvider { private const string TypeName = "CSharp"; @@ -16,8 +18,9 @@ internal class CSharpExpressionDescriptorProvider : IExpressionDescriptorProvide { Type = TypeName, DisplayName = "C#", + IsBrowsable = options.Value.AllowHostCodeExecution, Properties = new { MonacoLanguage = "csharp" }.ToDictionary(), HandlerFactory = ActivatorUtilities.GetServiceOrCreateInstance }; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.CSharp/Services/CSharpEvaluator.cs b/src/modules/Elsa.Expressions.CSharp/Services/CSharpEvaluator.cs index a6cfc3e86..30b63818f 100644 --- a/src/modules/Elsa.Expressions.CSharp/Services/CSharpEvaluator.cs +++ b/src/modules/Elsa.Expressions.CSharp/Services/CSharpEvaluator.cs @@ -34,6 +34,9 @@ public class CSharpEvaluator(INotificationSender notificationSender, IOptions, Script>? configureScript = default, CancellationToken cancellationToken = default) { + if (!_csharpOptions.AllowHostCodeExecution) + throw new InvalidOperationException("C# workflow expression execution is disabled. Set CSharpOptions.AllowHostCodeExecution to true only for trusted workflow authors; Roslyn scripting is not a sandbox."); + var scriptOptions = ScriptOptions.Default.WithOptimizationLevel(OptimizationLevel.Release); if (configureScriptOptions != null) @@ -85,4 +88,4 @@ public class CSharpEvaluator(INotificationSender notificationSender, IOptions() - .AddScoped(); + .AddScoped() + .AddSingleton(); // Handlers. services.AddNotificationHandlersFrom(); @@ -43,4 +45,3 @@ public class CSharpFeature : IShellFeature } } - diff --git a/src/modules/Elsa.Expressions.Python/Activities/RunPython/RunPython.cs b/src/modules/Elsa.Expressions.Python/Activities/RunPython/RunPython.cs index 0b3dfb76f..333dba3b0 100644 --- a/src/modules/Elsa.Expressions.Python/Activities/RunPython/RunPython.cs +++ b/src/modules/Elsa.Expressions.Python/Activities/RunPython/RunPython.cs @@ -13,7 +13,7 @@ namespace Elsa.Expressions.Python.Activities; /// /// Executes Python code. /// -[Activity("Elsa", "Scripting", "Executes Python code", DisplayName = "Run Python")] +[Activity(WorkflowScriptActivityTypeNames.Namespace, WorkflowScriptActivityTypeNames.RunPythonType, 1, "Executes Python code", "Scripting", DisplayName = "Run Python")] public class RunPython : CodeActivity { /// @@ -69,4 +69,4 @@ public class RunPython : CodeActivity // Complete the activity with the outcome. await context.CompleteActivityWithOutcomesAsync(outcomes); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.Python/ActivityDescriptorModifiers/PythonActivityDescriptorModifier.cs b/src/modules/Elsa.Expressions.Python/ActivityDescriptorModifiers/PythonActivityDescriptorModifier.cs index 80cd07e06..638a81eeb 100644 --- a/src/modules/Elsa.Expressions.Python/ActivityDescriptorModifiers/PythonActivityDescriptorModifier.cs +++ b/src/modules/Elsa.Expressions.Python/ActivityDescriptorModifiers/PythonActivityDescriptorModifier.cs @@ -1,6 +1,5 @@ using Elsa.Expressions.Python.Options; using Elsa.Workflows; -using Elsa.Workflows.Helpers; using Elsa.Workflows.Models; using Microsoft.Extensions.Options; @@ -8,11 +7,9 @@ namespace Elsa.Expressions.Python.ActivityDescriptorModifiers; internal class PythonActivityDescriptorModifier(IOptions options) : IActivityDescriptorModifier { - private static readonly string RunPythonActivityType = ActivityTypeNameHelper.GenerateTypeName(); - public void Modify(ActivityDescriptor descriptor) { - if (descriptor.TypeName != RunPythonActivityType) + if (descriptor.TypeName != WorkflowScriptActivityTypeNames.RunPython) return; descriptor.IsBrowsable = options.Value.AllowHostCodeExecution; diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/Scripting/ExpressionDescriptors/List/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/Scripting/ExpressionDescriptors/List/Endpoint.cs index ebcf6ee28..d0aefc7a4 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/Scripting/ExpressionDescriptors/List/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/Scripting/ExpressionDescriptors/List/Endpoint.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using Elsa.Abstractions; using Elsa.Expressions.Contracts; using Elsa.Expressions.Models; @@ -12,7 +13,11 @@ namespace Elsa.Workflows.Api.Endpoints.Scripting.ExpressionDescriptors.List; [UsedImplicitly] internal class List(IExpressionDescriptorRegistry expressionDescriptorRegistry) : ElsaEndpointWithoutRequest> { - private const string PythonExpressionType = "Python"; + private static readonly IReadOnlyDictionary PrivilegedExpressionPermissions = new Dictionary + { + ["CSharp"] = PermissionNames.ExecuteCSharpExpressions, + ["Python"] = PermissionNames.ExecutePythonExpressions + }.ToFrozenDictionary(StringComparer.Ordinal); /// public override void Configure() @@ -32,9 +37,10 @@ internal class List(IExpressionDescriptorRegistry expressionDescriptorRegistry) private bool CanListDescriptor(ExpressionDescriptor descriptor) { - return descriptor.Type != PythonExpressionType || - (descriptor.IsBrowsable && - User.Claims.Any(x => x.Type == "permissions" && (x.Value == PermissionNames.All || x.Value == PermissionNames.ExecutePythonExpressions))); + if (!PrivilegedExpressionPermissions.TryGetValue(descriptor.Type, out var permission)) + return true; + + return descriptor.IsBrowsable && User.Claims.Any(x => x.Type == PermissionNames.ClaimType && (x.Value == PermissionNames.All || x.Value == permission)); } private static IEnumerable Map(List descriptors) => descriptors.Select(Map); diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkDispatch/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkDispatch/Endpoint.cs index d01e9e8ad..067081371 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkDispatch/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkDispatch/Endpoint.cs @@ -14,7 +14,7 @@ internal class Endpoint( IWorkflowDefinitionService workflowDefinitionService, IWorkflowDispatcher workflowDispatcher, IIdentityGenerator identityGenerator, - PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) + WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService) : ElsaEndpoint { public override void Configure() @@ -35,10 +35,10 @@ internal class Endpoint( return; } - var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken); - if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken); + if (!scriptAuthorizationResult.Succeeded) { - await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); return; } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkPublish/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkPublish/Endpoint.cs index 45daa3d17..da23172a1 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkPublish/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkPublish/Endpoint.cs @@ -17,7 +17,7 @@ internal class BulkPublish( IWorkflowDefinitionPublisher workflowDefinitionPublisher, IAuthorizationService authorizationService, IWorkflowDefinitionService workflowDefinitionService, - PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) + WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService) : ElsaEndpoint { public override void Configure() @@ -77,10 +77,10 @@ internal class BulkPublish( foreach (var (_, definition) in publishableDefinitions) { var workflowGraph = await workflowDefinitionService.MaterializeWorkflowAsync(definition, cancellationToken); - var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken); - if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken); + if (!scriptAuthorizationResult.Succeeded) { - await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); return null!; } } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Dispatch/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Dispatch/Endpoint.cs index 379483fa6..ae3fc342f 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Dispatch/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Dispatch/Endpoint.cs @@ -13,7 +13,7 @@ internal class Endpoint( IWorkflowDefinitionService workflowDefinitionService, IWorkflowDispatcher workflowDispatcher, IIdentityGenerator identityGenerator, - PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) : ElsaEndpoint + WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService) : ElsaEndpoint { public override void Configure() { @@ -33,10 +33,10 @@ internal class Endpoint( return; } - var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken); - if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken); + if (!scriptAuthorizationResult.Succeeded) { - await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); return; } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs index 63356f5dc..52251479d 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs @@ -15,7 +15,7 @@ internal class GetEndpoint( IWorkflowRuntime workflowRuntime, IWorkflowStarter workflowStarter, IApiSerializer apiSerializer, - PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) + WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService) : ElsaEndpoint { /// @@ -35,7 +35,7 @@ internal class GetEndpoint( workflowRuntime, workflowStarter, apiSerializer, - pythonAuthorizationService, + scriptAuthorizationService, HttpContext, cancellationToken); } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs index c3eb39e79..ced1f3d8c 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs @@ -16,7 +16,7 @@ internal class PostEndpoint( IWorkflowRuntime workflowRuntime, IWorkflowStarter workflowStarter, IApiSerializer apiSerializer, - PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) + WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService) : ElsaEndpointWithoutRequest { /// @@ -73,7 +73,7 @@ internal class PostEndpoint( workflowRuntime, workflowStarter, apiSerializer, - pythonAuthorizationService, + scriptAuthorizationService, HttpContext, cancellationToken); } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs index 5499b25f6..fc6d1da8f 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs @@ -17,7 +17,7 @@ internal static class WorkflowExecutionHelper IWorkflowRuntime workflowRuntime, IWorkflowStarter workflowStarter, IApiSerializer apiSerializer, - PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService, + WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService, HttpContext httpContext, CancellationToken cancellationToken) { @@ -31,10 +31,10 @@ internal static class WorkflowExecutionHelper return; } - var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, httpContext.User, cancellationToken); - if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, httpContext.User, cancellationToken); + if (!scriptAuthorizationResult.Succeeded) { - await SendPythonAuthorizationFailureAsync(httpContext, pythonAuthorizationResult, cancellationToken); + await SendScriptAuthorizationFailureAsync(httpContext, scriptAuthorizationResult, cancellationToken); return; } @@ -97,15 +97,15 @@ internal static class WorkflowExecutionHelper await httpContext.Response.WriteAsync(faultedResponse, cancellationToken); } - private static async Task SendPythonAuthorizationFailureAsync(HttpContext httpContext, PythonWorkflowDefinitionAuthorizationResult result, CancellationToken cancellationToken) + private static async Task SendScriptAuthorizationFailureAsync(HttpContext httpContext, WorkflowDefinitionScriptAuthorizationResult result, CancellationToken cancellationToken) { - if (result == PythonWorkflowDefinitionAuthorizationResult.MissingPermission) + if (result.FailureReason == WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission) { await httpContext.Response.SendForbiddenAsync(cancellation: cancellationToken); return; } httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; - await httpContext.Response.WriteAsync(PythonWorkflowDefinitionAuthorizationService.HostDisabledMessage, cancellationToken); + await httpContext.Response.WriteAsync(result.Message ?? "Workflow script authorization failed.", cancellationToken); } } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Import/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Import/Endpoint.cs index 2d3eec31f..66557c0c2 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Import/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Import/Endpoint.cs @@ -17,7 +17,7 @@ internal class Import : ElsaEndpoint private readonly IWorkflowDefinitionImporter _workflowDefinitionImporter; private readonly IWorkflowDefinitionLinker _linker; private readonly IAuthorizationService _authorizationService; - private readonly PythonWorkflowDefinitionAuthorizationService _pythonAuthorizationService; + private readonly WorkflowDefinitionScriptAuthorizationService _scriptAuthorizationService; /// public Import( @@ -25,13 +25,13 @@ internal class Import : ElsaEndpoint IWorkflowDefinitionImporter workflowDefinitionImporter, IWorkflowDefinitionLinker linker, IAuthorizationService authorizationService, - PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) + WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService) { _workflowDefinitionStore = workflowDefinitionStore; _workflowDefinitionImporter = workflowDefinitionImporter; _linker = linker; _authorizationService = authorizationService; - _pythonAuthorizationService = pythonAuthorizationService; + _scriptAuthorizationService = scriptAuthorizationService; } /// @@ -48,10 +48,10 @@ internal class Import : ElsaEndpoint var definitionId = model.DefinitionId; var isNew = string.IsNullOrWhiteSpace(definitionId); - var pythonAuthorizationResult = await _pythonAuthorizationService.AuthorizeAsync(model, User, cancellationToken); - if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + var scriptAuthorizationResult = await _scriptAuthorizationService.AuthorizeAsync(model, User, cancellationToken); + if (!scriptAuthorizationResult.Succeeded) { - await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); return; } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/ImportFiles/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/ImportFiles/Endpoint.cs index 99bce9e04..1c0548061 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/ImportFiles/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/ImportFiles/Endpoint.cs @@ -18,7 +18,7 @@ internal class ImportFiles : ElsaEndpoint private readonly IWorkflowDefinitionImporter _workflowDefinitionImporter; private readonly IApiSerializer _apiSerializer; private readonly IAuthorizationService _authorizationService; - private readonly PythonWorkflowDefinitionAuthorizationService _pythonAuthorizationService; + private readonly WorkflowDefinitionScriptAuthorizationService _scriptAuthorizationService; /// public ImportFiles( @@ -26,13 +26,13 @@ internal class ImportFiles : ElsaEndpoint IWorkflowDefinitionImporter workflowDefinitionImporter, IApiSerializer apiSerializer, IAuthorizationService authorizationService, - PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) + WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService) { _workflowDefinitionStore = workflowDefinitionStore; _workflowDefinitionImporter = workflowDefinitionImporter; _apiSerializer = apiSerializer; _authorizationService = authorizationService; - _pythonAuthorizationService = pythonAuthorizationService; + _scriptAuthorizationService = scriptAuthorizationService; } /// @@ -80,10 +80,10 @@ internal class ImportFiles : ElsaEndpoint { foreach (var model in models) { - var pythonAuthorizationResult = await _pythonAuthorizationService.AuthorizeAsync(model, User, cancellationToken); - if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + var scriptAuthorizationResult = await _scriptAuthorizationService.AuthorizeAsync(model, User, cancellationToken); + if (!scriptAuthorizationResult.Succeeded) { - await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); return false; } } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Post/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Post/Endpoint.cs index 77851ed07..a0895762e 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Post/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Post/Endpoint.cs @@ -26,7 +26,7 @@ internal class Post( IDistributedLockProvider distributedLockProvider, IWorkflowDefinitionLinker linker, IAuthorizationService authorizationService, - PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) + WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService) : ElsaEndpoint { public override void Configure() @@ -66,10 +66,10 @@ internal class Post( return; } - var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(model, User, cancellationToken); - if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(model, User, cancellationToken); + if (!scriptAuthorizationResult.Succeeded) { - await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); return; } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Publish/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Publish/Endpoint.cs index f2300f8e9..299534025 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Publish/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Publish/Endpoint.cs @@ -17,7 +17,7 @@ internal class Publish( IWorkflowDefinitionLinker linker, IAuthorizationService authorizationService, IWorkflowDefinitionService workflowDefinitionService, - PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) + WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService) : ElsaEndpoint { public override void Configure() @@ -51,10 +51,10 @@ internal class Publish( } var workflowGraph = await workflowDefinitionService.MaterializeWorkflowAsync(definition, cancellationToken); - var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken); - if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken); + if (!scriptAuthorizationResult.Succeeded) { - await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); return; } diff --git a/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs b/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs index d885b2246..d7c6acb5d 100644 --- a/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs +++ b/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs @@ -37,7 +37,7 @@ public class WorkflowsApiFeature(IModule module) : FeatureBase(module) Module.AddFastEndpointsFromModule(); Services.AddScoped(); - Services.AddScoped(); + Services.AddScoped(); Services.AddScoped(); Services.Configure(options => { diff --git a/src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationService.cs b/src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationService.cs deleted file mode 100644 index 736d7da76..000000000 --- a/src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationService.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Security.Claims; -using Elsa.Expressions.Contracts; -using Elsa.Extensions; -using Elsa.Workflows.Activities; -using Elsa.Workflows.Management.Models; - -namespace Elsa.Workflows.Api.Security; - -internal class PythonWorkflowDefinitionAuthorizationService( - IActivityVisitor activityVisitor, - IExpressionDescriptorRegistry expressionDescriptorRegistry) -{ - public const string HostDisabledMessage = "Python.NET workflow expression execution is disabled by the host. Set PythonOptions.AllowHostCodeExecution to true only for trusted workflow authors; Python.NET is not a sandbox."; - private const string PythonExpressionType = "Python"; - // Keep in sync with ActivityTypeNameHelper.GenerateTypeName(). - private const string RunPythonActivityType = "Elsa.RunPython"; - private const string PermissionsClaimType = "permissions"; - - public async Task AuthorizeAsync(WorkflowDefinitionModel model, ClaimsPrincipal user, CancellationToken cancellationToken = default) - { - if (model.Root == null || !await UsesPythonAsync(model.Root, cancellationToken)) - return PythonWorkflowDefinitionAuthorizationResult.Allowed; - - return AuthorizePythonUsage(user); - } - - public async Task AuthorizeAsync(Workflow workflow, ClaimsPrincipal user, CancellationToken cancellationToken = default) - { - if (!await UsesPythonAsync(workflow, cancellationToken)) - return PythonWorkflowDefinitionAuthorizationResult.Allowed; - - return AuthorizePythonUsage(user); - } - - private PythonWorkflowDefinitionAuthorizationResult AuthorizePythonUsage(ClaimsPrincipal user) - { - // PythonOptions lives in the optional Python module. Workflows.Api observes the descriptor state projected by that module's provider. - if (expressionDescriptorRegistry.Find(PythonExpressionType)?.IsBrowsable != true) - return PythonWorkflowDefinitionAuthorizationResult.HostDisabled; - - return HasPermission(user, PermissionNames.ExecutePythonExpressions) - ? PythonWorkflowDefinitionAuthorizationResult.Allowed - : PythonWorkflowDefinitionAuthorizationResult.MissingPermission; - } - - private async Task UsesPythonAsync(IActivity root, CancellationToken cancellationToken) - { - var graph = await activityVisitor.VisitAsync(root, cancellationToken); - var nodes = new[] { graph }.Concat(graph.Descendants()); - - return nodes.Any(x => IsRunPythonActivity(x.Activity) || HasPythonExpression(x.Activity)); - } - - private static bool IsRunPythonActivity(IActivity activity) - { - return string.Equals(activity.Type, RunPythonActivityType, StringComparison.Ordinal); - } - - private static bool HasPythonExpression(IActivity activity) - { - return activity.GetInputs().Any(x => string.Equals(x.Expression?.Type, PythonExpressionType, StringComparison.Ordinal)); - } - - private static bool HasPermission(ClaimsPrincipal user, string permission) - { - return user.Claims.Any(x => - x.Type == PermissionsClaimType && - (string.Equals(x.Value, PermissionNames.All, StringComparison.Ordinal) || - string.Equals(x.Value, permission, StringComparison.Ordinal))); - } -} - -internal enum PythonWorkflowDefinitionAuthorizationResult -{ - Allowed, - HostDisabled, - MissingPermission -} diff --git a/src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationFailure.cs b/src/modules/Elsa.Workflows.Api/Security/WorkflowDefinitionScriptAuthorizationFailure.cs similarity index 59% rename from src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationFailure.cs rename to src/modules/Elsa.Workflows.Api/Security/WorkflowDefinitionScriptAuthorizationFailure.cs index fac411252..445ff6050 100644 --- a/src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationFailure.cs +++ b/src/modules/Elsa.Workflows.Api/Security/WorkflowDefinitionScriptAuthorizationFailure.cs @@ -1,21 +1,21 @@ namespace Elsa.Workflows.Api.Security; -internal static class PythonWorkflowDefinitionAuthorizationFailure +internal static class WorkflowDefinitionScriptAuthorizationFailure { public static async Task SendAsync( - PythonWorkflowDefinitionAuthorizationResult result, + WorkflowDefinitionScriptAuthorizationResult result, Func sendForbiddenAsync, Action addError, Func sendErrorsAsync, CancellationToken cancellationToken) { - if (result == PythonWorkflowDefinitionAuthorizationResult.MissingPermission) + if (result.FailureReason == WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission) { await sendForbiddenAsync(cancellationToken); return; } - addError(PythonWorkflowDefinitionAuthorizationService.HostDisabledMessage); + addError(result.Message ?? "Workflow script authorization failed."); await sendErrorsAsync(400, cancellationToken); } } diff --git a/src/modules/Elsa.Workflows.Api/Security/WorkflowDefinitionScriptAuthorizationService.cs b/src/modules/Elsa.Workflows.Api/Security/WorkflowDefinitionScriptAuthorizationService.cs new file mode 100644 index 000000000..fc30033da --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Security/WorkflowDefinitionScriptAuthorizationService.cs @@ -0,0 +1,106 @@ +using System.Security.Claims; +using Elsa.Expressions.Contracts; +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management.Models; + +namespace Elsa.Workflows.Api.Security; + +internal class WorkflowDefinitionScriptAuthorizationService( + IActivityVisitor activityVisitor, + IExpressionDescriptorRegistry expressionDescriptorRegistry) +{ + private static readonly ScriptPolicy[] ScriptPolicies = + [ + new( + "CSharp", + WorkflowScriptActivityTypeNames.RunCSharp, + PermissionNames.ExecuteCSharpExpressions, + "C# workflow expression execution is disabled by the host. Set CSharpOptions.AllowHostCodeExecution to true only for trusted workflow authors; Roslyn scripting is not a sandbox."), + new( + "Python", + WorkflowScriptActivityTypeNames.RunPython, + PermissionNames.ExecutePythonExpressions, + "Python.NET workflow expression execution is disabled by the host. Set PythonOptions.AllowHostCodeExecution to true only for trusted workflow authors; Python.NET is not a sandbox.") + ]; + + public async Task AuthorizeAsync(WorkflowDefinitionModel model, ClaimsPrincipal user, CancellationToken cancellationToken = default) + { + if (model.Root == null) + return WorkflowDefinitionScriptAuthorizationResult.Allowed(); + + return await AuthorizeAsync(model.Root, user, cancellationToken); + } + + public async Task AuthorizeAsync(IActivity root, ClaimsPrincipal user, CancellationToken cancellationToken = default) + { + var scriptUsages = await GetUsedScriptPoliciesAsync(root, cancellationToken); + + var failure = scriptUsages + .Select(policy => AuthorizeScriptUsage(policy, user)) + .FirstOrDefault(result => result is { Succeeded: false }); + + if (failure.FailureReason.HasValue) + return failure; + + return WorkflowDefinitionScriptAuthorizationResult.Allowed(); + } + + public async Task AuthorizeAsync(Workflow workflow, ClaimsPrincipal user, CancellationToken cancellationToken = default) + { + return await AuthorizeAsync((IActivity)workflow, user, cancellationToken); + } + + private WorkflowDefinitionScriptAuthorizationResult AuthorizeScriptUsage(ScriptPolicy policy, ClaimsPrincipal user) + { + // Language-specific options live in optional modules. Workflows.Api observes the descriptor state projected by those module providers. + if (expressionDescriptorRegistry.Find(policy.ExpressionType)?.IsBrowsable != true) + return WorkflowDefinitionScriptAuthorizationResult.HostDisabled(policy.HostDisabledMessage); + + return HasPermission(user, policy.Permission) + ? WorkflowDefinitionScriptAuthorizationResult.Allowed() + : WorkflowDefinitionScriptAuthorizationResult.MissingPermission(); + } + + private async Task> GetUsedScriptPoliciesAsync(IActivity root, CancellationToken cancellationToken) + { + var graph = await activityVisitor.VisitAsync(root, cancellationToken); + var nodes = new[] { graph }.Concat(graph.Descendants()).ToList(); + var policies = ScriptPolicies + .Where(policy => nodes.Any(x => IsRunActivity(x.Activity, policy) || HasExpression(x.Activity, policy))) + .ToList(); + + return policies; + } + + private static bool IsRunActivity(IActivity activity, ScriptPolicy policy) => + string.Equals(activity.Type, policy.RunActivityType, StringComparison.Ordinal); + + private static bool HasExpression(IActivity activity, ScriptPolicy policy) => + activity.GetInputs().Any(x => string.Equals(x.Expression?.Type, policy.ExpressionType, StringComparison.Ordinal)); + + private static bool HasPermission(ClaimsPrincipal user, string permission) + { + return user.Claims.Any(x => + x.Type == PermissionNames.ClaimType && + (string.Equals(x.Value, PermissionNames.All, StringComparison.Ordinal) || + string.Equals(x.Value, permission, StringComparison.Ordinal))); + } + + private sealed record ScriptPolicy(string ExpressionType, string RunActivityType, string Permission, string HostDisabledMessage); +} + +internal readonly record struct WorkflowDefinitionScriptAuthorizationResult(bool Succeeded, WorkflowDefinitionScriptAuthorizationFailureReason? FailureReason, string? Message) +{ + public static WorkflowDefinitionScriptAuthorizationResult Allowed() => new(true, null, null); + + public static WorkflowDefinitionScriptAuthorizationResult HostDisabled(string message) => new(false, WorkflowDefinitionScriptAuthorizationFailureReason.HostDisabled, message); + + public static WorkflowDefinitionScriptAuthorizationResult MissingPermission() => new(false, WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission, null); +} + +internal enum WorkflowDefinitionScriptAuthorizationFailureReason +{ + HostDisabled, + MissingPermission +} diff --git a/src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs b/src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs index 689bcb292..a0daf3089 100644 --- a/src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs +++ b/src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs @@ -37,7 +37,7 @@ public class WorkflowsApiFeature : IFastEndpointsShellFeature { services.AddSerializationOptionsConfigurator(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.Configure(options => { diff --git a/src/modules/Elsa.Workflows.Core/Constants/WorkflowScriptActivityTypeNames.cs b/src/modules/Elsa.Workflows.Core/Constants/WorkflowScriptActivityTypeNames.cs new file mode 100644 index 000000000..6b3cb0037 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Constants/WorkflowScriptActivityTypeNames.cs @@ -0,0 +1,32 @@ +namespace Elsa.Workflows; + +/// +/// Activity type names for built-in workflow script activities. +/// +public static class WorkflowScriptActivityTypeNames +{ + /// + /// The Elsa activity namespace for built-in script activities. + /// + public const string Namespace = "Elsa"; + + /// + /// The unqualified C# script activity type. + /// + public const string RunCSharpType = "RunCSharp"; + + /// + /// The fully qualified C# script activity type name. + /// + public const string RunCSharp = $"{Namespace}.{RunCSharpType}"; + + /// + /// The unqualified Python script activity type. + /// + public const string RunPythonType = "RunPython"; + + /// + /// The fully qualified Python script activity type name. + /// + public const string RunPython = $"{Namespace}.{RunPythonType}"; +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Security/PythonWorkflowDefinitionAuthorizationServiceTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Security/PythonWorkflowDefinitionAuthorizationServiceTests.cs deleted file mode 100644 index 1b6334cd5..000000000 --- a/test/integration/Elsa.Workflows.IntegrationTests/Security/PythonWorkflowDefinitionAuthorizationServiceTests.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Security.Claims; -using Elsa.Expressions.Contracts; -using Elsa.Expressions.Models; -using Elsa.Workflows.Activities; -using Elsa.Workflows.Api.Security; -using Elsa.Workflows.Management.Models; -using Elsa.Workflows.Management.Services; -using Elsa.Workflows.Models; -using Elsa.Workflows.PortResolvers; -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; - -namespace Elsa.Workflows.IntegrationTests.Security; - -public class PythonWorkflowDefinitionAuthorizationServiceTests -{ - private static readonly ClaimsPrincipal UserWithPythonPermission = CreateUser(PermissionNames.ExecutePythonExpressions); - private static readonly ClaimsPrincipal UserWithoutPythonPermission = CreateUser("write:workflow-definitions"); - - [Fact] - public async Task AuthorizeAsync_BlocksPythonExpression_WhenHostHasNotOptedIn() - { - var service = CreateService(hostAllowsPython: false); - var model = CreateModelWithPythonExpression(); - - var result = await service.AuthorizeAsync(model, UserWithPythonPermission); - - Assert.Equal(PythonWorkflowDefinitionAuthorizationResult.HostDisabled, result); - } - - [Fact] - public async Task AuthorizeAsync_BlocksPythonExpression_WhenUserLacksPermission() - { - var service = CreateService(hostAllowsPython: true); - var model = CreateModelWithPythonExpression(); - - var result = await service.AuthorizeAsync(model, UserWithoutPythonPermission); - - Assert.Equal(PythonWorkflowDefinitionAuthorizationResult.MissingPermission, result); - } - - [Fact] - public async Task AuthorizeAsync_AllowsPythonExpression_WhenHostAndUserAllowIt() - { - var service = CreateService(hostAllowsPython: true); - var model = CreateModelWithPythonExpression(); - - var result = await service.AuthorizeAsync(model, UserWithPythonPermission); - - Assert.Equal(PythonWorkflowDefinitionAuthorizationResult.Allowed, result); - } - - [Fact] - public async Task AuthorizeAsync_TreatsRunPythonActivityAsPythonUsage() - { - var service = CreateService(hostAllowsPython: true); - var model = new WorkflowDefinitionModel - { - Root = new WriteLine("hello") - { - Type = "Elsa.RunPython" - } - }; - - var result = await service.AuthorizeAsync(model, UserWithoutPythonPermission); - - Assert.Equal(PythonWorkflowDefinitionAuthorizationResult.MissingPermission, result); - } - - private static WorkflowDefinitionModel CreateModelWithPythonExpression() - { - return new() - { - Root = new WriteLine("placeholder") - { - Text = new Input(new Expression("Python", "'hello'")) - } - }; - } - - private static PythonWorkflowDefinitionAuthorizationService CreateService(bool hostAllowsPython) - { - var expressionDescriptor = new ExpressionDescriptor - { - Type = "Python", - DisplayName = "Python", - IsBrowsable = hostAllowsPython, - HandlerFactory = _ => Substitute.For() - }; - - var provider = Substitute.For(); - provider.GetDescriptors().Returns([expressionDescriptor]); - - var registry = new ExpressionDescriptorRegistry([provider]); - var visitor = new ActivityVisitor( - [ - new SwitchActivityResolver(), - new PropertyBasedActivityResolver() - ], - new ServiceCollection().BuildServiceProvider()); - - return new(visitor, registry); - } - - private static ClaimsPrincipal CreateUser(params string[] permissions) - { - var identity = new ClaimsIdentity(permissions.Select(x => new Claim("permissions", x)), "Test"); - return new(identity); - } -} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Security/WorkflowDefinitionScriptAuthorizationServiceTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Security/WorkflowDefinitionScriptAuthorizationServiceTests.cs new file mode 100644 index 000000000..b748a0d47 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Security/WorkflowDefinitionScriptAuthorizationServiceTests.cs @@ -0,0 +1,198 @@ +using System.Security.Claims; +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Api.Security; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.Management.Services; +using Elsa.Workflows.Models; +using Elsa.Workflows.PortResolvers; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Elsa.Workflows.IntegrationTests.Security; + +public class WorkflowDefinitionScriptAuthorizationServiceTests +{ + private static readonly ClaimsPrincipal UserWithCSharpPermission = CreateUser(PermissionNames.ExecuteCSharpExpressions); + private static readonly ClaimsPrincipal UserWithPythonPermission = CreateUser(PermissionNames.ExecutePythonExpressions); + private static readonly ClaimsPrincipal UserWithoutScriptPermission = CreateUser("write:workflow-definitions"); + + [Fact] + public async Task AuthorizeAsync_BlocksCSharpExpression_WhenHostHasNotOptedIn() + { + var service = CreateService(hostAllowsCSharp: false, hostAllowsPython: true); + var model = CreateModelWithCSharpExpression(); + + var result = await service.AuthorizeAsync(model, UserWithCSharpPermission); + + Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.HostDisabled, result.FailureReason); + Assert.Contains("CSharpOptions.AllowHostCodeExecution", result.Message); + } + + [Fact] + public async Task AuthorizeAsync_BlocksCSharpExpression_WhenUserLacksPermission() + { + var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true); + var model = CreateModelWithCSharpExpression(); + + var result = await service.AuthorizeAsync(model, UserWithoutScriptPermission); + + Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission, result.FailureReason); + } + + [Fact] + public async Task AuthorizeAsync_AllowsCSharpExpression_WhenHostAndUserAllowIt() + { + var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true); + var model = CreateModelWithCSharpExpression(); + + var result = await service.AuthorizeAsync(model, UserWithCSharpPermission); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task AuthorizeAsync_AllowsWorkflowWithoutScriptUsage() + { + var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true); + var model = new WorkflowDefinitionModel + { + Root = new WriteLine("hello") + }; + + var result = await service.AuthorizeAsync(model, UserWithoutScriptPermission); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task AuthorizeAsync_TreatsRunCSharpActivityAsCSharpUsage() + { + var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true); + var model = new WorkflowDefinitionModel + { + Root = new WriteLine("hello") + { + Type = WorkflowScriptActivityTypeNames.RunCSharp + } + }; + + var result = await service.AuthorizeAsync(model, UserWithoutScriptPermission); + + Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission, result.FailureReason); + } + + [Fact] + public async Task AuthorizeAsync_BlocksPythonExpression_WhenHostHasNotOptedIn() + { + var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: false); + var model = CreateModelWithPythonExpression(); + + var result = await service.AuthorizeAsync(model, UserWithPythonPermission); + + Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.HostDisabled, result.FailureReason); + Assert.Contains("PythonOptions.AllowHostCodeExecution", result.Message); + } + + [Fact] + public async Task AuthorizeAsync_BlocksPythonExpression_WhenUserLacksPermission() + { + var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true); + var model = CreateModelWithPythonExpression(); + + var result = await service.AuthorizeAsync(model, UserWithoutScriptPermission); + + Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission, result.FailureReason); + } + + [Fact] + public async Task AuthorizeAsync_AllowsPythonExpression_WhenHostAndUserAllowIt() + { + var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true); + var model = CreateModelWithPythonExpression(); + + var result = await service.AuthorizeAsync(model, UserWithPythonPermission); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task AuthorizeAsync_TreatsRunPythonActivityAsPythonUsage() + { + var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true); + var model = new WorkflowDefinitionModel + { + Root = new WriteLine("hello") + { + Type = WorkflowScriptActivityTypeNames.RunPython + } + }; + + var result = await service.AuthorizeAsync(model, UserWithoutScriptPermission); + + Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission, result.FailureReason); + } + + private static WorkflowDefinitionModel CreateModelWithCSharpExpression() + { + return new() + { + Root = new WriteLine("placeholder") + { + Text = new Input(new Expression("CSharp", "\"hello\"")) + } + }; + } + + private static WorkflowDefinitionModel CreateModelWithPythonExpression() + { + return new() + { + Root = new WriteLine("placeholder") + { + Text = new Input(new Expression("Python", "'hello'")) + } + }; + } + + private static WorkflowDefinitionScriptAuthorizationService CreateService(bool hostAllowsCSharp, bool hostAllowsPython) + { + var expressionDescriptors = new[] + { + new ExpressionDescriptor + { + Type = "CSharp", + DisplayName = "C#", + IsBrowsable = hostAllowsCSharp, + HandlerFactory = _ => Substitute.For() + }, + new ExpressionDescriptor + { + Type = "Python", + DisplayName = "Python", + IsBrowsable = hostAllowsPython, + HandlerFactory = _ => Substitute.For() + } + }; + + var provider = Substitute.For(); + provider.GetDescriptors().Returns(expressionDescriptors); + + var registry = new ExpressionDescriptorRegistry([provider]); + var visitor = new ActivityVisitor( + [ + new SwitchActivityResolver(), + new PropertyBasedActivityResolver() + ], + new ServiceCollection().BuildServiceProvider()); + + return new(visitor, registry); + } + + private static ClaimsPrincipal CreateUser(params string[] permissions) + { + var identity = new ClaimsIdentity(permissions.Select(x => new Claim("permissions", x)), "Test"); + return new(identity); + } +} diff --git a/test/unit/Elsa.Expressions.UnitTests/CSharp/CSharpHostCodeExecutionTests.cs b/test/unit/Elsa.Expressions.UnitTests/CSharp/CSharpHostCodeExecutionTests.cs new file mode 100644 index 000000000..be84e923e --- /dev/null +++ b/test/unit/Elsa.Expressions.UnitTests/CSharp/CSharpHostCodeExecutionTests.cs @@ -0,0 +1,54 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.CSharp.Contracts; +using Elsa.Expressions.CSharp.Options; +using Elsa.Expressions.CSharp.Services; +using Elsa.Expressions.Models; +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Elsa.Expressions.UnitTests.CSharp; + +public class CSharpHostCodeExecutionTests +{ + [Fact] + public async Task Evaluator_BlocksExecution_WhenHostHasNotOptedIn() + { + using var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var evaluator = new CSharpEvaluator( + Substitute.For(), + Microsoft.Extensions.Options.Options.Create(new CSharpOptions()), + memoryCache); + var context = await new ActivityTestFixture(new WriteLine("test")).BuildAsync(); + + var exception = await Assert.ThrowsAsync(() => + evaluator.EvaluateAsync("\"hello\"", typeof(string), context.ExpressionExecutionContext, new ExpressionEvaluatorOptions())); + + Assert.Contains(nameof(CSharpOptions.AllowHostCodeExecution), exception.Message); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Descriptor_Browsability_FollowsHostOptIn(bool allowHostCodeExecution) + { + var services = new ServiceCollection(); + services.AddOptions(); + services.AddMemoryCache(); + new Elsa.Expressions.CSharp.ShellFeatures.CSharpFeature + { + CSharpOptions = options => options.AllowHostCodeExecution = allowHostCodeExecution + }.ConfigureServices(services); + services.AddSingleton(); + + var serviceProvider = services.BuildServiceProvider(); + var registry = serviceProvider.GetRequiredService(); + + var descriptor = registry.Find("CSharp"); + + Assert.NotNull(descriptor); + Assert.Equal(allowHostCodeExecution, descriptor.IsBrowsable); + } +}