Add Secret expression runtime

This commit is contained in:
Sipke Schoorstra 2026-06-01 09:20:55 +02:00
parent d84d83f828
commit b438551c78
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
7 changed files with 285 additions and 0 deletions

View file

@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Api.Common\Elsa.Api.Common.csproj" />
<ProjectReference Include="..\Elsa.Expressions\Elsa.Expressions.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,19 @@
using Elsa.Expressions.Models;
namespace Elsa.Secrets.Expressions;
/// <summary>
/// Creates Secret expressions that store references to named secrets.
/// </summary>
public static class SecretExpression
{
/// <summary>
/// The Secret expression type name.
/// </summary>
public const string TypeName = "Secret";
/// <summary>
/// Creates a Secret expression for the specified reference.
/// </summary>
public static Expression Create(SecretReference reference) => new(TypeName, reference);
}

View file

@ -0,0 +1,24 @@
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Helpers;
using Elsa.Expressions.Models;
namespace Elsa.Secrets.Expressions;
/// <summary>
/// Resolves Secret expressions through the configured secret resolver.
/// </summary>
public class SecretExpressionHandler(ISecretResolver secretResolver, IWellKnownTypeRegistry wellKnownTypeRegistry) : IExpressionHandler
{
/// <inheritdoc />
public async ValueTask<object?> EvaluateAsync(Expression expression, Type returnType, ExpressionExecutionContext context, ExpressionEvaluatorOptions options)
{
if (expression.Value is not SecretReference reference)
throw new InvalidOperationException("Secret expression value must be a SecretReference.");
if (string.IsNullOrWhiteSpace(reference.Name))
throw new InvalidOperationException("Secret expression reference must specify a secret name.");
var value = await secretResolver.ResolveAsync(reference, context.CancellationToken);
return value.ConvertTo(returnType, new ObjectConverterOptions(WellKnownTypeRegistry: wellKnownTypeRegistry));
}
}

View file

@ -1,3 +1,5 @@
using Elsa.Expressions.Contracts;
using Elsa.Secrets.Providers;
using Elsa.Secrets.Repositories;
using Elsa.Secrets.Services;
using Elsa.Secrets.Stores;
@ -22,6 +24,7 @@ public static class ServiceCollectionExtensions
services.TryAddSingleton<ISecretResolver, DefaultSecretResolver>();
services.TryAddSingleton<ISecretStoreRegistry, SecretStoreRegistry>();
services.TryAddSingleton<ISecretTypeRegistry, SecretTypeRegistry>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IExpressionDescriptorProvider, SecretExpressionDescriptorProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISecretStore, EncryptedSecretStore>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISecretStore, ConfigurationSecretStore>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISecretTypeProvider, TextSecretTypeProvider>());

View file

