From 0856d790931e4fd16a1503b6cb6d8cb7a20fad6a Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 20 May 2026 14:19:47 +0200 Subject: [PATCH] Harden Python expression execution (#7507) * Secure Python workflow expressions * Preflight Python authorization for bulk publish * Preflight Python auth for import files * Name Python expression descriptor type --- doc/wiki/expressions-and-scripting.md | 6 +- src/apps/Elsa.Server.Web/appsettings.json | 1 + src/common/Elsa.Api.Common/PermissionNames.cs | 5 + .../PythonActivityDescriptorModifier.cs | 20 ++++ .../Features/PythonFeature.cs | 4 +- .../PythonGlobalInterpreterManager.cs | 5 +- .../Options/PythonOptions.cs | 8 +- .../PythonExpressionSyntaxProvider.cs | 7 +- .../Services/PythonNetPythonEvaluator.cs | 18 ++- .../ShellFeatures/PythonFeature.cs | 5 +- .../ExpressionDescriptors/List/Endpoint.cs | 13 ++- .../BulkDispatch/Endpoint.cs | 17 ++- .../BulkPublish/Endpoint.cs | 29 ++++- .../WorkflowDefinitions/Dispatch/Endpoint.cs | 17 ++- .../Execute/GetEndpoint.cs | 7 +- .../Execute/PostEndpoint.cs | 7 +- .../Execute/WorkflowExecutionHelper.cs | 24 +++- .../WorkflowDefinitions/Import/Endpoint.cs | 17 ++- .../ImportFiles/Endpoint.cs | 62 ++++------ .../WorkflowDefinitionImportFileReader.cs | 50 ++++++++ .../WorkflowDefinitions/Post/Endpoint.cs | 14 ++- .../WorkflowDefinitions/Publish/Endpoint.cs | 19 ++- .../Features/WorkflowsApiFeature.cs | 4 +- ...nWorkflowDefinitionAuthorizationFailure.cs | 21 ++++ ...nWorkflowDefinitionAuthorizationService.cs | 78 +++++++++++++ .../ShellFeatures/WorkflowsApiFeature.cs | 2 + .../Elsa.Workflows.IntegrationTests.csproj | 1 + ...flowDefinitionAuthorizationServiceTests.cs | 110 ++++++++++++++++++ ...WorkflowDefinitionImportFileReaderTests.cs | 91 +++++++++++++++ .../Elsa.Expressions.UnitTests.csproj | 1 + .../Python/PythonHostCodeExecutionTests.cs | 51 ++++++++ 31 files changed, 645 insertions(+), 69 deletions(-) create mode 100644 src/modules/Elsa.Expressions.Python/ActivityDescriptorModifiers/PythonActivityDescriptorModifier.cs create mode 100644 src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/ImportFiles/WorkflowDefinitionImportFileReader.cs create mode 100644 src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationFailure.cs create mode 100644 src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationService.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Security/PythonWorkflowDefinitionAuthorizationServiceTests.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Security/WorkflowDefinitionImportFileReaderTests.cs create mode 100644 test/unit/Elsa.Expressions.UnitTests/Python/PythonHostCodeExecutionTests.cs diff --git a/doc/wiki/expressions-and-scripting.md b/doc/wiki/expressions-and-scripting.md index 014c80b53..912d31eb2 100644 --- a/doc/wiki/expressions-and-scripting.md +++ b/doc/wiki/expressions-and-scripting.md @@ -62,10 +62,14 @@ elsa.UseCSharp(options => ## Python -[PythonFeature](../../src/modules/Elsa.Expressions.Python/Features/PythonFeature.cs) registers pythonnet-based evaluation and configures `PythonGlobalInterpreterManager` as a hosted service. Hosts must configure the Python DLL path or set `PYTHONNET_PYDLL`. +[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. + +Hosts must explicitly set `PythonOptions.AllowHostCodeExecution` to `true` before Python expressions or `RunPython` can be authored or executed. API callers that author, publish, dispatch, or directly execute workflows containing Python must have the `exec:python-expressions` permission. Hosts must also configure the Python DLL path or set `PYTHONNET_PYDLL`. The reference server binds `Scripting:Python` configuration in [Program.cs](../../src/apps/Elsa.Server.Web/Program.cs). +Python.NET hardening is part of the broader script execution security tracking in [#7096](https://github.com/elsa-workflows/elsa-core/issues/7096). + ## Liquid [LiquidFeature](../../src/modules/Elsa.Expressions.Liquid/Features/LiquidFeature.cs) registers Fluid options, parser services, expression descriptors, and built-in filters: diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index e3cf3df52..0010606f2 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -133,6 +133,7 @@ }, "Scripting": { "Python": { + "AllowHostCodeExecution": true, "PythonDllPath": "", "Scripts": [ "def greet(name): return f'Hello {name}!'", diff --git a/src/common/Elsa.Api.Common/PermissionNames.cs b/src/common/Elsa.Api.Common/PermissionNames.cs index e46c2e50f..cf75a1662 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 Python.NET workflow expressions. + /// + public const string ExecutePythonExpressions = "exec:python-expressions"; + /// /// Permission required to pause, resume, force-drain, or query the workflow runtime's graceful-shutdown status. /// diff --git a/src/modules/Elsa.Expressions.Python/ActivityDescriptorModifiers/PythonActivityDescriptorModifier.cs b/src/modules/Elsa.Expressions.Python/ActivityDescriptorModifiers/PythonActivityDescriptorModifier.cs new file mode 100644 index 000000000..80cd07e06 --- /dev/null +++ b/src/modules/Elsa.Expressions.Python/ActivityDescriptorModifiers/PythonActivityDescriptorModifier.cs @@ -0,0 +1,20 @@ +using Elsa.Expressions.Python.Options; +using Elsa.Workflows; +using Elsa.Workflows.Helpers; +using Elsa.Workflows.Models; +using Microsoft.Extensions.Options; + +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) + return; + + descriptor.IsBrowsable = options.Value.AllowHostCodeExecution; + } +} diff --git a/src/modules/Elsa.Expressions.Python/Features/PythonFeature.cs b/src/modules/Elsa.Expressions.Python/Features/PythonFeature.cs index bf60b606a..23d36581a 100644 --- a/src/modules/Elsa.Expressions.Python/Features/PythonFeature.cs +++ b/src/modules/Elsa.Expressions.Python/Features/PythonFeature.cs @@ -1,4 +1,5 @@ using Elsa.Common.Features; +using Elsa.Expressions.Python.ActivityDescriptorModifiers; using Elsa.Expressions.Features; using Elsa.Extensions; using Elsa.Features.Abstractions; @@ -47,6 +48,7 @@ public class PythonFeature : FeatureBase Services .AddScoped() .AddExpressionDescriptorProvider() + .AddSingleton() ; // Handlers. @@ -58,4 +60,4 @@ public class PythonFeature : FeatureBase // UI property handlers. Services.AddScoped(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.Python/HostedServices/PythonGlobalInterpreterManager.cs b/src/modules/Elsa.Expressions.Python/HostedServices/PythonGlobalInterpreterManager.cs index 909ebd65c..39f799829 100644 --- a/src/modules/Elsa.Expressions.Python/HostedServices/PythonGlobalInterpreterManager.cs +++ b/src/modules/Elsa.Expressions.Python/HostedServices/PythonGlobalInterpreterManager.cs @@ -29,6 +29,9 @@ public class PythonGlobalInterpreterManager : IHostedService /// public Task StartAsync(CancellationToken cancellationToken) { + if (!_options.Value.AllowHostCodeExecution) + return Task.CompletedTask; + if (_initialized) return Task.CompletedTask; @@ -51,4 +54,4 @@ public class PythonGlobalInterpreterManager : IHostedService /// public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.Python/Options/PythonOptions.cs b/src/modules/Elsa.Expressions.Python/Options/PythonOptions.cs index 3f128b5a6..31efaeeb8 100644 --- a/src/modules/Elsa.Expressions.Python/Options/PythonOptions.cs +++ b/src/modules/Elsa.Expressions.Python/Options/PythonOptions.cs @@ -10,6 +10,12 @@ namespace Elsa.Expressions.Python.Options; [PublicAPI] public class PythonOptions { + /// + /// Gets or sets whether workflow-authored Python.NET host-code execution is allowed. + /// Python.NET is not a sandbox and can access host process capabilities. + /// + public bool AllowHostCodeExecution { get; set; } + /// /// Gets or sets the path to the Python DLL. Alternatively, you can set the PYTHON_DLL environment variable, which is required if you leave this property empty. /// @@ -52,4 +58,4 @@ public class PythonOptions { Scopes.Add(configure); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.Python/Providers/PythonExpressionSyntaxProvider.cs b/src/modules/Elsa.Expressions.Python/Providers/PythonExpressionSyntaxProvider.cs index b602ebaf0..16c98446d 100644 --- a/src/modules/Elsa.Expressions.Python/Providers/PythonExpressionSyntaxProvider.cs +++ b/src/modules/Elsa.Expressions.Python/Providers/PythonExpressionSyntaxProvider.cs @@ -2,11 +2,13 @@ using Elsa.Expressions.Contracts; using Elsa.Expressions.Models; using Elsa.Extensions; using Elsa.Expressions.Python.Expressions; +using Elsa.Expressions.Python.Options; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Elsa.Expressions.Python.Providers; -internal class PythonExpressionDescriptorProvider : IExpressionDescriptorProvider +internal class PythonExpressionDescriptorProvider(IOptions options) : IExpressionDescriptorProvider { private const string TypeName = "Python"; @@ -16,8 +18,9 @@ internal class PythonExpressionDescriptorProvider : IExpressionDescriptorProvide { Type = TypeName, DisplayName = "Python", + IsBrowsable = options.Value.AllowHostCodeExecution, Properties = new { MonacoLanguage = "python" }.ToDictionary(), HandlerFactory = ActivatorUtilities.GetServiceOrCreateInstance }; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.Python/Services/PythonNetPythonEvaluator.cs b/src/modules/Elsa.Expressions.Python/Services/PythonNetPythonEvaluator.cs index f7ebe3b55..79145fea9 100644 --- a/src/modules/Elsa.Expressions.Python/Services/PythonNetPythonEvaluator.cs +++ b/src/modules/Elsa.Expressions.Python/Services/PythonNetPythonEvaluator.cs @@ -5,6 +5,8 @@ using Elsa.Mediator.Contracts; using Elsa.Expressions.Python.Contracts; using Elsa.Expressions.Python.Models; using Elsa.Expressions.Python.Notifications; +using Elsa.Expressions.Python.Options; +using Microsoft.Extensions.Options; using Python.Runtime; namespace Elsa.Expressions.Python.Services; @@ -16,18 +18,30 @@ public class PythonNetPythonEvaluator : IPythonEvaluator { private const string ReturnVarName = "elsa_python_result_variable_name"; private readonly INotificationSender _notificationSender; + private readonly IOptions _options; /// /// Initializes a new instance of the class. /// - public PythonNetPythonEvaluator(INotificationSender notificationSender) + public PythonNetPythonEvaluator(INotificationSender notificationSender) : this(notificationSender, Microsoft.Extensions.Options.Options.Create(new PythonOptions())) + { + } + + /// + /// Initializes a new instance of the class. + /// + public PythonNetPythonEvaluator(INotificationSender notificationSender, IOptions options) { _notificationSender = notificationSender; + _options = options; } /// public async Task EvaluateAsync(string expression, Type returnType, ExpressionExecutionContext context, CancellationToken cancellationToken = default) { + if (!_options.Value.AllowHostCodeExecution) + throw new InvalidOperationException("Python.NET workflow expression execution is disabled. Set PythonOptions.AllowHostCodeExecution to true only for trusted workflow authors; Python.NET is not a sandbox."); + using var gil = Py.GIL(); using var scope = Py.CreateScope(); var notification = new EvaluatingPython(scope, context); @@ -70,4 +84,4 @@ public class PythonNetPythonEvaluator : IPythonEvaluator wrappedScript.AppendLine($"{ReturnVarName} = execute_script()"); return wrappedScript.ToString(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.Python/ShellFeatures/PythonFeature.cs b/src/modules/Elsa.Expressions.Python/ShellFeatures/PythonFeature.cs index 7b5b38628..87bb3a706 100644 --- a/src/modules/Elsa.Expressions.Python/ShellFeatures/PythonFeature.cs +++ b/src/modules/Elsa.Expressions.Python/ShellFeatures/PythonFeature.cs @@ -1,4 +1,5 @@ using CShells.Features; +using Elsa.Expressions.Python.ActivityDescriptorModifiers; using Elsa.Expressions.Python.Activities; using Elsa.Expressions.Python.Contracts; using Elsa.Expressions.Python.HostedServices; @@ -34,7 +35,8 @@ public class PythonFeature : IShellFeature // Python services. services .AddScoped() - .AddExpressionDescriptorProvider(); + .AddExpressionDescriptorProvider() + .AddSingleton(); // Handlers. services.AddNotificationHandlersFrom(); @@ -47,4 +49,3 @@ public class PythonFeature : IShellFeature } } - 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 0368fd5a7..ebcf6ee28 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 @@ -12,6 +12,8 @@ namespace Elsa.Workflows.Api.Endpoints.Scripting.ExpressionDescriptors.List; [UsedImplicitly] internal class List(IExpressionDescriptorRegistry expressionDescriptorRegistry) : ElsaEndpointWithoutRequest> { + private const string PythonExpressionType = "Python"; + /// public override void Configure() { @@ -22,12 +24,19 @@ internal class List(IExpressionDescriptorRegistry expressionDescriptorRegistry) /// public override Task HandleAsync(CancellationToken cancellationToken) { - var descriptors = expressionDescriptorRegistry.ListAll().ToList(); + var descriptors = expressionDescriptorRegistry.ListAll().Where(CanListDescriptor).ToList(); var models = Map(descriptors).ToList(); var response = new ListResponse(models); return Send.OkAsync(response, cancellationToken); } + 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))); + } + private static IEnumerable Map(List descriptors) => descriptors.Select(Map); private static ExpressionDescriptorModel Map(ExpressionDescriptor descriptor) @@ -42,4 +51,4 @@ internal class List(IExpressionDescriptorRegistry expressionDescriptorRegistry) } } -internal record ExpressionDescriptorModel(string Type, string DisplayName, bool IsSerializable, bool IsBrowsable, IDictionary Properties); \ No newline at end of file +internal record ExpressionDescriptorModel(string Type, string DisplayName, bool IsSerializable, bool IsBrowsable, IDictionary Properties); 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 be7740423..d01e9e8ad 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkDispatch/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkDispatch/Endpoint.cs @@ -1,5 +1,6 @@ using Elsa.Abstractions; using Elsa.Common.Models; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime; using Elsa.Workflows.Runtime.Contracts; @@ -9,7 +10,11 @@ using JetBrains.Annotations; namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.BulkDispatch; [PublicAPI] -internal class Endpoint(IWorkflowDefinitionService workflowDefinitionService, IWorkflowDispatcher workflowDispatcher, IIdentityGenerator identityGenerator) +internal class Endpoint( + IWorkflowDefinitionService workflowDefinitionService, + IWorkflowDispatcher workflowDispatcher, + IIdentityGenerator identityGenerator, + PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) : ElsaEndpoint { public override void Configure() @@ -30,6 +35,13 @@ internal class Endpoint(IWorkflowDefinitionService workflowDefinitionService, IW return; } + var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken); + if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + { + await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + return; + } + var instanceIds = new List(); for (var i = 0; i < request.Count; i++) @@ -50,4 +62,5 @@ internal class Endpoint(IWorkflowDefinitionService workflowDefinitionService, IW await Send.OkAsync(new Response(instanceIds), cancellationToken); } -} \ No newline at end of file + +} 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 753cb237a..45daa3d17 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkPublish/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkPublish/Endpoint.cs @@ -2,7 +2,9 @@ using Elsa.Abstractions; using Elsa.Common.Models; using Elsa.Workflows.Api.Constants; using Elsa.Workflows.Api.Requirements; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Filters; using JetBrains.Annotations; using Microsoft.AspNetCore.Authorization; @@ -10,7 +12,12 @@ using Microsoft.AspNetCore.Authorization; namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.BulkPublish; [PublicAPI] -internal class BulkPublish(IWorkflowDefinitionStore store, IWorkflowDefinitionPublisher workflowDefinitionPublisher, IAuthorizationService authorizationService) +internal class BulkPublish( + IWorkflowDefinitionStore store, + IWorkflowDefinitionPublisher workflowDefinitionPublisher, + IAuthorizationService authorizationService, + IWorkflowDefinitionService workflowDefinitionService, + PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) : ElsaEndpoint { public override void Configure() @@ -34,6 +41,7 @@ internal class BulkPublish(IWorkflowDefinitionStore store, IWorkflowDefinitionPu var alreadyPublished = new List(); var skipped = new List(); var updatedConsumers = new List(); + var publishableDefinitions = new List<(string DefinitionId, WorkflowDefinition Definition)>(); var definitions = (await store.FindManyAsync(new WorkflowDefinitionFilter { @@ -63,6 +71,22 @@ internal class BulkPublish(IWorkflowDefinitionStore store, IWorkflowDefinitionPu continue; } + publishableDefinitions.Add((definitionId, definition)); + } + + 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) + { + await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + return null!; + } + } + + foreach (var (definitionId, definition) in publishableDefinitions) + { var result = await workflowDefinitionPublisher.PublishAsync(definition, cancellationToken); published.Add(definitionId); @@ -72,4 +96,5 @@ internal class BulkPublish(IWorkflowDefinitionStore store, IWorkflowDefinitionPu return new(published, alreadyPublished, notFound, skipped, updatedConsumers); } -} \ No newline at end of file + +} 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 bce302489..379483fa6 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Dispatch/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Dispatch/Endpoint.cs @@ -1,5 +1,6 @@ using Elsa.Abstractions; using Elsa.Common.Models; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime; using Elsa.Workflows.Runtime.Requests; @@ -8,7 +9,11 @@ using JetBrains.Annotations; namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Dispatch; [UsedImplicitly] -internal class Endpoint(IWorkflowDefinitionService workflowDefinitionService, IWorkflowDispatcher workflowDispatcher, IIdentityGenerator identityGenerator) : ElsaEndpoint +internal class Endpoint( + IWorkflowDefinitionService workflowDefinitionService, + IWorkflowDispatcher workflowDispatcher, + IIdentityGenerator identityGenerator, + PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) : ElsaEndpoint { public override void Configure() { @@ -27,6 +32,13 @@ internal class Endpoint(IWorkflowDefinitionService workflowDefinitionService, IW await Send.NotFoundAsync(cancellationToken); return; } + + var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken); + if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + { + await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + return; + } var input = request.Input; @@ -63,4 +75,5 @@ internal class Endpoint(IWorkflowDefinitionService workflowDefinitionService, IW await Send.OkAsync(new Response(instanceId), cancellationToken); } -} \ No newline at end of file + +} 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 4bd54724a..63356f5dc 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs @@ -1,4 +1,5 @@ using Elsa.Abstractions; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime; using JetBrains.Annotations; @@ -13,7 +14,8 @@ internal class GetEndpoint( IWorkflowDefinitionService workflowDefinitionService, IWorkflowRuntime workflowRuntime, IWorkflowStarter workflowStarter, - IApiSerializer apiSerializer) + IApiSerializer apiSerializer, + PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) : ElsaEndpoint { /// @@ -33,7 +35,8 @@ internal class GetEndpoint( workflowRuntime, workflowStarter, apiSerializer, + pythonAuthorizationService, HttpContext, cancellationToken); } -} \ No newline at end of file +} 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 caaef86c9..c3eb39e79 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Elsa.Abstractions; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime; using JetBrains.Annotations; @@ -14,7 +15,8 @@ internal class PostEndpoint( IWorkflowDefinitionService workflowDefinitionService, IWorkflowRuntime workflowRuntime, IWorkflowStarter workflowStarter, - IApiSerializer apiSerializer) + IApiSerializer apiSerializer, + PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) : ElsaEndpointWithoutRequest { /// @@ -71,7 +73,8 @@ internal class PostEndpoint( workflowRuntime, workflowStarter, apiSerializer, + pythonAuthorizationService, HttpContext, cancellationToken); } -} \ No newline at end of file +} 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 359c1cb13..5499b25f6 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs @@ -1,5 +1,6 @@ using System.Net.Mime; using Elsa.Common.Models; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime; using Elsa.Workflows.State; @@ -8,7 +9,7 @@ using Microsoft.AspNetCore.Http; namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute; -public static class WorkflowExecutionHelper +internal static class WorkflowExecutionHelper { public static async Task ExecuteWorkflowAsync( IExecutionRequest request, @@ -16,6 +17,7 @@ public static class WorkflowExecutionHelper IWorkflowRuntime workflowRuntime, IWorkflowStarter workflowStarter, IApiSerializer apiSerializer, + PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService, HttpContext httpContext, CancellationToken cancellationToken) { @@ -28,6 +30,13 @@ public static class WorkflowExecutionHelper await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken); return; } + + var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, httpContext.User, cancellationToken); + if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + { + await SendPythonAuthorizationFailureAsync(httpContext, pythonAuthorizationResult, cancellationToken); + return; + } var startRequest = new StartWorkflowRequest { @@ -87,5 +96,16 @@ public static class WorkflowExecutionHelper httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; await httpContext.Response.WriteAsync(faultedResponse, cancellationToken); } -} + private static async Task SendPythonAuthorizationFailureAsync(HttpContext httpContext, PythonWorkflowDefinitionAuthorizationResult result, CancellationToken cancellationToken) + { + if (result == PythonWorkflowDefinitionAuthorizationResult.MissingPermission) + { + await httpContext.Response.SendForbiddenAsync(cancellation: cancellationToken); + return; + } + + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await httpContext.Response.WriteAsync(PythonWorkflowDefinitionAuthorizationService.HostDisabledMessage, 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 e6ab1387e..2f12131f2 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Import/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Import/Endpoint.cs @@ -1,6 +1,7 @@ using Elsa.Abstractions; using Elsa.Workflows.Api.Constants; using Elsa.Workflows.Api.Requirements; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Models; using JetBrains.Annotations; @@ -17,16 +18,19 @@ internal class Import : ElsaEndpoint private readonly IWorkflowDefinitionImporter _workflowDefinitionImporter; private readonly IWorkflowDefinitionLinker _linker; private readonly IAuthorizationService _authorizationService; + private readonly PythonWorkflowDefinitionAuthorizationService _pythonAuthorizationService; /// public Import( IWorkflowDefinitionImporter workflowDefinitionImporter, IWorkflowDefinitionLinker linker, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) { _workflowDefinitionImporter = workflowDefinitionImporter; _linker = linker; _authorizationService = authorizationService; + _pythonAuthorizationService = pythonAuthorizationService; } /// @@ -42,6 +46,14 @@ 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) + { + await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + return; + } + var result = await ImportSingleWorkflowDefinitionAsync(model, cancellationToken); var definition = result.WorkflowDefinition; @@ -86,4 +98,5 @@ internal class Import : ElsaEndpoint return result; } -} \ No newline at end of file + +} 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 c848f305e..61334a825 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/ImportFiles/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/ImportFiles/Endpoint.cs @@ -1,7 +1,7 @@ -using System.IO.Compression; using Elsa.Abstractions; using Elsa.Workflows.Api.Constants; using Elsa.Workflows.Api.Requirements; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Mappers; using Elsa.Workflows.Management.Models; @@ -22,6 +22,7 @@ internal class ImportFiles : ElsaEndpoint private readonly WorkflowDefinitionMapper _workflowDefinitionMapper; private readonly IApiSerializer _apiSerializer; private readonly IAuthorizationService _authorizationService; + private readonly PythonWorkflowDefinitionAuthorizationService _pythonAuthorizationService; /// public ImportFiles( @@ -29,13 +30,15 @@ internal class ImportFiles : ElsaEndpoint IWorkflowDefinitionImporter workflowDefinitionImporter, WorkflowDefinitionMapper workflowDefinitionMapper, IApiSerializer apiSerializer, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) { _workflowDefinitionService = workflowDefinitionService; _workflowDefinitionImporter = workflowDefinitionImporter; _workflowDefinitionMapper = workflowDefinitionMapper; _apiSerializer = apiSerializer; _authorizationService = authorizationService; + _pythonAuthorizationService = pythonAuthorizationService; } /// @@ -61,56 +64,39 @@ internal class ImportFiles : ElsaEndpoint { var count = await ImportFilesAsync(Files, cancellationToken); - if (!ValidationFailed) + if (!ValidationFailed && !HttpContext.Response.HasStarted) await Send.OkAsync(new { Count = count }, cancellationToken); } - if (ValidationFailed) + if (ValidationFailed && !HttpContext.Response.HasStarted) await Send.ErrorsAsync(400, cancellationToken); } private async Task ImportFilesAsync(IFormFileCollection files, CancellationToken cancellationToken) { - var count = 0; + var models = await WorkflowDefinitionImportFileReader.ReadAsync(files, _apiSerializer, () => HttpContext.Response.HasStarted, cancellationToken); - foreach (var file in files) + foreach (var model in models) { - var fileStream = file.OpenReadStream(); - - // Check if the file is a JSON file or a ZIP file. - var isJsonFile = file.ContentType == "application/json"; - - // If the file is a JSON file, read it. - if (isJsonFile) + var pythonAuthorizationResult = await _pythonAuthorizationService.AuthorizeAsync(model, User, cancellationToken); + if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) { - await ImportJsonStreamAsync(fileStream, cancellationToken); - count++; - } - else - { - // If the file is a ZIP file, extract the JSON files and read them. - var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read); - - foreach (var entry in zipArchive.Entries) - { - if (!entry.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) - continue; - - var jsonStream = entry.Open(); - await ImportJsonStreamAsync(jsonStream, cancellationToken); - count++; - } + await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + return 0; } } - return count; - } + var count = 0; + foreach (var model in models) + { + if (HttpContext.Response.HasStarted) + return count; - private async Task ImportJsonStreamAsync(Stream jsonStream, CancellationToken cancellationToken) - { - var json = await new StreamReader(jsonStream).ReadToEndAsync(cancellationToken); - var model = _apiSerializer.Deserialize(json); - await ImportSingleWorkflowDefinitionAsync(model, cancellationToken); + await ImportSingleWorkflowDefinitionAsync(model, cancellationToken); + count++; + } + + return count; } private async Task ImportSingleWorkflowDefinitionAsync(WorkflowDefinitionModel model, CancellationToken cancellationToken) @@ -132,4 +118,4 @@ internal class ImportFiles : ElsaEndpoint return result; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/ImportFiles/WorkflowDefinitionImportFileReader.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/ImportFiles/WorkflowDefinitionImportFileReader.cs new file mode 100644 index 000000000..673552aac --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/ImportFiles/WorkflowDefinitionImportFileReader.cs @@ -0,0 +1,50 @@ +using System.IO.Compression; +using Elsa.Workflows.Management.Models; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.ImportFiles; + +internal static class WorkflowDefinitionImportFileReader +{ + public static async Task> ReadAsync(IFormFileCollection files, IApiSerializer apiSerializer, Func hasResponseStarted, CancellationToken cancellationToken) + { + var models = new List(); + + foreach (var file in files) + { + if (hasResponseStarted()) + return models; + + await using var fileStream = file.OpenReadStream(); + + if (file.ContentType == "application/json") + { + models.Add(await ReadJsonStreamAsync(fileStream, apiSerializer, cancellationToken)); + continue; + } + + using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read); + + foreach (var entry in zipArchive.Entries) + { + if (hasResponseStarted()) + return models; + + if (!entry.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + continue; + + await using var jsonStream = entry.Open(); + models.Add(await ReadJsonStreamAsync(jsonStream, apiSerializer, cancellationToken)); + } + } + + return models; + } + + private static async Task ReadJsonStreamAsync(Stream jsonStream, IApiSerializer apiSerializer, CancellationToken cancellationToken) + { + using var reader = new StreamReader(jsonStream); + var json = await reader.ReadToEndAsync(cancellationToken); + return apiSerializer.Deserialize(json); + } +} 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 90fcc9db5..77851ed07 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Post/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Post/Endpoint.cs @@ -5,6 +5,7 @@ using Elsa.Workflows.Activities; using Elsa.Workflows.Api.Constants; using Elsa.Workflows.Api.Models; using Elsa.Workflows.Api.Requirements; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Mappers; using Elsa.Workflows.Management.Materializers; @@ -24,7 +25,8 @@ internal class Post( VariableDefinitionMapper variableDefinitionMapper, IDistributedLockProvider distributedLockProvider, IWorkflowDefinitionLinker linker, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) : ElsaEndpoint { public override void Configure() @@ -64,6 +66,13 @@ internal class Post( return; } + var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(model, User, cancellationToken); + if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + { + await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + return; + } + // Update the draft with the received model. var root = model.Root ?? new Sequence(); var serializerOptions = serializer.GetOptions(); @@ -110,4 +119,5 @@ internal class Post( var response = new Response(mappedDefinition, false, affectedWorkflows.Count); await HttpContext.Response.WriteAsJsonAsync(response, serializerOptions, cancellationToken); } -} \ No newline at end of file + +} 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 684b6e7ae..f2300f8e9 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Publish/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Publish/Endpoint.cs @@ -2,6 +2,7 @@ using Elsa.Abstractions; using Elsa.Common.Models; using Elsa.Workflows.Api.Constants; using Elsa.Workflows.Api.Requirements; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Filters; using JetBrains.Annotations; @@ -10,7 +11,13 @@ using Microsoft.AspNetCore.Authorization; namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Publish; [PublicAPI] -internal class Publish(IWorkflowDefinitionStore store, IWorkflowDefinitionPublisher workflowDefinitionPublisher, IWorkflowDefinitionLinker linker, IAuthorizationService authorizationService) +internal class Publish( + IWorkflowDefinitionStore store, + IWorkflowDefinitionPublisher workflowDefinitionPublisher, + IWorkflowDefinitionLinker linker, + IAuthorizationService authorizationService, + IWorkflowDefinitionService workflowDefinitionService, + PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) : ElsaEndpoint { public override void Configure() @@ -43,10 +50,18 @@ internal class Publish(IWorkflowDefinitionStore store, IWorkflowDefinitionPublis return; } + var workflowGraph = await workflowDefinitionService.MaterializeWorkflowAsync(definition, cancellationToken); + var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken); + if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed) + { + await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken); + return; + } + var isPublished = definition.IsPublished; var result = !isPublished ? await workflowDefinitionPublisher.PublishAsync(definition, cancellationToken) : null; var mappedDefinition = await linker.MapAsync(definition, cancellationToken); var response = new Response(mappedDefinition, isPublished, result?.AffectedWorkflows.WorkflowDefinitions.Count ?? 0); await Send.OkAsync(response, cancellationToken); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs b/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs index fd625fc05..d885b2246 100644 --- a/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs +++ b/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs @@ -6,6 +6,7 @@ using Elsa.SasTokens.Features; using Elsa.Workflows.Api.Constants; using Elsa.Workflows.Api.Requirements; using Elsa.Workflows.Api.Serialization; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Api.Services; using Elsa.Workflows.Management.Features; using Elsa.Workflows.Runtime.Features; @@ -36,6 +37,7 @@ public class WorkflowsApiFeature(IModule module) : FeatureBase(module) Module.AddFastEndpointsFromModule(); Services.AddScoped(); + Services.AddScoped(); Services.AddScoped(); Services.Configure(options => { @@ -43,4 +45,4 @@ public class WorkflowsApiFeature(IModule module) : FeatureBase(module) }); Services.AddScoped(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationFailure.cs b/src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationFailure.cs new file mode 100644 index 000000000..fac411252 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationFailure.cs @@ -0,0 +1,21 @@ +namespace Elsa.Workflows.Api.Security; + +internal static class PythonWorkflowDefinitionAuthorizationFailure +{ + public static async Task SendAsync( + PythonWorkflowDefinitionAuthorizationResult result, + Func sendForbiddenAsync, + Action addError, + Func sendErrorsAsync, + CancellationToken cancellationToken) + { + if (result == PythonWorkflowDefinitionAuthorizationResult.MissingPermission) + { + await sendForbiddenAsync(cancellationToken); + return; + } + + addError(PythonWorkflowDefinitionAuthorizationService.HostDisabledMessage); + await sendErrorsAsync(400, cancellationToken); + } +} diff --git a/src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationService.cs b/src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationService.cs new file mode 100644 index 000000000..736d7da76 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Security/PythonWorkflowDefinitionAuthorizationService.cs @@ -0,0 +1,78 @@ +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/ShellFeatures/WorkflowsApiFeature.cs b/src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs index de155669f..689bcb292 100644 --- a/src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs +++ b/src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs @@ -4,6 +4,7 @@ using Elsa.Extensions; using Elsa.Workflows.Api.Constants; using Elsa.Workflows.Api.Requirements; using Elsa.Workflows.Api.Serialization; +using Elsa.Workflows.Api.Security; using Elsa.Workflows.Api.Services; using JetBrains.Annotations; using Microsoft.AspNetCore.Authorization; @@ -36,6 +37,7 @@ public class WorkflowsApiFeature : IFastEndpointsShellFeature { services.AddSerializationOptionsConfigurator(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.Configure(options => { diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj index 43f34a790..dbc164b1c 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj +++ b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj @@ -11,6 +11,7 @@ + diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Security/PythonWorkflowDefinitionAuthorizationServiceTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Security/PythonWorkflowDefinitionAuthorizationServiceTests.cs new file mode 100644 index 000000000..1b6334cd5 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Security/PythonWorkflowDefinitionAuthorizationServiceTests.cs @@ -0,0 +1,110 @@ +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/WorkflowDefinitionImportFileReaderTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Security/WorkflowDefinitionImportFileReaderTests.cs new file mode 100644 index 000000000..edb462e40 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Security/WorkflowDefinitionImportFileReaderTests.cs @@ -0,0 +1,91 @@ +using System.IO.Compression; +using Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.ImportFiles; +using Elsa.Workflows.Management.Models; +using Microsoft.AspNetCore.Http; +using NSubstitute; + +namespace Elsa.Workflows.IntegrationTests.Security; + +public class WorkflowDefinitionImportFileReaderTests +{ + private readonly IApiSerializer _apiSerializer = Substitute.For(); + + public WorkflowDefinitionImportFileReaderTests() + { + _apiSerializer.Deserialize(Arg.Any()) + .Returns(call => new WorkflowDefinitionModel { DefinitionId = call.Arg() }); + } + + [Fact] + public async Task ReadAsync_CollectsAllJsonModelsBeforeImportPreflight() + { + var files = new FormFileCollection + { + CreateJsonFile("plain-a"), + CreateZipFile(("workflow-b.json", "zip-b"), ("workflow-c.json", "zip-c"), ("notes.txt", "ignored")) + }; + + var models = await WorkflowDefinitionImportFileReader.ReadAsync(files, _apiSerializer, () => false, CancellationToken.None); + + Assert.Equal(["plain-a", "zip-b", "zip-c"], models.Select(x => x.DefinitionId)); + } + + [Fact] + public async Task ReadAsync_StopsReadingZipEntries_WhenResponseHasStarted() + { + var hasResponseStarted = false; + var files = new FormFileCollection + { + CreateZipFile(("workflow-a.json", "zip-a"), ("workflow-b.json", "zip-b")) + }; + + _apiSerializer.Deserialize(Arg.Any()) + .Returns(call => + { + hasResponseStarted = true; + return new WorkflowDefinitionModel { DefinitionId = call.Arg() }; + }); + + var models = await WorkflowDefinitionImportFileReader.ReadAsync(files, _apiSerializer, () => hasResponseStarted, CancellationToken.None); + + Assert.Equal(["zip-a"], models.Select(x => x.DefinitionId)); + } + + private static IFormFile CreateJsonFile(string content) + { + var stream = new MemoryStream(); + using var writer = new StreamWriter(stream, leaveOpen: true); + writer.Write(content); + writer.Flush(); + stream.Position = 0; + + return new FormFile(stream, 0, stream.Length, "files", $"{content}.json") + { + Headers = new HeaderDictionary(), + ContentType = "application/json" + }; + } + + private static IFormFile CreateZipFile(params (string Name, string Content)[] entries) + { + var stream = new MemoryStream(); + + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write(content); + } + } + + stream.Position = 0; + return new FormFile(stream, 0, stream.Length, "files", "workflows.zip") + { + Headers = new HeaderDictionary(), + ContentType = "application/zip" + }; + } +} diff --git a/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj b/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj index e3d176913..d0f4fe512 100644 --- a/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj +++ b/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj @@ -9,6 +9,7 @@ + diff --git a/test/unit/Elsa.Expressions.UnitTests/Python/PythonHostCodeExecutionTests.cs b/test/unit/Elsa.Expressions.UnitTests/Python/PythonHostCodeExecutionTests.cs new file mode 100644 index 000000000..86b48ffdb --- /dev/null +++ b/test/unit/Elsa.Expressions.UnitTests/Python/PythonHostCodeExecutionTests.cs @@ -0,0 +1,51 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Expressions.Python.Contracts; +using Elsa.Expressions.Python.Options; +using Elsa.Expressions.Python.Services; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management.Services; +using Elsa.Testing.Shared; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Elsa.Expressions.UnitTests.Python; + +public class PythonHostCodeExecutionTests +{ + [Fact] + public async Task Evaluator_BlocksExecution_WhenHostHasNotOptedIn() + { + var evaluator = new PythonNetPythonEvaluator( + Substitute.For(), + Microsoft.Extensions.Options.Options.Create(new PythonOptions())); + var context = await new ActivityTestFixture(new WriteLine("test")).BuildAsync(); + + var exception = await Assert.ThrowsAsync(() => + evaluator.EvaluateAsync("'hello'", typeof(string), context.ExpressionExecutionContext)); + + Assert.Contains(nameof(PythonOptions.AllowHostCodeExecution), exception.Message); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Descriptor_Browsability_FollowsHostOptIn(bool allowHostCodeExecution) + { + var services = new ServiceCollection(); + services.AddOptions(); + new Elsa.Expressions.Python.ShellFeatures.PythonFeature + { + PythonOptions = options => options.AllowHostCodeExecution = allowHostCodeExecution + }.ConfigureServices(services); + services.AddSingleton(); + + var serviceProvider = services.BuildServiceProvider(); + var registry = serviceProvider.GetRequiredService(); + + var descriptor = registry.Find("Python"); + + Assert.NotNull(descriptor); + Assert.Equal(allowHostCodeExecution, descriptor.IsBrowsable); + } +}