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:
Sipke Schoorstra 2026-05-20 14:19:47 +02:00 committed by GitHub
parent 1da8709e2c
commit 0856d79093
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 645 additions and 69 deletions

View file

@ -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:

View file

@ -133,6 +133,7 @@
},
"Scripting": {
"Python": {
"AllowHostCodeExecution": true,
"PythonDllPath": "",
"Scripts": [
"def greet(name): return f'Hello {name}!'",

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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>();
}
}
}

View file

@ -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;
}
}

View file

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

View file

@ -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>
};
}
}
}

View file

@ -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();
}
}
}

View file

@ -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
}
}

View file

@ -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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

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

View file

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

View file

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

View file

@ -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>();
}
}
}

View file

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

View file

@ -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
}

View file

@ -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 =>
{

View file

@ -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>

View file

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

View file

@ -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"
};
}
}

View file

@ -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>

View file

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