From 556e9316622b509ef38f742ad52a456c27ae15fa Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 1 Jun 2026 09:26:05 +0200 Subject: [PATCH] Add JavaScript secret functions --- Elsa.sln | 15 +++ .../Elsa.Secrets.JavaScript.csproj | 15 +++ .../Extensions/ModuleExtensions.cs | 13 +++ .../Features/SecretsJavaScriptFeature.cs | 22 ++++ .../Elsa.Secrets.JavaScript/FodyWeavers.xml | 4 + .../JavaScript/SecretsJavaScriptHandler.cs | 27 +++++ .../ShellFeatures/SecretsJavaScriptFeature.cs | 24 ++++ .../Elsa.JavaScript.IntegrationTests.csproj | 1 + .../SecretsJavaScriptTests.cs | 108 ++++++++++++++++++ 9 files changed, 229 insertions(+) create mode 100644 src/modules/Elsa.Secrets.JavaScript/Elsa.Secrets.JavaScript.csproj create mode 100644 src/modules/Elsa.Secrets.JavaScript/Extensions/ModuleExtensions.cs create mode 100644 src/modules/Elsa.Secrets.JavaScript/Features/SecretsJavaScriptFeature.cs create mode 100644 src/modules/Elsa.Secrets.JavaScript/FodyWeavers.xml create mode 100644 src/modules/Elsa.Secrets.JavaScript/Scripting/JavaScript/SecretsJavaScriptHandler.cs create mode 100644 src/modules/Elsa.Secrets.JavaScript/ShellFeatures/SecretsJavaScriptFeature.cs create mode 100644 test/integration/Elsa.JavaScript.IntegrationTests/SecretsJavaScriptTests.cs diff --git a/Elsa.sln b/Elsa.sln index c16d086ad..f037a18c6 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -355,6 +355,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Diagnostics.OpenTeleme EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "secrets", "secrets", "{D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.JavaScript", "src\modules\Elsa.Secrets.JavaScript\Elsa.Secrets.JavaScript.csproj", "{80F7ED38-15BA-4C82-88C8-12135B33F7EF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1431,6 +1433,18 @@ Global {B6A62D42-D9AA-47BE-BCBC-FCB866D346F0}.Release|x64.Build.0 = Release|Any CPU {B6A62D42-D9AA-47BE-BCBC-FCB866D346F0}.Release|x86.ActiveCfg = Release|Any CPU {B6A62D42-D9AA-47BE-BCBC-FCB866D346F0}.Release|x86.Build.0 = Release|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Debug|x64.ActiveCfg = Debug|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Debug|x64.Build.0 = Debug|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Debug|x86.ActiveCfg = Debug|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Debug|x86.Build.0 = Debug|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Release|Any CPU.Build.0 = Release|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Release|x64.ActiveCfg = Release|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Release|x64.Build.0 = Release|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Release|x86.ActiveCfg = Release|Any CPU + {80F7ED38-15BA-4C82-88C8-12135B33F7EF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1558,6 +1572,7 @@ Global {B6A62D42-D9AA-47BE-BCBC-FCB866D346F0} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {09B4B78B-FE02-44E2-8667-E182AF921C54} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89} + {80F7ED38-15BA-4C82-88C8-12135B33F7EF} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/modules/Elsa.Secrets.JavaScript/Elsa.Secrets.JavaScript.csproj b/src/modules/Elsa.Secrets.JavaScript/Elsa.Secrets.JavaScript.csproj new file mode 100644 index 000000000..8fd62bfe0 --- /dev/null +++ b/src/modules/Elsa.Secrets.JavaScript/Elsa.Secrets.JavaScript.csproj @@ -0,0 +1,15 @@ + + + + + Provides JavaScript expression functions for resolving Elsa secrets. + + elsa module secrets javascript workflow security + + + + + + + + diff --git a/src/modules/Elsa.Secrets.JavaScript/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Secrets.JavaScript/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..8931ef1a1 --- /dev/null +++ b/src/modules/Elsa.Secrets.JavaScript/Extensions/ModuleExtensions.cs @@ -0,0 +1,13 @@ +using Elsa.Features.Services; +using Elsa.Secrets.JavaScript.Features; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +public static class ModuleExtensions +{ + public static IModule UseSecretsJavaScript(this IModule module, Action? configure = null) + { + return module.Use(configure); + } +} diff --git a/src/modules/Elsa.Secrets.JavaScript/Features/SecretsJavaScriptFeature.cs b/src/modules/Elsa.Secrets.JavaScript/Features/SecretsJavaScriptFeature.cs new file mode 100644 index 000000000..811c15532 --- /dev/null +++ b/src/modules/Elsa.Secrets.JavaScript/Features/SecretsJavaScriptFeature.cs @@ -0,0 +1,22 @@ +using Elsa.Expressions.JavaScript.Extensions; +using Elsa.Expressions.JavaScript.Features; +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.Secrets.Features; +using Elsa.Secrets.JavaScript.Scripting.JavaScript; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Secrets.JavaScript.Features; + +[DependsOn(typeof(JavaScriptFeature))] +[DependsOn(typeof(SecretsFeature))] +public class SecretsJavaScriptFeature(IModule module) : FeatureBase(module) +{ + public override void Apply() + { + Services + .AddNotificationHandler() + .AddFunctionDefinitionProvider(); + } +} diff --git a/src/modules/Elsa.Secrets.JavaScript/FodyWeavers.xml b/src/modules/Elsa.Secrets.JavaScript/FodyWeavers.xml new file mode 100644 index 000000000..8099e287a --- /dev/null +++ b/src/modules/Elsa.Secrets.JavaScript/FodyWeavers.xml @@ -0,0 +1,4 @@ + + + + diff --git a/src/modules/Elsa.Secrets.JavaScript/Scripting/JavaScript/SecretsJavaScriptHandler.cs b/src/modules/Elsa.Secrets.JavaScript/Scripting/JavaScript/SecretsJavaScriptHandler.cs new file mode 100644 index 000000000..a6f79e232 --- /dev/null +++ b/src/modules/Elsa.Secrets.JavaScript/Scripting/JavaScript/SecretsJavaScriptHandler.cs @@ -0,0 +1,27 @@ +using Elsa.Expressions.JavaScript.Notifications; +using Elsa.Expressions.JavaScript.TypeDefinitions.Abstractions; +using Elsa.Expressions.JavaScript.TypeDefinitions.Models; +using Elsa.Mediator.Contracts; +using Elsa.Secrets.Contracts; +using JetBrains.Annotations; + +namespace Elsa.Secrets.JavaScript.Scripting.JavaScript; + +[UsedImplicitly] +public class SecretsJavaScriptHandler(ISecretResolver secretResolver) : FunctionDefinitionProvider, INotificationHandler +{ + public Task HandleAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken) + { + var context = notification.Context; + notification.Engine.SetValue("getSecret", (Func>)(name => secretResolver.ResolveAsync(name, context.CancellationToken))); + return Task.CompletedTask; + } + + protected override IEnumerable GetFunctionDefinitions(TypeDefinitionContext context) + { + yield return CreateFunctionDefinition(function => function + .Name("getSecret") + .ReturnType("Promise") + .Parameter("name", "string")); + } +} diff --git a/src/modules/Elsa.Secrets.JavaScript/ShellFeatures/SecretsJavaScriptFeature.cs b/src/modules/Elsa.Secrets.JavaScript/ShellFeatures/SecretsJavaScriptFeature.cs new file mode 100644 index 000000000..749244b4c --- /dev/null +++ b/src/modules/Elsa.Secrets.JavaScript/ShellFeatures/SecretsJavaScriptFeature.cs @@ -0,0 +1,24 @@ +using CShells.Features; +using Elsa.Expressions.JavaScript.Extensions; +using Elsa.Expressions.JavaScript.ShellFeatures; +using Elsa.Secrets.JavaScript.Scripting.JavaScript; +using Elsa.Secrets.ShellFeatures; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Secrets.JavaScript.ShellFeatures; + +[ShellFeature( + DisplayName = "Secrets JavaScript", + Description = "Provides JavaScript expression functions for resolving Elsa secrets.", + DependsOn = [typeof(JavaScriptFeature), typeof(SecretsFeature)])] +[UsedImplicitly] +public class SecretsJavaScriptFeature : IShellFeature +{ + public void ConfigureServices(IServiceCollection services) + { + services + .AddNotificationHandler() + .AddFunctionDefinitionProvider(); + } +} diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj b/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj index b7f1ee64d..90ec04f35 100644 --- a/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj +++ b/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj @@ -8,6 +8,7 @@ + diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/SecretsJavaScriptTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/SecretsJavaScriptTests.cs new file mode 100644 index 000000000..85563669b --- /dev/null +++ b/test/integration/Elsa.JavaScript.IntegrationTests/SecretsJavaScriptTests.cs @@ -0,0 +1,108 @@ +using Elsa.Expressions.JavaScript.Contracts; +using Elsa.Expressions.JavaScript.TypeDefinitions.Contracts; +using Elsa.Expressions.JavaScript.TypeDefinitions.Models; +using Elsa.Extensions; +using Elsa.Features.Services; +using Elsa.Secrets.Contracts; +using Elsa.Secrets.Models; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.JavaScript.IntegrationTests; + +public class SecretsJavaScriptTests +{ + private readonly TestSecretResolver _secretResolver = new(); + private readonly WorkflowTestFixture _fixture; + + public SecretsJavaScriptTests(ITestOutputHelper testOutputHelper) + { + _fixture = new(testOutputHelper); + _fixture + .ConfigureServices(services => services.AddSingleton(_secretResolver)) + .ConfigureElsa(ConfigureElsa); + } + + [Fact] + public async Task GetSecret_ReturnsPromiseCompatibleSecretValue() + { + var result = await EvaluateScriptAsync("return getSecret('api:key');"); + + Assert.Equal("secret-value", result); + Assert.Equal(["api:key"], _secretResolver.ResolvedNames); + } + + [Fact] + public async Task GetSecret_ComposesWithThen() + { + var result = await EvaluateScriptAsync("return getSecret('api:key').then(value => value + '-suffix');"); + + Assert.Equal("secret-value-suffix", result); + } + + [Fact] + public async Task GetSecret_ComposesWithAsyncIife() + { + var result = await EvaluateScriptAsync("return (async () => await getSecret('api:key'))();"); + + Assert.Equal("secret-value", result); + } + + [Fact] + public async Task TypeDefinitions_DocumentGetSecretAsPromiseOfString() + { + var typeDefinitions = await GenerateTypeDefinitionsAsync(); + + Assert.Contains("declare function getSecret(name: string): Promise;", typeDefinitions); + } + + private static void ConfigureElsa(IModule module) + { + module.UseSecretsJavaScript(); + } + + private async Task EvaluateScriptAsync(string script) + { + var context = await _fixture.CreateExpressionExecutionContextAsync(); + var evaluator = _fixture.Services.GetRequiredService(); + var result = await evaluator.EvaluateAsync(script, typeof(T), context); + + return result is T typedResult ? typedResult : (T)Convert.ChangeType(result, typeof(T))!; + } + + private async Task GenerateTypeDefinitionsAsync() + { + await _fixture.BuildAsync(); + var workflow = new Workflow(); + var workflowGraphBuilder = _fixture.Services.GetRequiredService(); + var workflowGraph = await workflowGraphBuilder.BuildAsync(workflow); + var typeDefinitionContext = new TypeDefinitionContext(workflowGraph, null, null, CancellationToken.None); + var typeDefinitionService = _fixture.Services.GetRequiredService(); + + return await typeDefinitionService.GenerateTypeDefinitionsAsync(typeDefinitionContext); + } + + private class TestSecretResolver : ISecretResolver + { + private readonly Dictionary _secrets = new(StringComparer.OrdinalIgnoreCase) + { + ["api:key"] = "secret-value" + }; + + public List ResolvedNames { get; } = []; + + public Task ResolveAsync(string name, CancellationToken cancellationToken = default) + { + ResolvedNames.Add(name); + return Task.FromResult(_secrets[name]); + } + + public Task ResolveAsync(SecretReference reference, CancellationToken cancellationToken = default) + { + return ResolveAsync(reference.Name, cancellationToken); + } + } +}