[codex] Harden C# expression host-code execution (#7519)

* Harden C# expression host-code execution

* Address script authorization review feedback

* Harden script authorization failure responses

* Address code quality review feedback

* Use explicit failure filter in script authorization

* Centralize script activity type names

* Address script authorization review feedback
This commit is contained in:
Sipke Schoorstra 2026-05-21 00:50:25 +02:00 committed by GitHub
parent d23e61e9be
commit 2fa1a9ef8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 507 additions and 258 deletions

View file

@ -55,11 +55,14 @@ Additional JavaScript libraries are in [Elsa.Expressions.JavaScript.Libraries](.
```csharp
elsa.UseCSharp(options =>
{
options.AllowHostCodeExecution = true;
options.DisableWrappers = disableVariableWrappers;
options.AppendScript("string Greet(string name) => $\"Hello {name}!\";");
});
```
Roslyn C# scripting is privileged host-code execution, not a sandbox. Hosts must explicitly set `CSharpOptions.AllowHostCodeExecution` to `true` before C# expressions or `RunCSharp` can be authored or executed. API callers that author, publish, dispatch, or directly execute workflows containing C# must have the `exec:csharp-expressions` permission.
## Python
[PythonFeature](../../src/modules/Elsa.Expressions.Python/Features/PythonFeature.cs) registers pythonnet-based evaluation and configures `PythonGlobalInterpreterManager` as a hosted service. Python.NET execution is privileged host-code execution, not a sandbox. Python code can access host process capabilities through pythonnet and must only be enabled for trusted workflow authors.

View file

@ -86,6 +86,7 @@ services
.UseScheduling()
.UseCSharp(options =>
{
configuration.GetSection("Scripting:CSharp").Bind(options);
options.DisableWrappers = disableVariableWrappers;
options.AppendScript("string Greet(string name) => $\"Hello {name}!\";");
options.AppendScript("string SayHelloWorld() => Greet(\"World\");");

View file

@ -84,6 +84,9 @@
]
},
"Scripting": {
"CSharp": {
"AllowHostCodeExecution": false
},
"Python": {
"AllowHostCodeExecution": true,
"PythonDllPath": "",

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 C# workflow expressions.
/// </summary>
public const string ExecuteCSharpExpressions = "exec:csharp-expressions";
/// <summary>
/// Permission required to author or execute Python.NET workflow expressions.
/// </summary>

View file

@ -36,7 +36,7 @@ public class TestApplicationBuilder
_configureElsa += elsa => elsa
.AddActivitiesFrom<WriteLine>()
.UseScheduling()
.UseCSharp()
.UseCSharp(options => options.AllowHostCodeExecution = true)
.UseJavaScript()
.UseLiquid()
.UseWorkflowManagement()

View file

@ -15,7 +15,7 @@ namespace Elsa.Expressions.CSharp.Activities;
/// <summary>
/// Executes C# code.
/// </summary>
[Activity("Elsa", "Scripting", "Executes C# code", DisplayName = "Run C#")]
[Activity(WorkflowScriptActivityTypeNames.Namespace, WorkflowScriptActivityTypeNames.RunCSharpType, 1, "Executes C# code", "Scripting", DisplayName = "Run C#")]
public class RunCSharp : CodeActivity<object?>
{
/// <inheritdoc />

View file

@ -0,0 +1,17 @@
using Elsa.Expressions.CSharp.Options;
using Elsa.Workflows;
using Elsa.Workflows.Models;
using Microsoft.Extensions.Options;
namespace Elsa.Expressions.CSharp.ActivityDescriptorModifiers;
internal class CSharpActivityDescriptorModifier(IOptions<CSharpOptions> options) : IActivityDescriptorModifier
{
public void Modify(ActivityDescriptor descriptor)
{
if (descriptor.TypeName != WorkflowScriptActivityTypeNames.RunCSharp)
return;
descriptor.IsBrowsable = options.Value.AllowHostCodeExecution;
}
}

View file

@ -1,5 +1,6 @@
using Elsa.Caching.Features;
using Elsa.Common.Features;
using Elsa.Expressions.CSharp.ActivityDescriptorModifiers;
using Elsa.Expressions.CSharp.Activities;
using Elsa.Expressions.CSharp.Contracts;
using Elsa.Expressions.CSharp.Options;
@ -42,6 +43,7 @@ public class CSharpFeature : FeatureBase
Services
.AddExpressionDescriptorProvider<CSharpExpressionDescriptorProvider>()
.AddScoped<ICSharpEvaluator, CSharpEvaluator>()
.AddSingleton<IActivityDescriptorModifier, CSharpActivityDescriptorModifier>()
;
// Handlers.

View file

@ -16,6 +16,12 @@ namespace Elsa.Expressions.CSharp.Options;
/// </summary>
public class CSharpOptions
{
/// <summary>
/// Gets or sets whether workflow-authored Roslyn C# host-code execution is allowed.
/// Roslyn scripting is not a sandbox and can access host process capabilities.
/// </summary>
public bool AllowHostCodeExecution { get; set; }
/// <summary>
/// A list of callbacks that is invoked when a C# expression is evaluated. Use this to configure the <see cref="ScriptOptions"/>.
/// </summary>

View file

@ -1,12 +1,14 @@
using Elsa.Expressions.CSharp.Expressions;
using Elsa.Expressions.CSharp.Options;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Elsa.Expressions.CSharp.Providers;
internal class CSharpExpressionDescriptorProvider : IExpressionDescriptorProvider
internal class CSharpExpressionDescriptorProvider(IOptions<CSharpOptions> options) : IExpressionDescriptorProvider
{
private const string TypeName = "CSharp";
@ -16,6 +18,7 @@ internal class CSharpExpressionDescriptorProvider : IExpressionDescriptorProvide
{
Type = TypeName,
DisplayName = "C#",
IsBrowsable = options.Value.AllowHostCodeExecution,
Properties = new { MonacoLanguage = "csharp" }.ToDictionary(),
HandlerFactory = ActivatorUtilities.GetServiceOrCreateInstance<CSharpExpressionHandler>
};

View file

@ -34,6 +34,9 @@ public class CSharpEvaluator(INotificationSender notificationSender, IOptions<CS
Func<Script<object>, Script<object>>? configureScript = default,
CancellationToken cancellationToken = default)
{
if (!_csharpOptions.AllowHostCodeExecution)
throw new InvalidOperationException("C# workflow expression execution is disabled. Set CSharpOptions.AllowHostCodeExecution to true only for trusted workflow authors; Roslyn scripting is not a sandbox.");
var scriptOptions = ScriptOptions.Default.WithOptimizationLevel(OptimizationLevel.Release);
if (configureScriptOptions != null)

View file

@ -1,4 +1,5 @@
using CShells.Features;
using Elsa.Expressions.CSharp.ActivityDescriptorModifiers;
using Elsa.Expressions.CSharp.Activities;
using Elsa.Expressions.CSharp.Contracts;
using Elsa.Expressions.CSharp.Options;
@ -33,7 +34,8 @@ public class CSharpFeature : IShellFeature
// C# services.
services
.AddExpressionDescriptorProvider<CSharpExpressionDescriptorProvider>()
.AddScoped<ICSharpEvaluator, CSharpEvaluator>();
.AddScoped<ICSharpEvaluator, CSharpEvaluator>()
.AddSingleton<IActivityDescriptorModifier, CSharpActivityDescriptorModifier>();
// Handlers.
services.AddNotificationHandlersFrom<CSharpFeature>();
@ -43,4 +45,3 @@ public class CSharpFeature : IShellFeature
}
}

View file

@ -13,7 +13,7 @@ namespace Elsa.Expressions.Python.Activities;
/// <summary>
/// Executes Python code.
/// </summary>
[Activity("Elsa", "Scripting", "Executes Python code", DisplayName = "Run Python")]
[Activity(WorkflowScriptActivityTypeNames.Namespace, WorkflowScriptActivityTypeNames.RunPythonType, 1, "Executes Python code", "Scripting", DisplayName = "Run Python")]
public class RunPython : CodeActivity<object?>
{
/// <inheritdoc />

View file

@ -1,6 +1,5 @@
using Elsa.Expressions.Python.Options;
using Elsa.Workflows;
using Elsa.Workflows.Helpers;
using Elsa.Workflows.Models;
using Microsoft.Extensions.Options;
@ -8,11 +7,9 @@ namespace Elsa.Expressions.Python.ActivityDescriptorModifiers;
internal class PythonActivityDescriptorModifier(IOptions<PythonOptions> options) : IActivityDescriptorModifier
{
private static readonly string RunPythonActivityType = ActivityTypeNameHelper.GenerateTypeName<Activities.RunPython>();
public void Modify(ActivityDescriptor descriptor)
{
if (descriptor.TypeName != RunPythonActivityType)
if (descriptor.TypeName != WorkflowScriptActivityTypeNames.RunPython)
return;
descriptor.IsBrowsable = options.Value.AllowHostCodeExecution;

View file

@ -1,3 +1,4 @@
using System.Collections.Frozen;
using Elsa.Abstractions;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
@ -12,7 +13,11 @@ namespace Elsa.Workflows.Api.Endpoints.Scripting.ExpressionDescriptors.List;
[UsedImplicitly]
internal class List(IExpressionDescriptorRegistry expressionDescriptorRegistry) : ElsaEndpointWithoutRequest<ListResponse<ExpressionDescriptorModel>>
{
private const string PythonExpressionType = "Python";
private static readonly IReadOnlyDictionary<string, string> PrivilegedExpressionPermissions = new Dictionary<string, string>
{
["CSharp"] = PermissionNames.ExecuteCSharpExpressions,
["Python"] = PermissionNames.ExecutePythonExpressions
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <inheritdoc />
public override void Configure()
@ -32,9 +37,10 @@ internal class List(IExpressionDescriptorRegistry expressionDescriptorRegistry)
private bool CanListDescriptor(ExpressionDescriptor descriptor)
{
return descriptor.Type != PythonExpressionType ||
(descriptor.IsBrowsable &&
User.Claims.Any(x => x.Type == "permissions" && (x.Value == PermissionNames.All || x.Value == PermissionNames.ExecutePythonExpressions)));
if (!PrivilegedExpressionPermissions.TryGetValue(descriptor.Type, out var permission))
return true;
return descriptor.IsBrowsable && User.Claims.Any(x => x.Type == PermissionNames.ClaimType && (x.Value == PermissionNames.All || x.Value == permission));
}
private static IEnumerable<ExpressionDescriptorModel> Map(List<ExpressionDescriptor> descriptors) => descriptors.Select(Map);

View file

@ -14,7 +14,7 @@ internal class Endpoint(
IWorkflowDefinitionService workflowDefinitionService,
IWorkflowDispatcher workflowDispatcher,
IIdentityGenerator identityGenerator,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService)
: ElsaEndpoint<Request, Response>
{
public override void Configure()
@ -35,10 +35,10 @@ internal class Endpoint(
return;
}
var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken);
if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed)
var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken);
if (!scriptAuthorizationResult.Succeeded)
{
await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
return;
}

View file

@ -17,7 +17,7 @@ internal class BulkPublish(
IWorkflowDefinitionPublisher workflowDefinitionPublisher,
IAuthorizationService authorizationService,
IWorkflowDefinitionService workflowDefinitionService,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService)
: ElsaEndpoint<Request, Response>
{
public override void Configure()
@ -77,10 +77,10 @@ internal class BulkPublish(
foreach (var (_, definition) in publishableDefinitions)
{
var workflowGraph = await workflowDefinitionService.MaterializeWorkflowAsync(definition, cancellationToken);
var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken);
if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed)
var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken);
if (!scriptAuthorizationResult.Succeeded)
{
await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
return null!;
}
}

View file

@ -13,7 +13,7 @@ internal class Endpoint(
IWorkflowDefinitionService workflowDefinitionService,
IWorkflowDispatcher workflowDispatcher,
IIdentityGenerator identityGenerator,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService) : ElsaEndpoint<Request, Response>
WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService) : ElsaEndpoint<Request, Response>
{
public override void Configure()
{
@ -33,10 +33,10 @@ internal class Endpoint(
return;
}
var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken);
if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed)
var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken);
if (!scriptAuthorizationResult.Succeeded)
{
await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
return;
}

View file

@ -15,7 +15,7 @@ internal class GetEndpoint(
IWorkflowRuntime workflowRuntime,
IWorkflowStarter workflowStarter,
IApiSerializer apiSerializer,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService)
: ElsaEndpoint<GetRequest>
{
/// <inheritdoc />
@ -35,7 +35,7 @@ internal class GetEndpoint(
workflowRuntime,
workflowStarter,
apiSerializer,
pythonAuthorizationService,
scriptAuthorizationService,
HttpContext,
cancellationToken);
}

View file

@ -16,7 +16,7 @@ internal class PostEndpoint(
IWorkflowRuntime workflowRuntime,
IWorkflowStarter workflowStarter,
IApiSerializer apiSerializer,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService)
: ElsaEndpointWithoutRequest<Response>
{
/// <inheritdoc />
@ -73,7 +73,7 @@ internal class PostEndpoint(
workflowRuntime,
workflowStarter,
apiSerializer,
pythonAuthorizationService,
scriptAuthorizationService,
HttpContext,
cancellationToken);
}

View file

@ -17,7 +17,7 @@ internal static class WorkflowExecutionHelper
IWorkflowRuntime workflowRuntime,
IWorkflowStarter workflowStarter,
IApiSerializer apiSerializer,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService,
WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
@ -31,10 +31,10 @@ internal static class WorkflowExecutionHelper
return;
}
var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, httpContext.User, cancellationToken);
if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed)
var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, httpContext.User, cancellationToken);
if (!scriptAuthorizationResult.Succeeded)
{
await SendPythonAuthorizationFailureAsync(httpContext, pythonAuthorizationResult, cancellationToken);
await SendScriptAuthorizationFailureAsync(httpContext, scriptAuthorizationResult, cancellationToken);
return;
}
@ -97,15 +97,15 @@ internal static class WorkflowExecutionHelper
await httpContext.Response.WriteAsync(faultedResponse, cancellationToken);
}
private static async Task SendPythonAuthorizationFailureAsync(HttpContext httpContext, PythonWorkflowDefinitionAuthorizationResult result, CancellationToken cancellationToken)
private static async Task SendScriptAuthorizationFailureAsync(HttpContext httpContext, WorkflowDefinitionScriptAuthorizationResult result, CancellationToken cancellationToken)
{
if (result == PythonWorkflowDefinitionAuthorizationResult.MissingPermission)
if (result.FailureReason == WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission)
{
await httpContext.Response.SendForbiddenAsync(cancellation: cancellationToken);
return;
}
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsync(PythonWorkflowDefinitionAuthorizationService.HostDisabledMessage, cancellationToken);
await httpContext.Response.WriteAsync(result.Message ?? "Workflow script authorization failed.", cancellationToken);
}
}