@ -0,0 +1,44 @@
using System.Text.Json;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
using Elsa.Secrets.Expressions;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Secrets.Providers;
/// <summary>
/// Provides the Secret expression descriptor.
/// </summary>
public class SecretExpressionDescriptorProvider : IExpressionDescriptorProvider
{
private const string SecretPickerUIHint = "secret-picker";
private const string SecretPickerEndpoint = "/secrets/picker";
/// <inheritdoc />
public IEnumerable<ExpressionDescriptor> GetDescriptors()
{
yield return new()
{
Type = SecretExpression.TypeName,
DisplayName = "Secret",
HandlerFactory = ActivatorUtilities.GetServiceOrCreateInstance<SecretExpressionHandler>,
Properties = new Dictionary<string, object>
{
["UIHint"] = SecretPickerUIHint,
["PickerEndpoint"] = SecretPickerEndpoint
},
Deserialize = Deserialize
};
}
private static Expression Deserialize(ExpressionSerializationContext context)
{
var valueElement = context.JsonElement.TryGetProperty("value", out var v) ? v : default;
if (valueElement.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
return new Expression(SecretExpression.TypeName, null);
var reference = valueElement.Deserialize<SecretReference>(context.Options);
return new Expression(SecretExpression.TypeName, reference);
}
}

View file

@ -7,6 +7,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\modules\Elsa.Secrets\Elsa.Secrets.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,193 @@
using System.Text.Json;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
using Elsa.Expressions.Options;
using Elsa.Expressions.Services;
using Elsa.Secrets.Contracts;
using Elsa.Secrets.Expressions;
using Elsa.Secrets.Extensions;
using Elsa.Secrets.Models;
using Elsa.Secrets.Providers;
using Elsa.Workflows.Models;
using Elsa.Workflows.Serialization.Converters;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Elsa.Secrets.UnitTests;
public class SecretExpressionTests
{
private readonly SecretTestFixture _fixture = new();
private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry = new WellKnownTypeRegistry(Microsoft.Extensions.Options.Options.Create(new ExpressionOptions()));
private readonly SecretExpressionHandler _handler;
public SecretExpressionTests()
{
_handler = new(_fixture.Resolver, _wellKnownTypeRegistry);
}
[Fact]
public async Task EvaluateAsync_ResolvesSecretReference()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", Value = "top-secret" });
var result = await EvaluateAsync<string>(new("api:key"));
Assert.Equal("top-secret", result);
}
[Fact]
public async Task EvaluateAsync_Throws_WhenSecretIsMissing()
{
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => EvaluateAsync<string>(new("api:key")));
Assert.Equal("Secret 'api:key' was not found.", exception.Message);
}
[Fact]
public async Task EvaluateAsync_Throws_WhenSecretTypeDoesNotMatchReference()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", TypeName = SecretTypeNames.Text, Value = "top-secret" });
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => EvaluateAsync<string>(new("api:key", SecretTypeNames.RsaKey)));
Assert.Equal("Secret 'api:key' is not compatible with required type 'rsa-key'.", exception.Message);
}
[Fact]
public async Task EvaluateAsync_Throws_WhenSecretScopeDoesNotMatchReference()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", Scope = "production", Value = "top-secret" });
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => EvaluateAsync<string>(new("api:key", Scope: "development")));
Assert.Equal("Secret 'api:key' is not compatible with required scope 'development'.", exception.Message);
}
[Fact]
public async Task EvaluateAsync_PassesCancellationTokenToResolver()
{
using var cancellationTokenSource = new CancellationTokenSource();
var resolver = new CapturingSecretResolver("top-secret");
var handler = new SecretExpressionHandler(resolver, _wellKnownTypeRegistry);
var context = CreateContext(cancellationTokenSource.Token);
await handler.EvaluateAsync(SecretExpression.Create(new("api:key")), typeof(string), context, ExpressionEvaluatorOptions.Empty);
Assert.Equal(cancellationTokenSource.Token, resolver.CancellationToken);
}
[Fact]
public void SecretExpression_RoundTripsAsSecretReference()
{
var options = CreateSerializerOptions();
var expression = SecretExpression.Create(new("api:key", SecretTypeNames.Text, "production"));
var json = JsonSerializer.Serialize(expression, options);
var deserializedExpression = JsonSerializer.Deserialize<Expression>(json, options)!;
var deserializedReference = Assert.IsType<SecretReference>(deserializedExpression.Value);
Assert.Contains("\"type\":\"Secret\"", json);
Assert.Contains("\"name\":\"api:key\"", json);
Assert.Contains("\"typeName\":\"text\"", json);
Assert.Contains("\"scope\":\"production\"", json);
Assert.DoesNotContain("top-secret", json);
Assert.Equal(SecretExpression.TypeName, deserializedExpression.Type);
Assert.Equal(new SecretReference("api:key", SecretTypeNames.Text, "production"), deserializedReference);
}
[Fact]
public void WorkflowInputJson_StoresSecretReferenceNotSecretValue()
{
var options = CreateSerializerOptions();
var input = new Input<string>(SecretExpression.Create(new("api:key", SecretTypeNames.Text, "production")));
var json = JsonSerializer.Serialize(input, options);
var deserializedInput = JsonSerializer.Deserialize<Input<string>>(json, options)!;
var deserializedReference = Assert.IsType<SecretReference>(deserializedInput.Expression!.Value);
Assert.Contains("\"expression\":{\"type\":\"Secret\"", json);
Assert.Contains("\"value\":{\"name\":\"api:key\"", json);
Assert.DoesNotContain("top-secret", json);
Assert.Equal(new SecretReference("api:key", SecretTypeNames.Text, "production"), deserializedReference);
}
[Fact]
public void AddSecretsServices_RegistersSecretExpressionDescriptorProvider()
{
var services = new ServiceCollection();
services.AddSecretsServices();
var serviceProvider = services.BuildServiceProvider();
var provider = serviceProvider.GetServices<IExpressionDescriptorProvider>().Single(x => x is SecretExpressionDescriptorProvider);
var descriptor = provider.GetDescriptors().Single();
Assert.Equal(SecretExpression.TypeName, descriptor.Type);
Assert.Equal("secret-picker", descriptor.Properties["UIHint"]);
Assert.Equal("/secrets/picker", descriptor.Properties["PickerEndpoint"]);
}
private async Task<T?> EvaluateAsync<T>(SecretReference reference)
{
var expression = SecretExpression.Create(reference);
var context = CreateContext();
return (T?)await _handler.EvaluateAsync(expression, typeof(T), context, ExpressionEvaluatorOptions.Empty);
}
private static ExpressionExecutionContext CreateContext(CancellationToken cancellationToken = default)
{
return new(new ServiceCollection().BuildServiceProvider(), new MemoryRegister(), cancellationToken: cancellationToken);
}
private static JsonSerializerOptions CreateSerializerOptions()
{
var registry = new TestExpressionDescriptorRegistry(new SecretExpressionDescriptorProvider().GetDescriptors());
var serviceProvider = new ServiceCollection()
.AddSingleton<IExpressionDescriptorRegistry>(registry)
.BuildServiceProvider();
return new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters =
{
new TypeJsonConverter(),
new ExpressionJsonConverterFactory(registry),
new InputJsonConverterFactory(serviceProvider)
}
};
}
private class CapturingSecretResolver(string value) : ISecretResolver
{
public CancellationToken CancellationToken { get; private set; }
public Task<string> ResolveAsync(string name, CancellationToken cancellationToken = default) => ResolveAsync(new SecretReference(name), cancellationToken);
public Task<string> ResolveAsync(SecretReference reference, CancellationToken cancellationToken = default)
{
CancellationToken = cancellationToken;
return Task.FromResult(value);
}
}
private class TestExpressionDescriptorRegistry(IEnumerable<ExpressionDescriptor> descriptors) : IExpressionDescriptorRegistry
{
private readonly Dictionary<string, ExpressionDescriptor> _descriptors = descriptors.ToDictionary(x => x.Type);
public void Add(ExpressionDescriptor descriptor) => _descriptors[descriptor.Type] = descriptor;
public void AddRange(IEnumerable<ExpressionDescriptor> descriptors)
{
foreach (var descriptor in descriptors)
Add(descriptor);
}
public IEnumerable<ExpressionDescriptor> ListAll() => _descriptors.Values;
public ExpressionDescriptor? Find(Func<ExpressionDescriptor, bool> predicate) => _descriptors.Values.FirstOrDefault(predicate);
public ExpressionDescriptor? Find(string type) => _descriptors.GetValueOrDefault(type);
}
}