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);
+ }
+}