View file

@ -17,7 +17,7 @@ internal class Import : ElsaEndpoint<WorkflowDefinitionModel>
private readonly IWorkflowDefinitionImporter _workflowDefinitionImporter;
private readonly IWorkflowDefinitionLinker _linker;
private readonly IAuthorizationService _authorizationService;
private readonly PythonWorkflowDefinitionAuthorizationService _pythonAuthorizationService;
private readonly WorkflowDefinitionScriptAuthorizationService _scriptAuthorizationService;
/// <inheritdoc />
public Import(
@ -25,13 +25,13 @@ internal class Import : ElsaEndpoint<WorkflowDefinitionModel>
IWorkflowDefinitionImporter workflowDefinitionImporter,
IWorkflowDefinitionLinker linker,
IAuthorizationService authorizationService,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService)
{
_workflowDefinitionStore = workflowDefinitionStore;
_workflowDefinitionImporter = workflowDefinitionImporter;
_linker = linker;
_authorizationService = authorizationService;
_pythonAuthorizationService = pythonAuthorizationService;
_scriptAuthorizationService = scriptAuthorizationService;
}
/// <inheritdoc />
@ -48,10 +48,10 @@ 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)
var scriptAuthorizationResult = await _scriptAuthorizationService.AuthorizeAsync(model, User, cancellationToken);
if (!scriptAuthorizationResult.Succeeded)
{
await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
return;
}

