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
This commit is contained in:
parent
1da8709e2c
commit
0856d79093
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@
|
|||
},
|
||||
"Scripting": {
|
||||
"Python": {
|
||||
"AllowHostCodeExecution": true,
|
||||
"PythonDllPath": "",
|
||||
"Scripts": [
|
||||
"def greet(name): return f'Hello {name}!'",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ public static class PermissionNames
|
|||
public const string All = "*";
|
||||
public const string ClaimType = "permissions";
|
||||
|
||||
/// <summary>
|
||||
/// Permission required to author or execute Python.NET workflow expressions.
|
||||
/// </summary>
|
||||
public const string ExecutePythonExpressions = "exec:python-expressions";
|
||||
|
||||
/// <summary>
|
||||
/// Permission required to pause, resume, force-drain, or query the workflow runtime's graceful-shutdown status.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -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<PythonOptions> options) : IActivityDescriptorModifier
|
||||
{
|
||||
private static readonly string RunPythonActivityType = ActivityTypeNameHelper.GenerateTypeName<Activities.RunPython>();
|
||||
|
||||
public void Modify(ActivityDescriptor descriptor)
|
||||
{
|
||||
if (descriptor.TypeName != RunPythonActivityType)
|
||||
return;
|
||||
|
||||
descriptor.IsBrowsable = options.Value.AllowHostCodeExecution;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IPythonEvaluator, PythonNetPythonEvaluator>()
|
||||
.AddExpressionDescriptorProvider<PythonExpressionDescriptorProvider>()
|
||||
.AddSingleton<IActivityDescriptorModifier, PythonActivityDescriptorModifier>()
|
||||
;
|
||||
|
||||
// Handlers.
|
||||
|
|
@ -58,4 +60,4 @@ public class PythonFeature : FeatureBase
|
|||
// UI property handlers.
|
||||
Services.AddScoped<IPropertyUIHandler, RunPythonOptionsProvider>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ public class PythonGlobalInterpreterManager : IHostedService
|
|||
/// <inheritdoc />
|
||||
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
|
|||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ namespace Elsa.Expressions.Python.Options;
|
|||
[PublicAPI]
|
||||
public class PythonOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool AllowHostCodeExecution { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
|
|
@ -52,4 +58,4 @@ public class PythonOptions
|
|||
{
|
||||
Scopes.Add(configure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PythonOptions> 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<PythonExpressionHandler>
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PythonOptions> _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PythonNetPythonEvaluator"/> class.
|
||||
/// </summary>
|
||||
public PythonNetPythonEvaluator(INotificationSender notificationSender)
|
||||
public PythonNetPythonEvaluator(INotificationSender notificationSender) : this(notificationSender, Microsoft.Extensions.Options.Options.Create(new PythonOptions()))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PythonNetPythonEvaluator"/> class.
|
||||
/// </summary>
|
||||
public PythonNetPythonEvaluator(INotificationSender notificationSender, IOptions<PythonOptions> options)
|
||||
{
|
||||
_notificationSender = notificationSender;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<object?> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IPythonEvaluator, PythonNetPythonEvaluator>()
|
||||
.AddExpressionDescriptorProvider<PythonExpressionDescriptorProvider>();
|
||||
.AddExpressionDescriptorProvider<PythonExpressionDescriptorProvider>()
|
||||
.AddSingleton<IActivityDescriptorModifier, PythonActivityDescriptorModifier>();
|
||||
|
||||
// Handlers.
|
||||
services.AddNotificationHandlersFrom<PythonFeature>();
|
||||
|
|
@ -47,4 +49,3 @@ public class PythonFeature : IShellFeature
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ namespace Elsa.Workflows.Api.Endpoints.Scripting.ExpressionDescriptors.List;
|
|||
[UsedImplicitly]
|
||||
internal class List(IExpressionDescriptorRegistry expressionDescriptorRegistry) : ElsaEndpointWithoutRequest<ListResponse<ExpressionDescriptorModel>>
|
||||
{
|
||||
private const string PythonExpressionType = "Python";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
|
|
@ -22,12 +24,19 @@ internal class List(IExpressionDescriptorRegistry expressionDescriptorRegistry)
|
|||
/// <inheritdoc />
|
||||
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<ExpressionDescriptorModel>(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<ExpressionDescriptorModel> Map(List<ExpressionDescriptor> 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<string, object> Properties);
|
||||
internal record ExpressionDescriptorModel(string Type, string DisplayName, bool IsSerializable, bool IsBrowsable, IDictionary<string, object> Properties);
|
||||
|
|
|
|||
|
|
@ -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<Request, Response>
|
||||
{
|
||||
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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Request, Response>
|
||||
{
|
||||
public override void Configure()
|
||||
|
|
@ -34,6 +41,7 @@ internal class BulkPublish(IWorkflowDefinitionStore store, IWorkflowDefinitionPu
|
|||
var alreadyPublished = new List<string>();
|
||||
var skipped = new List<string>();
|
||||
var updatedConsumers = new List<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Request, Response>
|
||||
internal class Endpoint(
|
||||
IWorkflowDefinitionService workflowDefinitionService,
|
||||
IWorkflowDispatcher workflowDispatcher,
|
||||
IIdentityGenerator identityGenerator,
|
||||
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) : ElsaEndpoint<Request, Response>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GetRequest>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
|
@ -33,7 +35,8 @@ internal class GetEndpoint(
|
|||
workflowRuntime,
|
||||
workflowStarter,
|
||||
apiSerializer,
|
||||
pythonAuthorizationService,
|
||||
HttpContext,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Response>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
|
@ -71,7 +73,8 @@ internal class PostEndpoint(
|
|||
workflowRuntime,
|
||||
workflowStarter,
|
||||
apiSerializer,
|
||||
pythonAuthorizationService,
|
||||
HttpContext,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WorkflowDefinitionModel>
|
|||
private readonly IWorkflowDefinitionImporter _workflowDefinitionImporter;
|
||||
private readonly IWorkflowDefinitionLinker _linker;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly PythonWorkflowDefinitionAuthorizationService _pythonAuthorizationService;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Import(
|
||||
IWorkflowDefinitionImporter workflowDefinitionImporter,
|
||||
IWorkflowDefinitionLinker linker,
|
||||
IAuthorizationService authorizationService)
|
||||
IAuthorizationService authorizationService,
|
||||
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
|
||||
{
|
||||
_workflowDefinitionImporter = workflowDefinitionImporter;
|
||||
_linker = linker;
|
||||
_authorizationService = authorizationService;
|
||||
_pythonAuthorizationService = pythonAuthorizationService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -42,6 +46,14 @@ internal class Import : ElsaEndpoint<WorkflowDefinitionModel>
|
|||
{
|
||||
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<WorkflowDefinitionModel>
|
|||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WorkflowDefinitionModel>
|
|||
private readonly WorkflowDefinitionMapper _workflowDefinitionMapper;
|
||||
private readonly IApiSerializer _apiSerializer;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly PythonWorkflowDefinitionAuthorizationService _pythonAuthorizationService;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImportFiles(
|
||||
|
|
@ -29,13 +30,15 @@ internal class ImportFiles : ElsaEndpoint<WorkflowDefinitionModel>
|
|||
IWorkflowDefinitionImporter workflowDefinitionImporter,
|
||||
WorkflowDefinitionMapper workflowDefinitionMapper,
|
||||
IApiSerializer apiSerializer,
|
||||
IAuthorizationService authorizationService)
|
||||
IAuthorizationService authorizationService,
|
||||
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
|
||||
{
|
||||
_workflowDefinitionService = workflowDefinitionService;
|
||||
_workflowDefinitionImporter = workflowDefinitionImporter;
|
||||
_workflowDefinitionMapper = workflowDefinitionMapper;
|
||||
_apiSerializer = apiSerializer;
|
||||
_authorizationService = authorizationService;
|
||||
_pythonAuthorizationService = pythonAuthorizationService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -61,56 +64,39 @@ internal class ImportFiles : ElsaEndpoint<WorkflowDefinitionModel>
|
|||
{
|
||||
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<int> 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<WorkflowDefinitionModel>(json);
|
||||
await ImportSingleWorkflowDefinitionAsync(model, cancellationToken);
|
||||
await ImportSingleWorkflowDefinitionAsync(model, cancellationToken);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private async Task<ImportWorkflowResult> ImportSingleWorkflowDefinitionAsync(WorkflowDefinitionModel model, CancellationToken cancellationToken)
|
||||
|
|
@ -132,4 +118,4 @@ internal class ImportFiles : ElsaEndpoint<WorkflowDefinitionModel>
|
|||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IReadOnlyCollection<WorkflowDefinitionModel>> ReadAsync(IFormFileCollection files, IApiSerializer apiSerializer, Func<bool> hasResponseStarted, CancellationToken cancellationToken)
|
||||
{
|
||||
var models = new List<WorkflowDefinitionModel>();
|
||||
|
||||
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<WorkflowDefinitionModel> ReadJsonStreamAsync(Stream jsonStream, IApiSerializer apiSerializer, CancellationToken cancellationToken)
|
||||
{
|
||||
using var reader = new StreamReader(jsonStream);
|
||||
var json = await reader.ReadToEndAsync(cancellationToken);
|
||||
return apiSerializer.Deserialize<WorkflowDefinitionModel>(json);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SaveWorkflowDefinitionRequest, LinkedWorkflowDefinitionModel>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Request, Response>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IWorkflowDefinitionLinker, StaticWorkflowDefinitionLinker>();
|
||||
Services.AddScoped<PythonWorkflowDefinitionAuthorizationService>();
|
||||
Services.AddScoped<IAuthorizationHandler, NotReadOnlyRequirementHandler>();
|
||||
Services.Configure<AuthorizationOptions>(options =>
|
||||
{
|
||||
|
|
@ -43,4 +45,4 @@ public class WorkflowsApiFeature(IModule module) : FeatureBase(module)
|
|||
});
|
||||
Services.AddScoped<IWorkflowInstanceExportNameProvider, DefaultWorkflowInstanceExportNameProvider>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
namespace Elsa.Workflows.Api.Security;
|
||||
|
||||
internal static class PythonWorkflowDefinitionAuthorizationFailure
|
||||
{
|
||||
public static async Task SendAsync(
|
||||
PythonWorkflowDefinitionAuthorizationResult result,
|
||||
Func<CancellationToken, Task> sendForbiddenAsync,
|
||||
Action<string> addError,
|
||||
Func<int, CancellationToken, Task> sendErrorsAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (result == PythonWorkflowDefinitionAuthorizationResult.MissingPermission)
|
||||
{
|
||||
await sendForbiddenAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
addError(PythonWorkflowDefinitionAuthorizationService.HostDisabledMessage);
|
||||
await sendErrorsAsync(400, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Elsa.Expressions.Python.Activities.RunPython>().
|
||||
private const string RunPythonActivityType = "Elsa.RunPython";
|
||||
private const string PermissionsClaimType = "permissions";
|
||||
|
||||
public async Task<PythonWorkflowDefinitionAuthorizationResult> 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<PythonWorkflowDefinitionAuthorizationResult> 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<bool> 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
|
||||
}
|
||||
|
|
@ -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<SerializationConfigurator>();
|
||||
services.AddScoped<IWorkflowDefinitionLinker, StaticWorkflowDefinitionLinker>();
|
||||
services.AddScoped<PythonWorkflowDefinitionAuthorizationService>();
|
||||
services.AddScoped<IAuthorizationHandler, NotReadOnlyRequirementHandler>();
|
||||
services.Configure<AuthorizationOptions>(options =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
<ProjectReference Include="..\..\..\src\modules\Elsa.Expressions.JavaScript\Elsa.Expressions.JavaScript.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Http\Elsa.Http.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Scheduling\Elsa.Scheduling.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Api\Elsa.Workflows.Api.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Runtime.Distributed\Elsa.Workflows.Runtime.Distributed.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Runtime\Elsa.Workflows.Runtime.csproj" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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<string>(new Expression("Python", "'hello'"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static PythonWorkflowDefinitionAuthorizationService CreateService(bool hostAllowsPython)
|
||||
{
|
||||
var expressionDescriptor = new ExpressionDescriptor
|
||||
{
|
||||
Type = "Python",
|
||||
DisplayName = "Python",
|
||||
IsBrowsable = hostAllowsPython,
|
||||
HandlerFactory = _ => Substitute.For<IExpressionHandler>()
|
||||
};
|
||||
|
||||
var provider = Substitute.For<IExpressionDescriptorProvider>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IApiSerializer>();
|
||||
|
||||
public WorkflowDefinitionImportFileReaderTests()
|
||||
{
|
||||
_apiSerializer.Deserialize<WorkflowDefinitionModel>(Arg.Any<string>())
|
||||
.Returns(call => new WorkflowDefinitionModel { DefinitionId = call.Arg<string>() });
|
||||
}
|
||||
|
||||
[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<WorkflowDefinitionModel>(Arg.Any<string>())
|
||||
.Returns(call =>
|
||||
{
|
||||
hasResponseStarted = true;
|
||||
return new WorkflowDefinitionModel { DefinitionId = call.Arg<string>() };
|
||||
});
|
||||
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Expressions\Elsa.Expressions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Expressions.CSharp\Elsa.Expressions.CSharp.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Expressions.Python\Elsa.Expressions.Python.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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<Elsa.Mediator.Contracts.INotificationSender>(),
|
||||
Microsoft.Extensions.Options.Options.Create(new PythonOptions()));
|
||||
var context = await new ActivityTestFixture(new WriteLine("test")).BuildAsync();
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
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<IExpressionDescriptorRegistry, ExpressionDescriptorRegistry>();
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var registry = serviceProvider.GetRequiredService<IExpressionDescriptorRegistry>();
|
||||
|
||||
var descriptor = registry.Find("Python");
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(allowHostCodeExecution, descriptor.IsBrowsable);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue