diff --git a/src/clients/Elsa.Api.Client/Resources/ActivityDescriptors/Models/InputDescriptor.cs b/src/clients/Elsa.Api.Client/Resources/ActivityDescriptors/Models/InputDescriptor.cs index d19089503..e143a6af2 100644 --- a/src/clients/Elsa.Api.Client/Resources/ActivityDescriptors/Models/InputDescriptor.cs +++ b/src/clients/Elsa.Api.Client/Resources/ActivityDescriptors/Models/InputDescriptor.cs @@ -43,6 +43,11 @@ public class InputDescriptor : PropertyDescriptor /// public bool? IsReadOnly { get; set; } + /// + /// True if the input can contain secrets and should be treated as sensitive. + /// + public bool IsSensitive { get; set; } + /// /// The storage driver type to use for persistence. /// If no driver is specified, the referenced memory block will remain in memory for as long as the expression execution context exists. @@ -53,4 +58,4 @@ public class InputDescriptor : PropertyDescriptor /// A dictionary of UI specifications to be used by the UI. /// public IDictionary? UISpecifications { get; set; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs index 4bdbd64e6..bf07ae9ac 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs @@ -143,6 +143,12 @@ public static partial class ActivityExecutionContextExtensions private static Task StoreInputValueAsync(ActivityExecutionContext context, InputDescriptor inputDescriptor, object value) { + if (inputDescriptor.IsSensitive) + { + context.ActivityState.Remove(inputDescriptor.Name); + return Task.CompletedTask; + } + // Store the serialized input value in the activity state. // Serializing the value ensures we store a copy of the value and not a reference to the input, which may change over time. if (inputDescriptor.IsSerializable != false) @@ -157,4 +163,4 @@ public static partial class ActivityExecutionContextExtensions return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs index 8498bc4eb..3450b5c29 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs @@ -164,7 +164,10 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve null, propertyInfo, uiSpecification - ); + ) + { + IsSensitive = inputAttribute?.CanContainSecrets ?? false + }; } /// diff --git a/src/modules/Elsa.Workflows.Management/Services/HostMethodActivityDescriber.cs b/src/modules/Elsa.Workflows.Management/Services/HostMethodActivityDescriber.cs index f496e33a3..b57735d18 100644 --- a/src/modules/Elsa.Workflows.Management/Services/HostMethodActivityDescriber.cs +++ b/src/modules/Elsa.Workflows.Management/Services/HostMethodActivityDescriber.cs @@ -146,7 +146,8 @@ public class HostMethodActivityDescriber(IActivityDescriber activityDescriber) : Order = inputAttribute?.Order ?? 0, IsBrowsable = inputAttribute?.IsBrowsable ?? true, AutoEvaluate = inputAttribute?.AutoEvaluate ?? true, - IsSerializable = inputAttribute?.IsSerializable ?? true + IsSerializable = inputAttribute?.IsSerializable ?? true, + IsSensitive = inputAttribute?.CanContainSecrets ?? false }; } @@ -176,7 +177,8 @@ public class HostMethodActivityDescriber(IActivityDescriber activityDescriber) : Order = inputAttribute?.Order ?? 0, IsBrowsable = inputAttribute?.IsBrowsable ?? true, AutoEvaluate = inputAttribute?.AutoEvaluate ?? true, - IsSerializable = inputAttribute?.IsSerializable ?? true + IsSerializable = inputAttribute?.IsSerializable ?? true, + IsSensitive = inputAttribute?.CanContainSecrets ?? false }; } @@ -243,4 +245,4 @@ public class HostMethodActivityDescriber(IActivityDescriber activityDescriber) : return true; } -} \ No newline at end of file +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/InputEvaluationTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/InputEvaluationTests.cs new file mode 100644 index 000000000..d3ac82ec8 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/InputEvaluationTests.cs @@ -0,0 +1,57 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Xunit; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class InputEvaluationTests +{ + private readonly ActivityWithSensitiveInputs _activity = new(); + private readonly ActivityTestFixture _fixture; + + public InputEvaluationTests() + { + _fixture = new(_activity); + } + + [Fact] + public async Task EvaluateInputPropertiesAsync_WhenInputIsSensitive_RemovesItFromActivityState() + { + _fixture.ConfigureContext(context => context.ActivityState[nameof(ActivityWithSensitiveInputs.SensitiveText)] = "stale-secret"); + + var context = await ActivateAsync(); + + Assert.False(context.ActivityState.ContainsKey(nameof(ActivityWithSensitiveInputs.SensitiveText))); + Assert.Equal("secret", _activity.CapturedSensitiveText); + } + + [Fact] + public async Task EvaluateInputPropertiesAsync_WhenInputIsNotSensitive_StoresItInActivityState() + { + var context = await ActivateAsync(); + + Assert.Equal("public", context.ActivityState[nameof(ActivityWithSensitiveInputs.PublicText)]); + Assert.Equal("public", _activity.CapturedPublicText); + } + + private Task ActivateAsync() => _fixture.ExecuteAsync(); + + private sealed class ActivityWithSensitiveInputs : CodeActivity + { + [Input(CanContainSecrets = true)] + public Input SensitiveText { get; set; } = new("secret"); + + [Input] + public Input PublicText { get; set; } = new("public"); + + public string? CapturedSensitiveText { get; private set; } + public string? CapturedPublicText { get; private set; } + + protected override void Execute(ActivityExecutionContext context) + { + CapturedSensitiveText = context.Get(SensitiveText); + CapturedPublicText = context.Get(PublicText); + } + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityDescriberTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityDescriberTests.cs new file mode 100644 index 000000000..328598c27 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityDescriberTests.cs @@ -0,0 +1,46 @@ +using System.Reflection; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using NSubstitute; +using Xunit; + +namespace Elsa.Workflows.Core.UnitTests.Services; + +public class ActivityDescriberTests +{ + private readonly IActivityDescriber _describer; + + public ActivityDescriberTests() + { + var defaultValueResolver = Substitute.For(); + var propertyUIHandlerResolver = Substitute.For(); + + defaultValueResolver.GetDefaultValue(Arg.Any()).Returns((object?)null); + propertyUIHandlerResolver + .GetUIPropertiesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => new ValueTask>(new Dictionary())); + + _describer = new ActivityDescriber(defaultValueResolver, propertyUIHandlerResolver); + } + + [Fact] + public async Task DescribeActivityAsync_MapsCanContainSecretsToInputDescriptorSensitivity() + { + var descriptor = await _describer.DescribeActivityAsync(typeof(ActivityWithSensitiveInputs)); + + var sensitiveInput = descriptor.Inputs.Single(x => x.Name == nameof(ActivityWithSensitiveInputs.SensitiveText)); + var publicInput = descriptor.Inputs.Single(x => x.Name == nameof(ActivityWithSensitiveInputs.PublicText)); + + Assert.True(sensitiveInput.IsSensitive); + Assert.False(publicInput.IsSensitive); + } + + private sealed class ActivityWithSensitiveInputs : CodeActivity + { + [Input(CanContainSecrets = true)] + public Input SensitiveText { get; set; } = new("secret"); + + [Input] + public Input PublicText { get; set; } = new("public"); + } +}