View file

@ -18,7 +18,7 @@ internal class ImportFiles : ElsaEndpoint<WorkflowDefinitionModel>
private readonly IWorkflowDefinitionImporter _workflowDefinitionImporter;
private readonly IApiSerializer _apiSerializer;
private readonly IAuthorizationService _authorizationService;
private readonly PythonWorkflowDefinitionAuthorizationService _pythonAuthorizationService;
private readonly WorkflowDefinitionScriptAuthorizationService _scriptAuthorizationService;
/// <inheritdoc />
public ImportFiles(
@ -26,13 +26,13 @@ internal class ImportFiles : ElsaEndpoint<WorkflowDefinitionModel>
IWorkflowDefinitionImporter workflowDefinitionImporter,
IApiSerializer apiSerializer,
IAuthorizationService authorizationService,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService)
{
_workflowDefinitionStore = workflowDefinitionStore;
_workflowDefinitionImporter = workflowDefinitionImporter;
_apiSerializer = apiSerializer;
_authorizationService = authorizationService;
_pythonAuthorizationService = pythonAuthorizationService;
_scriptAuthorizationService = scriptAuthorizationService;
}
/// <inheritdoc />
@ -80,10 +80,10 @@ internal class ImportFiles : ElsaEndpoint<WorkflowDefinitionModel>
{
foreach (var model in models)
{
var pythonAuthorizationResult = await _pythonAuthorizationService.AuthorizeAsync(model, User, cancellationToken);
if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed)
var scriptAuthorizationResult = await _scriptAuthorizationService.AuthorizeAsync(model, User, cancellationToken);
if (!scriptAuthorizationResult.Succeeded)
{
await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
return false;
}
}

