diff --git a/src/modules/Elsa.Secrets/Elsa.Secrets.csproj b/src/modules/Elsa.Secrets/Elsa.Secrets.csproj index 620ebdea0..ee8a03d51 100644 --- a/src/modules/Elsa.Secrets/Elsa.Secrets.csproj +++ b/src/modules/Elsa.Secrets/Elsa.Secrets.csproj @@ -14,6 +14,7 @@ + diff --git a/src/modules/Elsa.Secrets/Expressions/SecretExpression.cs b/src/modules/Elsa.Secrets/Expressions/SecretExpression.cs new file mode 100644 index 000000000..e41403685 --- /dev/null +++ b/src/modules/Elsa.Secrets/Expressions/SecretExpression.cs @@ -0,0 +1,19 @@ +using Elsa.Expressions.Models; + +namespace Elsa.Secrets.Expressions; + +/// +/// Creates Secret expressions that store references to named secrets. +/// +public static class SecretExpression +{ + /// + /// The Secret expression type name. + /// + public const string TypeName = "Secret"; + + /// + /// Creates a Secret expression for the specified reference. + /// + public static Expression Create(SecretReference reference) => new(TypeName, reference); +} diff --git a/src/modules/Elsa.Secrets/Expressions/SecretExpressionHandler.cs b/src/modules/Elsa.Secrets/Expressions/SecretExpressionHandler.cs new file mode 100644 index 000000000..7f7d136f9 --- /dev/null +++ b/src/modules/Elsa.Secrets/Expressions/SecretExpressionHandler.cs @@ -0,0 +1,24 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Helpers; +using Elsa.Expressions.Models; + +namespace Elsa.Secrets.Expressions; + +/// +/// Resolves Secret expressions through the configured secret resolver. +/// +public class SecretExpressionHandler(ISecretResolver secretResolver, IWellKnownTypeRegistry wellKnownTypeRegistry) : IExpressionHandler +{ + /// + public async ValueTask 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)); + } +} diff --git a/src/modules/Elsa.Secrets/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Secrets/Extensions/ServiceCollectionExtensions.cs index 5762d0977..396a263ed 100644 --- a/src/modules/Elsa.Secrets/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Secrets/Extensions/ServiceCollectionExtensions.cs @@ -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(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); diff --git a/src/modules/Elsa.Secrets/Providers/SecretExpressionDescriptorProvider.cs b/src/modules/Elsa.Secrets/Providers/SecretExpressionDescriptorProvider.cs new file mode 100644 index 000000000..d630f8b4e --- /dev/null +++ b/src/modules/Elsa.Secrets/Providers/SecretExpressionDescriptorProvider.cs @@ -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; + +/// +/// Provides the Secret expression descriptor. +/// +public class SecretExpressionDescriptorProvider : IExpressionDescriptorProvider +{ + private const string SecretPickerUIHint = "secret-picker"; + private const string SecretPickerEndpoint = "/secrets/picker"; + + /// + public IEnumerable GetDescriptors() + { + yield return new() + { + Type = SecretExpression.TypeName, + DisplayName = "Secret", + HandlerFactory = ActivatorUtilities.GetServiceOrCreateInstance, + Properties = new Dictionary + { + ["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(context.Options); + return new Expression(SecretExpression.TypeName, reference); + } +} diff --git a/test/unit/Elsa.Secrets.UnitTests/Elsa.Secrets.UnitTests.csproj b/test/unit/Elsa.Secrets.UnitTests/Elsa.Secrets.UnitTests.csproj index ba0648354..3e4a4d247 100644 --- a/test/unit/Elsa.Secrets.UnitTests/Elsa.Secrets.UnitTests.csproj +++ b/test/unit/Elsa.Secrets.UnitTests/Elsa.Secrets.UnitTests.csproj @@ -7,6 +7,7 @@ + diff --git a/test/unit/Elsa.Secrets.UnitTests/SecretExpressionTests.cs b/test/unit/Elsa.Secrets.UnitTests/SecretExpressionTests.cs new file mode 100644 index 000000000..afafeb3d8 --- /dev/null +++ b/test/unit/Elsa.Secrets.UnitTests/SecretExpressionTests.cs @@ -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(new("api:key")); + + Assert.Equal("top-secret", result); + } + + [Fact] + public async Task EvaluateAsync_Throws_WhenSecretIsMissing() + { + var exception = await Assert.ThrowsAsync(() => EvaluateAsync(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(() => EvaluateAsync(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(() => EvaluateAsync(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(json, options)!; + var deserializedReference = Assert.IsType(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(SecretExpression.Create(new("api:key", SecretTypeNames.Text, "production"))); + + var json = JsonSerializer.Serialize(input, options); + var deserializedInput = JsonSerializer.Deserialize>(json, options)!; + var deserializedReference = Assert.IsType(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().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 EvaluateAsync(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(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 ResolveAsync(string name, CancellationToken cancellationToken = default) => ResolveAsync(new SecretReference(name), cancellationToken); + + public Task ResolveAsync(SecretReference reference, CancellationToken cancellationToken = default) + { + CancellationToken = cancellationToken; + return Task.FromResult(value); + } + } + + private class TestExpressionDescriptorRegistry(IEnumerable descriptors) : IExpressionDescriptorRegistry + { + private readonly Dictionary _descriptors = descriptors.ToDictionary(x => x.Type); + + public void Add(ExpressionDescriptor descriptor) => _descriptors[descriptor.Type] = descriptor; + + public void AddRange(IEnumerable descriptors) + { + foreach (var descriptor in descriptors) + Add(descriptor); + } + + public IEnumerable ListAll() => _descriptors.Values; + + public ExpressionDescriptor? Find(Func predicate) => _descriptors.Values.FirstOrDefault(predicate); + + public ExpressionDescriptor? Find(string type) => _descriptors.GetValueOrDefault(type); + } +}