View file

@ -26,7 +26,7 @@ internal class Post(
IDistributedLockProvider distributedLockProvider,
IWorkflowDefinitionLinker linker,
IAuthorizationService authorizationService,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService)
: ElsaEndpoint<SaveWorkflowDefinitionRequest, LinkedWorkflowDefinitionModel>
{
public override void Configure()
@ -66,10 +66,10 @@ internal class Post(
return;
}
var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(model, User, cancellationToken);
if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed)
var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(model, User, cancellationToken);
if (!scriptAuthorizationResult.Succeeded)
{
await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
return;
}

View file

@ -17,7 +17,7 @@ internal class Publish(
IWorkflowDefinitionLinker linker,
IAuthorizationService authorizationService,
IWorkflowDefinitionService workflowDefinitionService,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
WorkflowDefinitionScriptAuthorizationService scriptAuthorizationService)
: ElsaEndpoint<Request, Response>
{
public override void Configure()
@ -51,10 +51,10 @@ internal class Publish(
}
var workflowGraph = await workflowDefinitionService.MaterializeWorkflowAsync(definition, cancellationToken);
var pythonAuthorizationResult = await pythonAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken);
if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed)
var scriptAuthorizationResult = await scriptAuthorizationService.AuthorizeAsync(workflowGraph.Workflow, User, cancellationToken);
if (!scriptAuthorizationResult.Succeeded)
{
await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
await WorkflowDefinitionScriptAuthorizationFailure.SendAsync(scriptAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
return;
}

View file

@ -37,7 +37,7 @@ public class WorkflowsApiFeature(IModule module) : FeatureBase(module)
Module.AddFastEndpointsFromModule();
Services.AddScoped<IWorkflowDefinitionLinker, StaticWorkflowDefinitionLinker>();
Services.AddScoped<PythonWorkflowDefinitionAuthorizationService>();
Services.AddScoped<WorkflowDefinitionScriptAuthorizationService>();
Services.AddScoped<IAuthorizationHandler, NotReadOnlyRequirementHandler>();
Services.Configure<AuthorizationOptions>(options =>
{

View file

@ -1,78 +0,0 @@
using System.Security.Claims;
using Elsa.Expressions.Contracts;
using Elsa.Extensions;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Management.Models;
namespace Elsa.Workflows.Api.Security;
internal class PythonWorkflowDefinitionAuthorizationService(
IActivityVisitor activityVisitor,
IExpressionDescriptorRegistry expressionDescriptorRegistry)
{
public const string HostDisabledMessage = "Python.NET workflow expression execution is disabled by the host. Set PythonOptions.AllowHostCodeExecution to true only for trusted workflow authors; Python.NET is not a sandbox.";
private const string PythonExpressionType = "Python";
// Keep in sync with ActivityTypeNameHelper.GenerateTypeName<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

@ -1,21 +1,21 @@
namespace Elsa.Workflows.Api.Security;
internal static class PythonWorkflowDefinitionAuthorizationFailure
internal static class WorkflowDefinitionScriptAuthorizationFailure
{
public static async Task SendAsync(
PythonWorkflowDefinitionAuthorizationResult result,
WorkflowDefinitionScriptAuthorizationResult result,
Func<CancellationToken, Task> sendForbiddenAsync,
Action<string> addError,
Func<int, CancellationToken, Task> sendErrorsAsync,
CancellationToken cancellationToken)
{
if (result == PythonWorkflowDefinitionAuthorizationResult.MissingPermission)
if (result.FailureReason == WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission)
{
await sendForbiddenAsync(cancellationToken);
return;
}
addError(PythonWorkflowDefinitionAuthorizationService.HostDisabledMessage);
addError(result.Message ?? "Workflow script authorization failed.");
await sendErrorsAsync(400, cancellationToken);
}
}

View file

@ -0,0 +1,106 @@
using System.Security.Claims;
using Elsa.Expressions.Contracts;
using Elsa.Extensions;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Management.Models;
namespace Elsa.Workflows.Api.Security;
internal class WorkflowDefinitionScriptAuthorizationService(
IActivityVisitor activityVisitor,
IExpressionDescriptorRegistry expressionDescriptorRegistry)
{
private static readonly ScriptPolicy[] ScriptPolicies =
[
new(
"CSharp",
WorkflowScriptActivityTypeNames.RunCSharp,
PermissionNames.ExecuteCSharpExpressions,
"C# workflow expression execution is disabled by the host. Set CSharpOptions.AllowHostCodeExecution to true only for trusted workflow authors; Roslyn scripting is not a sandbox."),
new(
"Python",
WorkflowScriptActivityTypeNames.RunPython,
PermissionNames.ExecutePythonExpressions,
"Python.NET workflow expression execution is disabled by the host. Set PythonOptions.AllowHostCodeExecution to true only for trusted workflow authors; Python.NET is not a sandbox.")
];
public async Task<WorkflowDefinitionScriptAuthorizationResult> AuthorizeAsync(WorkflowDefinitionModel model, ClaimsPrincipal user, CancellationToken cancellationToken = default)
{
if (model.Root == null)
return WorkflowDefinitionScriptAuthorizationResult.Allowed();
return await AuthorizeAsync(model.Root, user, cancellationToken);
}
public async Task<WorkflowDefinitionScriptAuthorizationResult> AuthorizeAsync(IActivity root, ClaimsPrincipal user, CancellationToken cancellationToken = default)
{
var scriptUsages = await GetUsedScriptPoliciesAsync(root, cancellationToken);
var failure = scriptUsages
.Select(policy => AuthorizeScriptUsage(policy, user))
.FirstOrDefault(result => result is { Succeeded: false });
if (failure.FailureReason.HasValue)
return failure;
return WorkflowDefinitionScriptAuthorizationResult.Allowed();
}
public async Task<WorkflowDefinitionScriptAuthorizationResult> AuthorizeAsync(Workflow workflow, ClaimsPrincipal user, CancellationToken cancellationToken = default)
{
return await AuthorizeAsync((IActivity)workflow, user, cancellationToken);
}
private WorkflowDefinitionScriptAuthorizationResult AuthorizeScriptUsage(ScriptPolicy policy, ClaimsPrincipal user)
{
// Language-specific options live in optional modules. Workflows.Api observes the descriptor state projected by those module providers.
if (expressionDescriptorRegistry.Find(policy.ExpressionType)?.IsBrowsable != true)
return WorkflowDefinitionScriptAuthorizationResult.HostDisabled(policy.HostDisabledMessage);
return HasPermission(user, policy.Permission)
? WorkflowDefinitionScriptAuthorizationResult.Allowed()
: WorkflowDefinitionScriptAuthorizationResult.MissingPermission();
}
private async Task<IEnumerable<ScriptPolicy>> GetUsedScriptPoliciesAsync(IActivity root, CancellationToken cancellationToken)
{
var graph = await activityVisitor.VisitAsync(root, cancellationToken);
var nodes = new[] { graph }.Concat(graph.Descendants()).ToList();
var policies = ScriptPolicies
.Where(policy => nodes.Any(x => IsRunActivity(x.Activity, policy) || HasExpression(x.Activity, policy)))
.ToList();
return policies;
}
private static bool IsRunActivity(IActivity activity, ScriptPolicy policy) =>
string.Equals(activity.Type, policy.RunActivityType, StringComparison.Ordinal);
private static bool HasExpression(IActivity activity, ScriptPolicy policy) =>
activity.GetInputs().Any(x => string.Equals(x.Expression?.Type, policy.ExpressionType, StringComparison.Ordinal));
private static bool HasPermission(ClaimsPrincipal user, string permission)
{
return user.Claims.Any(x =>
x.Type == PermissionNames.ClaimType &&
(string.Equals(x.Value, PermissionNames.All, StringComparison.Ordinal) ||
string.Equals(x.Value, permission, StringComparison.Ordinal)));
}
private sealed record ScriptPolicy(string ExpressionType, string RunActivityType, string Permission, string HostDisabledMessage);
}
internal readonly record struct WorkflowDefinitionScriptAuthorizationResult(bool Succeeded, WorkflowDefinitionScriptAuthorizationFailureReason? FailureReason, string? Message)
{
public static WorkflowDefinitionScriptAuthorizationResult Allowed() => new(true, null, null);
public static WorkflowDefinitionScriptAuthorizationResult HostDisabled(string message) => new(false, WorkflowDefinitionScriptAuthorizationFailureReason.HostDisabled, message);
public static WorkflowDefinitionScriptAuthorizationResult MissingPermission() => new(false, WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission, null);
}
internal enum WorkflowDefinitionScriptAuthorizationFailureReason
{
HostDisabled,
MissingPermission
}

View file

@ -37,7 +37,7 @@ public class WorkflowsApiFeature : IFastEndpointsShellFeature
{
services.AddSerializationOptionsConfigurator<SerializationConfigurator>();
services.AddScoped<IWorkflowDefinitionLinker, StaticWorkflowDefinitionLinker>();
services.AddScoped<PythonWorkflowDefinitionAuthorizationService>();
services.AddScoped<WorkflowDefinitionScriptAuthorizationService>();
services.AddScoped<IAuthorizationHandler, NotReadOnlyRequirementHandler>();
services.Configure<AuthorizationOptions>(options =>
{

View file

@ -0,0 +1,32 @@
namespace Elsa.Workflows;
/// <summary>
/// Activity type names for built-in workflow script activities.
/// </summary>
public static class WorkflowScriptActivityTypeNames
{
/// <summary>
/// The Elsa activity namespace for built-in script activities.
/// </summary>
public const string Namespace = "Elsa";
/// <summary>
/// The unqualified C# script activity type.
/// </summary>
public const string RunCSharpType = "RunCSharp";
/// <summary>
/// The fully qualified C# script activity type name.
/// </summary>
public const string RunCSharp = $"{Namespace}.{RunCSharpType}";
/// <summary>
/// The unqualified Python script activity type.
/// </summary>
public const string RunPythonType = "RunPython";
/// <summary>
/// The fully qualified Python script activity type name.
/// </summary>
public const string RunPython = $"{Namespace}.{RunPythonType}";
}

View file

@ -1,110 +0,0 @@
using System.Security.Claims;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Api.Security;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Management.Services;
using Elsa.Workflows.Models;
using Elsa.Workflows.PortResolvers;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.Workflows.IntegrationTests.Security;
public class PythonWorkflowDefinitionAuthorizationServiceTests
{
private static readonly ClaimsPrincipal UserWithPythonPermission = CreateUser(PermissionNames.ExecutePythonExpressions);
private static readonly ClaimsPrincipal UserWithoutPythonPermission = CreateUser("write:workflow-definitions");
[Fact]
public async Task AuthorizeAsync_BlocksPythonExpression_WhenHostHasNotOptedIn()
{
var service = CreateService(hostAllowsPython: false);
var model = CreateModelWithPythonExpression();
var result = await service.AuthorizeAsync(model, UserWithPythonPermission);
Assert.Equal(PythonWorkflowDefinitionAuthorizationResult.HostDisabled, result);
}
[Fact]
public async Task AuthorizeAsync_BlocksPythonExpression_WhenUserLacksPermission()
{
var service = CreateService(hostAllowsPython: true);
var model = CreateModelWithPythonExpression();
var result = await service.AuthorizeAsync(model, UserWithoutPythonPermission);
Assert.Equal(PythonWorkflowDefinitionAuthorizationResult.MissingPermission, result);
}
[Fact]
public async Task AuthorizeAsync_AllowsPythonExpression_WhenHostAndUserAllowIt()
{
var service = CreateService(hostAllowsPython: true);
var model = CreateModelWithPythonExpression();
var result = await service.AuthorizeAsync(model, UserWithPythonPermission);
Assert.Equal(PythonWorkflowDefinitionAuthorizationResult.Allowed, result);
}
[Fact]
public async Task AuthorizeAsync_TreatsRunPythonActivityAsPythonUsage()
{
var service = CreateService(hostAllowsPython: true);
var model = new WorkflowDefinitionModel
{
Root = new WriteLine("hello")
{
Type = "Elsa.RunPython"
}
};
var result = await service.AuthorizeAsync(model, UserWithoutPythonPermission);
Assert.Equal(PythonWorkflowDefinitionAuthorizationResult.MissingPermission, result);
}
private static WorkflowDefinitionModel CreateModelWithPythonExpression()
{
return new()
{
Root = new WriteLine("placeholder")
{
Text = new Input<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,198 @@
using System.Security.Claims;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Api.Security;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Management.Services;
using Elsa.Workflows.Models;
using Elsa.Workflows.PortResolvers;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.Workflows.IntegrationTests.Security;
public class WorkflowDefinitionScriptAuthorizationServiceTests
{
private static readonly ClaimsPrincipal UserWithCSharpPermission = CreateUser(PermissionNames.ExecuteCSharpExpressions);
private static readonly ClaimsPrincipal UserWithPythonPermission = CreateUser(PermissionNames.ExecutePythonExpressions);
private static readonly ClaimsPrincipal UserWithoutScriptPermission = CreateUser("write:workflow-definitions");
[Fact]
public async Task AuthorizeAsync_BlocksCSharpExpression_WhenHostHasNotOptedIn()
{
var service = CreateService(hostAllowsCSharp: false, hostAllowsPython: true);
var model = CreateModelWithCSharpExpression();
var result = await service.AuthorizeAsync(model, UserWithCSharpPermission);
Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.HostDisabled, result.FailureReason);
Assert.Contains("CSharpOptions.AllowHostCodeExecution", result.Message);
}
[Fact]
public async Task AuthorizeAsync_BlocksCSharpExpression_WhenUserLacksPermission()
{
var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true);
var model = CreateModelWithCSharpExpression();
var result = await service.AuthorizeAsync(model, UserWithoutScriptPermission);
Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission, result.FailureReason);
}
[Fact]
public async Task AuthorizeAsync_AllowsCSharpExpression_WhenHostAndUserAllowIt()
{
var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true);
var model = CreateModelWithCSharpExpression();
var result = await service.AuthorizeAsync(model, UserWithCSharpPermission);
Assert.True(result.Succeeded);
}
[Fact]
public async Task AuthorizeAsync_AllowsWorkflowWithoutScriptUsage()
{
var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true);
var model = new WorkflowDefinitionModel
{
Root = new WriteLine("hello")
};
var result = await service.AuthorizeAsync(model, UserWithoutScriptPermission);
Assert.True(result.Succeeded);
}
[Fact]
public async Task AuthorizeAsync_TreatsRunCSharpActivityAsCSharpUsage()
{
var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true);
var model = new WorkflowDefinitionModel
{
Root = new WriteLine("hello")
{
Type = WorkflowScriptActivityTypeNames.RunCSharp
}
};
var result = await service.AuthorizeAsync(model, UserWithoutScriptPermission);
Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission, result.FailureReason);
}
[Fact]
public async Task AuthorizeAsync_BlocksPythonExpression_WhenHostHasNotOptedIn()
{
var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: false);
var model = CreateModelWithPythonExpression();
var result = await service.AuthorizeAsync(model, UserWithPythonPermission);
Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.HostDisabled, result.FailureReason);
Assert.Contains("PythonOptions.AllowHostCodeExecution", result.Message);
}
[Fact]
public async Task AuthorizeAsync_BlocksPythonExpression_WhenUserLacksPermission()
{
var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true);
var model = CreateModelWithPythonExpression();
var result = await service.AuthorizeAsync(model, UserWithoutScriptPermission);
Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission, result.FailureReason);
}
[Fact]
public async Task AuthorizeAsync_AllowsPythonExpression_WhenHostAndUserAllowIt()
{
var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true);
var model = CreateModelWithPythonExpression();
var result = await service.AuthorizeAsync(model, UserWithPythonPermission);
Assert.True(result.Succeeded);
}
[Fact]
public async Task AuthorizeAsync_TreatsRunPythonActivityAsPythonUsage()
{
var service = CreateService(hostAllowsCSharp: true, hostAllowsPython: true);
var model = new WorkflowDefinitionModel
{
Root = new WriteLine("hello")
{
Type = WorkflowScriptActivityTypeNames.RunPython
}
};
var result = await service.AuthorizeAsync(model, UserWithoutScriptPermission);
Assert.Equal(WorkflowDefinitionScriptAuthorizationFailureReason.MissingPermission, result.FailureReason);
}
private static WorkflowDefinitionModel CreateModelWithCSharpExpression()
{
return new()
{
Root = new WriteLine("placeholder")
{
Text = new Input<string>(new Expression("CSharp", "\"hello\""))
}
};
}
private static WorkflowDefinitionModel CreateModelWithPythonExpression()
{
return new()
{
Root = new WriteLine("placeholder")
{
Text = new Input<string>(new Expression("Python", "'hello'"))
}
};
}
private static WorkflowDefinitionScriptAuthorizationService CreateService(bool hostAllowsCSharp, bool hostAllowsPython)
{
var expressionDescriptors = new[]
{
new ExpressionDescriptor
{
Type = "CSharp",
DisplayName = "C#",
IsBrowsable = hostAllowsCSharp,
HandlerFactory = _ => Substitute.For<IExpressionHandler>()
},
new ExpressionDescriptor
{
Type = "Python",
DisplayName = "Python",
IsBrowsable = hostAllowsPython,
HandlerFactory = _ => Substitute.For<IExpressionHandler>()
}
};
var provider = Substitute.For<IExpressionDescriptorProvider>();
provider.GetDescriptors().Returns(expressionDescriptors);
var registry = new ExpressionDescriptorRegistry([provider]);
var visitor = new ActivityVisitor(
[
new SwitchActivityResolver(),
new PropertyBasedActivityResolver()
],
new ServiceCollection().BuildServiceProvider());
return new(visitor, registry);
}
private static ClaimsPrincipal CreateUser(params string[] permissions)
{
var identity = new ClaimsIdentity(permissions.Select(x => new Claim("permissions", x)), "Test");
return new(identity);
}
}

View file

@ -0,0 +1,54 @@
using Elsa.Expressions.Contracts;
using Elsa.Expressions.CSharp.Contracts;
using Elsa.Expressions.CSharp.Options;
using Elsa.Expressions.CSharp.Services;
using Elsa.Expressions.Models;
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.Expressions.UnitTests.CSharp;
public class CSharpHostCodeExecutionTests
{
[Fact]
public async Task Evaluator_BlocksExecution_WhenHostHasNotOptedIn()
{
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var evaluator = new CSharpEvaluator(
Substitute.For<Elsa.Mediator.Contracts.INotificationSender>(),
Microsoft.Extensions.Options.Options.Create(new CSharpOptions()),
memoryCache);
var context = await new ActivityTestFixture(new WriteLine("test")).BuildAsync();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
evaluator.EvaluateAsync("\"hello\"", typeof(string), context.ExpressionExecutionContext, new ExpressionEvaluatorOptions()));
Assert.Contains(nameof(CSharpOptions.AllowHostCodeExecution), exception.Message);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Descriptor_Browsability_FollowsHostOptIn(bool allowHostCodeExecution)
{
var services = new ServiceCollection();
services.AddOptions();
services.AddMemoryCache();
new Elsa.Expressions.CSharp.ShellFeatures.CSharpFeature
{
CSharpOptions = options => options.AllowHostCodeExecution = allowHostCodeExecution
}.ConfigureServices(services);
services.AddSingleton<IExpressionDescriptorRegistry, Elsa.Workflows.Management.Services.ExpressionDescriptorRegistry>();
var serviceProvider = services.BuildServiceProvider();
var registry = serviceProvider.GetRequiredService<IExpressionDescriptorRegistry>();
var descriptor = registry.Find("CSharp");
Assert.NotNull(descriptor);
Assert.Equal(allowHostCodeExecution, descriptor.IsBrowsable);
}
}