From bbedd61138cdf731ad84929cd8ce5517cb6ab29b Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 2 Oct 2024 09:11:35 +0200 Subject: [PATCH] Implement Activity State Filtering and JavaScript Integration (#5993) * Add secret scripting integration for JavaScript Introduced a new `Elsa.Secrets.Scripting` module that provides secret management capabilities within JavaScript workflows. This includes configuring the Jint engine to use workflow variables, adding new type and variable definition providers, and integrating with existing secret management features. * Refactor secret name extraction to a separate method Moved the logic for extracting secret names from the main method to a dedicated private method `GetSecretNamesFromExpression`. This improves code readability and maintains the single responsibility principle by delegating secret name extraction to its own method. * Add input evaluation, sensitive input handling, and middleware refactor Introduced methods for evaluating activity input properties and handling inputs marked as sensitive. Refactored `ExecutionLogMiddleware` constructor for consistency. Enhanced `SendHttpRequestBase` to mark authorization inputs as potentially containing secrets. Removed obsolete entries and adjusted persistence logic for clarity. * Refactor IActivityStateProtector interface Remove unused using directives and unnecessary comments. Simplify the definition of the `ProtectedActivityStateContext` record. * Add activity state filtering mechanism Introduce an abstract filter base class, context, and result models to enable filtering of activity state. Implement a default filter manager to run these filters and apply a specific filter for obfuscating HTTP request headers. Update necessary dependencies and extension methods to integrate the new filtering functionality. * Add expired secrets management Implemented services to manage expired secrets by periodically checking and updating their status. Introduced a new hosted service to perform the sweep and configurable options for the sweep interval. Updated related classes and configurations accordingly. * Update SweepInterval in appsettings.json Changed the Secrets Management SweepInterval from 30 seconds to 4 hours. This adjustment aims to reduce the frequency of sweep operations and improve overall system performance. * Update comment to reflect configuring engine with secrets The comment was changed to better describe the handler's function, specifying that it configures the Jint engine with secrets instead of workflow variables. This clarifies the purpose and usage of the handler in the context of the code. * Remove unused inputDescriptors variable This commit removes the inputDescriptors variable, which was declared but never used in DefaultActivityExecutionMapper.cs. This helps in cleaning up the code and potentially reducing memory usage. Ensuring that all declared variables are utilized can improve code readability and maintainability. --- Directory.Packages.props | 1 - Elsa.sln | 7 + .../Elsa.Server.Web/Elsa.Server.Web.csproj | 1 + .../HttpRequestAuthenticationHeaderFilter.cs | 30 ++++ src/apps/Elsa.Server.Web/Program.cs | 11 +- src/apps/Elsa.Server.Web/appsettings.json | 5 + .../Activities/SendHttpRequestBase.cs | 15 +- .../Notifications/EvaluatedJavaScript.cs | 2 +- .../Notifications/EvaluatingJavaScript.cs | 2 +- .../Services/JintJavaScriptEvaluator.cs | 4 +- .../VariableDefinitionProvider.cs | 2 +- .../Builders/VariableDefinitionBuilder.cs | 2 +- .../Endpoints/Secrets/Update/Endpoint.cs | 2 +- .../Contracts/IExpiredSecretsUpdater.cs | 6 + .../Contracts/ISecretManager.cs | 3 + .../Features/SecretManagementFeature.cs | 13 ++ .../ExpiredSecretsHostedService.cs | 54 +++++++ .../Models/SecretFilter.cs | 10 +- .../Options/SecretManagementOptions.cs | 9 ++ .../StoreEncryptionKeyProviderOptions.cs | 10 -- .../Services/DefaultExpiredSecretsUpdater.cs | 26 ++++ .../Services/DefaultSecretManager.cs | 20 ++- .../Services/DefaultSecretNameValidator.cs | 2 +- .../Services/DefaultSecretUpdater.cs | 13 +- .../Elsa.Secrets.Models/SecretStatus.cs | 16 +++ .../Elsa.Secrets.Scripting.csproj | 13 ++ .../Extensions/ModuleExtensions.cs | 14 ++ .../Features/SecretsScriptingFeature.cs | 23 +++ .../Elsa.Secrets.Scripting/FodyWeavers.xml | 3 + .../JavaScript/ConfigureEngineWithSecrets.cs | 67 +++++++++ .../SecretsTypeDefinitionProvider.cs | 49 +++++++ .../Abstractions/ActivityStateFilterBase.cs | 26 ++++ .../Attributes/InputAttribute.cs | 6 + .../Contexts/ActivityStateFilterContext.cs | 6 + .../Contracts/IActivityExecutionMiddleware.cs | 6 + .../Contracts/IActivityStateFilter.cs | 6 + .../Contracts/IActivityStateFilterManager.cs | 6 + .../Elsa.Workflows.Core.csproj.DotSettings | 1 + ...cutionContextExtensions.InputEvaluation.cs | 132 ++++++++++++++++++ .../ActivityExecutionContextExtensions.cs | 114 +-------------- .../Extensions/ModuleExtensions.cs | 7 - .../Extensions/ServiceCollectionExtensions.cs | 19 +++ .../Features/WorkflowsFeature.cs | 1 + .../Activities/ExecutionLogMiddleware.cs | 20 +-- .../Models/ActivityStateFilterResult.cs | 13 ++ .../Models/InputDescriptor.cs | 6 + .../DefaultActivityStateFilterManager.cs | 17 +++ .../DefaultActivityExecutionMapper.cs | 14 +- 48 files changed, 656 insertions(+), 179 deletions(-) create mode 100644 src/apps/Elsa.Server.Web/Filters/HttpRequestAuthenticationHeaderFilter.cs create mode 100644 src/modules/Elsa.Secrets.Management/Contracts/IExpiredSecretsUpdater.cs create mode 100644 src/modules/Elsa.Secrets.Management/HostedService/ExpiredSecretsHostedService.cs create mode 100644 src/modules/Elsa.Secrets.Management/Options/SecretManagementOptions.cs delete mode 100644 src/modules/Elsa.Secrets.Management/Options/StoreEncryptionKeyProviderOptions.cs create mode 100644 src/modules/Elsa.Secrets.Management/Services/DefaultExpiredSecretsUpdater.cs create mode 100644 src/modules/Elsa.Secrets.Scripting/Elsa.Secrets.Scripting.csproj create mode 100644 src/modules/Elsa.Secrets.Scripting/Extensions/ModuleExtensions.cs create mode 100644 src/modules/Elsa.Secrets.Scripting/Features/SecretsScriptingFeature.cs create mode 100644 src/modules/Elsa.Secrets.Scripting/FodyWeavers.xml create mode 100644 src/modules/Elsa.Secrets.Scripting/JavaScript/ConfigureEngineWithSecrets.cs create mode 100644 src/modules/Elsa.Secrets.Scripting/JavaScript/SecretsTypeDefinitionProvider.cs create mode 100644 src/modules/Elsa.Workflows.Core/Abstractions/ActivityStateFilterBase.cs create mode 100644 src/modules/Elsa.Workflows.Core/Contexts/ActivityStateFilterContext.cs create mode 100644 src/modules/Elsa.Workflows.Core/Contracts/IActivityStateFilter.cs create mode 100644 src/modules/Elsa.Workflows.Core/Contracts/IActivityStateFilterManager.cs create mode 100644 src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs create mode 100644 src/modules/Elsa.Workflows.Core/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/modules/Elsa.Workflows.Core/Models/ActivityStateFilterResult.cs create mode 100644 src/modules/Elsa.Workflows.Core/Services/DefaultActivityStateFilterManager.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 8be1c4833..3cbfdec57 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -95,7 +95,6 @@ - diff --git a/Elsa.sln b/Elsa.sln index 0a4b53ad0..b1d711eac 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -466,6 +466,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Agents.Persistence.Ent EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.Models", "src\modules\Elsa.Secrets.Models\Elsa.Secrets.Models.csproj", "{29D12ADC-55E9-40D0-9E4C-F0EBB6E098EC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.Scripting", "src\modules\Elsa.Secrets.Scripting\Elsa.Secrets.Scripting.csproj", "{6C606FEB-9A1F-4816-ABE4-22AFA8CEE771}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1058,6 +1060,10 @@ Global {29D12ADC-55E9-40D0-9E4C-F0EBB6E098EC}.Debug|Any CPU.Build.0 = Debug|Any CPU {29D12ADC-55E9-40D0-9E4C-F0EBB6E098EC}.Release|Any CPU.ActiveCfg = Release|Any CPU {29D12ADC-55E9-40D0-9E4C-F0EBB6E098EC}.Release|Any CPU.Build.0 = Release|Any CPU + {6C606FEB-9A1F-4816-ABE4-22AFA8CEE771}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C606FEB-9A1F-4816-ABE4-22AFA8CEE771}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C606FEB-9A1F-4816-ABE4-22AFA8CEE771}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C606FEB-9A1F-4816-ABE4-22AFA8CEE771}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1243,6 +1249,7 @@ Global {A9140976-DFF9-432A-B1DC-E188A07C49E5} = {50470834-4CD8-479A-8B58-0A1869BA5D37} {B3046301-6F00-4885-8B01-080BD489055C} = {50470834-4CD8-479A-8B58-0A1869BA5D37} {29D12ADC-55E9-40D0-9E4C-F0EBB6E098EC} = {8CEEC194-820A-4C8D-AB9E-E51E6D3E9CC1} + {6C606FEB-9A1F-4816-ABE4-22AFA8CEE771} = {8CEEC194-820A-4C8D-AB9E-E51E6D3E9CC1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj index f09ba8d52..d4e953462 100644 --- a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj +++ b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj @@ -12,6 +12,7 @@ + diff --git a/src/apps/Elsa.Server.Web/Filters/HttpRequestAuthenticationHeaderFilter.cs b/src/apps/Elsa.Server.Web/Filters/HttpRequestAuthenticationHeaderFilter.cs new file mode 100644 index 000000000..b6ee83937 --- /dev/null +++ b/src/apps/Elsa.Server.Web/Filters/HttpRequestAuthenticationHeaderFilter.cs @@ -0,0 +1,30 @@ +using Elsa.Http; +using Elsa.Workflows; +using JetBrains.Annotations; + +namespace Elsa.Server.Web.Filters; + +/// +/// Mask the value of the input if it is a HttpRequest and the input name is "Authorization". +/// +[UsedImplicitly] +public class HttpRequestAuthenticationHeaderFilter : ActivityStateFilterBase +{ + protected override ActivityStateFilterResult OnExecute(ActivityStateFilterContext context) + { + var activityExecutionContext = context.ActivityExecutionContext; + var activity = activityExecutionContext.Activity; + var inputDescriptor = context.InputDescriptor; + + if (activity is not SendHttpRequestBase || inputDescriptor.Name is not nameof(SendHttpRequestBase.Authorization)) + return ActivityStateFilterResult.Pass(); + + var contextValue = context.Value.GetString(); + + if (contextValue == null) + return ActivityStateFilterResult.Pass(); + + var maskedValue = new string('*', contextValue.Length); + return Filtered(maskedValue); + } +} \ No newline at end of file diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index 0c2b5f6cb..6b0c7c169 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -25,6 +25,7 @@ using Elsa.OpenTelemetry.Middleware; using Elsa.Secrets.Extensions; using Elsa.Secrets.Persistence; using Elsa.Server.Web; +using Elsa.Server.Web.Filters; using Elsa.Tenants.Extensions; using Elsa.Workflows; using Elsa.Workflows.Api; @@ -436,8 +437,13 @@ services { elsa .UseSecrets() - .UseSecretsManagement(management => management.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString))) + .UseSecretsManagement(management => + { + management.ConfigureOptions(options => configuration.GetSection("Secrets:Management").Bind(options)); + management.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString)); + }) .UseSecretsApi() + .UseSecretsScripting() ; } @@ -463,6 +469,9 @@ services ConfigureForTest?.Invoke(elsa); }); +// Obfuscate HTTP request headers. +services.AddActivityStateFilter(); + //services.Configure(options => options.CacheDuration = TimeSpan.FromDays(1)); services.AddHealthChecks(); services.AddControllers(); diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index 5f929c4c0..79393dcd1 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -173,6 +173,11 @@ } ] }, + "Secrets": { + "Management": { + "SweepInterval": "04:00:00" + } + }, "Agents": { "ApiKeys": [ { diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs index 645e8b00f..4fdaa3fcc 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs @@ -15,18 +15,12 @@ namespace Elsa.Http; /// Base class for activities that send HTTP requests. /// [Output(IsSerializable = false)] -public abstract class SendHttpRequestBase : Activity +public abstract class SendHttpRequestBase(string? source = default, int? line = default) : Activity(source, line) { - /// - protected SendHttpRequestBase(string? source = default, int? line = default) : base(source, line) - { - } - /// /// The URL to send the request to. /// - [Input] - public Input Url { get; set; } = default!; + [Input] public Input Url { get; set; } = default!; /// /// The HTTP method to use when sending the request. @@ -62,7 +56,10 @@ public abstract class SendHttpRequestBase : Activity /// The Authorization header value to send with the request. /// /// Bearer {some-access-token} - [Input(Description = "The Authorization header value to send with the request. For example: Bearer {some-access-token}", Category = "Security")] + [Input( + Description = "The Authorization header value to send with the request. For example: Bearer {some-access-token}", + Category = "Security", + CanContainSecrets = true)] public Input Authorization { get; set; } = default!; /// diff --git a/src/modules/Elsa.JavaScript/Notifications/EvaluatedJavaScript.cs b/src/modules/Elsa.JavaScript/Notifications/EvaluatedJavaScript.cs index 71f980e79..b05bea82a 100644 --- a/src/modules/Elsa.JavaScript/Notifications/EvaluatedJavaScript.cs +++ b/src/modules/Elsa.JavaScript/Notifications/EvaluatedJavaScript.cs @@ -7,4 +7,4 @@ namespace Elsa.JavaScript.Notifications; /// /// This notification is published every time a JavaScript expression has been evaluated. /// -public record EvaluatedJavaScript(Engine Engine, ExpressionExecutionContext Context, object? Result) : INotification; \ No newline at end of file +public record EvaluatedJavaScript(Engine Engine, ExpressionExecutionContext Context, string Expression, object? Result) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/Notifications/EvaluatingJavaScript.cs b/src/modules/Elsa.JavaScript/Notifications/EvaluatingJavaScript.cs index 799f4cc31..98b6c85dd 100644 --- a/src/modules/Elsa.JavaScript/Notifications/EvaluatingJavaScript.cs +++ b/src/modules/Elsa.JavaScript/Notifications/EvaluatingJavaScript.cs @@ -8,4 +8,4 @@ namespace Elsa.JavaScript.Notifications; /// This notification is published every time a JavaScript expression is about to be evaluated. /// It gives subscribers a chance to configure the with additional functions and variables. /// -public record EvaluatingJavaScript(Engine Engine, ExpressionExecutionContext Context) : INotification; \ No newline at end of file +public record EvaluatingJavaScript(Engine Engine, ExpressionExecutionContext Context, string Expression) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs index 65d2a80e9..b9a9e65f7 100644 --- a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs +++ b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs @@ -37,9 +37,9 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification CancellationToken cancellationToken = default) { var engine = await GetConfiguredEngine(configureEngine, context, options, cancellationToken); - await mediator.SendAsync(new EvaluatingJavaScript(engine, context), cancellationToken); + await mediator.SendAsync(new EvaluatingJavaScript(engine, context, expression), cancellationToken); var result = ExecuteExpressionAndGetResult(engine, expression); - await mediator.SendAsync(new EvaluatedJavaScript(engine, context, result), cancellationToken); + await mediator.SendAsync(new EvaluatedJavaScript(engine, context, expression, result), cancellationToken); return result.ConvertTo(returnType); } diff --git a/src/modules/Elsa.JavaScript/TypeDefinitions/Abstractions/VariableDefinitionProvider.cs b/src/modules/Elsa.JavaScript/TypeDefinitions/Abstractions/VariableDefinitionProvider.cs index 6a4bf63ee..6d4771f3b 100644 --- a/src/modules/Elsa.JavaScript/TypeDefinitions/Abstractions/VariableDefinitionProvider.cs +++ b/src/modules/Elsa.JavaScript/TypeDefinitions/Abstractions/VariableDefinitionProvider.cs @@ -23,6 +23,6 @@ public abstract class VariableDefinitionProvider : IVariableDefinitionProvider { var builder = new VariableDefinitionBuilder(); setup(builder); - return builder.BuildVariableDefinition(); + return builder.Build(); } } \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/TypeDefinitions/Builders/VariableDefinitionBuilder.cs b/src/modules/Elsa.JavaScript/TypeDefinitions/Builders/VariableDefinitionBuilder.cs index bddfc8b74..fcec19810 100644 --- a/src/modules/Elsa.JavaScript/TypeDefinitions/Builders/VariableDefinitionBuilder.cs +++ b/src/modules/Elsa.JavaScript/TypeDefinitions/Builders/VariableDefinitionBuilder.cs @@ -18,5 +18,5 @@ public class VariableDefinitionBuilder return this; } - public VariableDefinition BuildVariableDefinition() => new(_variableDefinition.Name, _variableDefinition.Type); + public VariableDefinition Build() => new(_variableDefinition.Name, _variableDefinition.Type); } \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Api/Endpoints/Secrets/Update/Endpoint.cs b/src/modules/Elsa.Secrets.Api/Endpoints/Secrets/Update/Endpoint.cs index 1997a60c8..2bc5566ba 100644 --- a/src/modules/Elsa.Secrets.Api/Endpoints/Secrets/Update/Endpoint.cs +++ b/src/modules/Elsa.Secrets.Api/Endpoints/Secrets/Update/Endpoint.cs @@ -28,7 +28,7 @@ public class Endpoint(ISecretManager manager, ISecretNameValidator nameValidator return null!; } - var isNameDuplicate = !await nameValidator.IsNameUniqueAsync(req.Name, id, ct); + var isNameDuplicate = !await nameValidator.IsNameUniqueAsync(req.Name, entity.SecretId, ct); if (isNameDuplicate) { diff --git a/src/modules/Elsa.Secrets.Management/Contracts/IExpiredSecretsUpdater.cs b/src/modules/Elsa.Secrets.Management/Contracts/IExpiredSecretsUpdater.cs new file mode 100644 index 000000000..112f67f1a --- /dev/null +++ b/src/modules/Elsa.Secrets.Management/Contracts/IExpiredSecretsUpdater.cs @@ -0,0 +1,6 @@ +namespace Elsa.Secrets.Management; + +public interface IExpiredSecretsUpdater +{ + Task UpdateExpiredSecretsAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Management/Contracts/ISecretManager.cs b/src/modules/Elsa.Secrets.Management/Contracts/ISecretManager.cs index fac793b60..4b182ffd1 100644 --- a/src/modules/Elsa.Secrets.Management/Contracts/ISecretManager.cs +++ b/src/modules/Elsa.Secrets.Management/Contracts/ISecretManager.cs @@ -16,6 +16,9 @@ public interface ISecretManager /// Finds the entity from the store. Task FindAsync(SecretFilter filter, CancellationToken cancellationToken = default); + /// Finds all entities from the store matching the specified filter. + Task> FindManyAsync(SecretFilter filter, CancellationToken cancellationToken = default); + /// Gets all entities from the store. Task> ListAsync(CancellationToken cancellationToken = default); diff --git a/src/modules/Elsa.Secrets.Management/Features/SecretManagementFeature.cs b/src/modules/Elsa.Secrets.Management/Features/SecretManagementFeature.cs index 13380010a..0cc61cf5a 100644 --- a/src/modules/Elsa.Secrets.Management/Features/SecretManagementFeature.cs +++ b/src/modules/Elsa.Secrets.Management/Features/SecretManagementFeature.cs @@ -4,6 +4,7 @@ using Elsa.Features.Attributes; using Elsa.Features.Services; using Elsa.Secrets.Extensions; using Elsa.Secrets.Features; +using Elsa.Secrets.Management.HostedService; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Secrets.Management.Features; @@ -20,6 +21,12 @@ public class SecretManagementFeature(IModule module) : FeatureBase(module) _secretStoreFactory = secretStoreFactory; return this; } + + public SecretManagementFeature ConfigureOptions(Action configureOptions) + { + Services.Configure(configureOptions); + return this; + } public override void Configure() { @@ -29,6 +36,11 @@ public class SecretManagementFeature(IModule module) : FeatureBase(module) }); } + public override void ConfigureHostedServices() + { + ConfigureHostedService(); + } + public override void Apply() { Services @@ -43,6 +55,7 @@ public class SecretManagementFeature(IModule module) : FeatureBase(module) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() ; } } \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Management/HostedService/ExpiredSecretsHostedService.cs b/src/modules/Elsa.Secrets.Management/HostedService/ExpiredSecretsHostedService.cs new file mode 100644 index 000000000..93c8b3c8c --- /dev/null +++ b/src/modules/Elsa.Secrets.Management/HostedService/ExpiredSecretsHostedService.cs @@ -0,0 +1,54 @@ +using JetBrains.Annotations; +using Medallion.Threading; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Elsa.Secrets.Management.HostedService; + +[UsedImplicitly] +public class ExpiredSecretsHostedService(IOptions options, IDistributedLockProvider distributedLockProvider, IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService +{ + private Timer _timer = default!; + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + // Get the configured sweep interval from the options, and use it to periodically sweep expired secrets. + var sweepInterval = options.Value.SweepInterval; + + // Set up a timer that will sweep expired secrets at the configured interval. + _timer = new Timer(SweepExpiredSecrets, null, sweepInterval, sweepInterval); + + return Task.CompletedTask; + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + _timer.Change(Timeout.Infinite, 0); + _timer.Dispose(); + return base.StopAsync(cancellationToken); + } + + private async void SweepExpiredSecrets(object? state) + { + // Acquire a distributed lock to ensure that only one instance of the hosted service is running at any given time. + await using var distributedLock = await distributedLockProvider.TryAcquireLockAsync("expired-secrets-sweep"); + + // If the lock could not be acquired, return a completed task. + if (distributedLock == null) + { + logger.LogInformation("Another instance of the expired secrets hosted service is already running. Exiting..."); + return; + } + + // Sweep expired secrets here. + logger.LogInformation("Sweeping expired secrets..."); + + using var scope = scopeFactory.CreateScope(); + var updater = scope.ServiceProvider.GetRequiredService(); + await updater.UpdateExpiredSecretsAsync(); + + logger.LogInformation("Expired secrets have been swept."); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Management/Models/SecretFilter.cs b/src/modules/Elsa.Secrets.Management/Models/SecretFilter.cs index e5829019a..6cbfe6451 100644 --- a/src/modules/Elsa.Secrets.Management/Models/SecretFilter.cs +++ b/src/modules/Elsa.Secrets.Management/Models/SecretFilter.cs @@ -6,24 +6,30 @@ public class SecretFilter public ICollection? Ids { get; set; } public string? SecretId { get; set; } public ICollection? SecretIds { get; set; } - public string? NotId { get; set; } + public string? NotSecretId { get; set; } public string? Name { get; set; } + public ICollection? Names { get; set; } public int? Version { get; set; } public string? Type { get; set; } public SecretStatus? Status { get; set; } public bool IsLatest { get; set; } public string? SearchTerm { get; set; } + public DateTimeOffset? ExpiresAtLessThan { get; set; } public IQueryable Apply(IQueryable queryable) { if (Id != null) queryable = queryable.Where(x => x.Id == Id); if (Ids != null) queryable = queryable.Where(x => Ids.Contains(x.Id)); - if (NotId != null) queryable = queryable.Where(x => x.Id != NotId); + if (SecretId != null) queryable = queryable.Where(x => x.SecretId == SecretId); + if (SecretIds != null) queryable = queryable.Where(x => SecretIds.Contains(x.SecretId)); + if (NotSecretId != null) queryable = queryable.Where(x => x.SecretId != NotSecretId); if (Name != null) queryable = queryable.Where(x => x.Name == Name); + if (Names != null) queryable = queryable.Where(x => Names.Contains(x.Name)); if (Version != null) queryable = queryable.Where(x => x.Version == Version); if (Type != null) queryable = queryable.Where(x => x.Scope == Type); if(Status != null) queryable = queryable.Where(x => x.Status == Status); if (IsLatest) queryable = queryable.Where(x => x.IsLatest); + if (ExpiresAtLessThan != null) queryable = queryable.Where(x => x.ExpiresAt < ExpiresAtLessThan); if (!string.IsNullOrWhiteSpace(SearchTerm)) queryable = queryable.Where(x => x.Name.Contains(SearchTerm) || x.Description.Contains(SearchTerm) || x.Id.Contains(SearchTerm)); return queryable; diff --git a/src/modules/Elsa.Secrets.Management/Options/SecretManagementOptions.cs b/src/modules/Elsa.Secrets.Management/Options/SecretManagementOptions.cs new file mode 100644 index 000000000..08aec860b --- /dev/null +++ b/src/modules/Elsa.Secrets.Management/Options/SecretManagementOptions.cs @@ -0,0 +1,9 @@ +namespace Elsa.Secrets.Management; + +public class SecretManagementOptions +{ + /// + /// The interval at which the background sweep should run for expired secrets. + /// + public TimeSpan SweepInterval { get; set; } = TimeSpan.FromHours(12); +} \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Management/Options/StoreEncryptionKeyProviderOptions.cs b/src/modules/Elsa.Secrets.Management/Options/StoreEncryptionKeyProviderOptions.cs deleted file mode 100644 index 9bb163dd1..000000000 --- a/src/modules/Elsa.Secrets.Management/Options/StoreEncryptionKeyProviderOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Security.Cryptography; - -namespace Elsa.Secrets.Management; - -public class EncryptionKeyProviderOptions -{ - public string Key { get; set; } = default!; - public string IV { get; set; } = default!; - public string Algorithm { get; set; } = nameof(Aes); -} \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Management/Services/DefaultExpiredSecretsUpdater.cs b/src/modules/Elsa.Secrets.Management/Services/DefaultExpiredSecretsUpdater.cs new file mode 100644 index 000000000..a1771394f --- /dev/null +++ b/src/modules/Elsa.Secrets.Management/Services/DefaultExpiredSecretsUpdater.cs @@ -0,0 +1,26 @@ +using Elsa.Common.Contracts; + +namespace Elsa.Secrets.Management; + +public class DefaultExpiredSecretsUpdater(ISecretStore store, ISystemClock systemClock) : IExpiredSecretsUpdater +{ + public async Task UpdateExpiredSecretsAsync(CancellationToken cancellationToken = default) + { + var now = systemClock.UtcNow; + + var filter = new SecretFilter + { + Status = SecretStatus.Active, + ExpiresAtLessThan = now + }; + + var secrets = (await store.FindManyAsync(filter, cancellationToken)).ToList(); + + foreach (var secret in secrets) + { + secret.Status = SecretStatus.Expired; + secret.UpdatedAt = now; + await store.UpdateAsync(secret, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Management/Services/DefaultSecretManager.cs b/src/modules/Elsa.Secrets.Management/Services/DefaultSecretManager.cs index e34c58480..12eb29540 100644 --- a/src/modules/Elsa.Secrets.Management/Services/DefaultSecretManager.cs +++ b/src/modules/Elsa.Secrets.Management/Services/DefaultSecretManager.cs @@ -51,6 +51,11 @@ public class DefaultSecretManager(ISecretStore store, IEncryptor encryptor, ISec return store.FindAsync(filter, cancellationToken); } + public Task> FindManyAsync(SecretFilter filter, CancellationToken cancellationToken = default) + { + return store.FindManyAsync(filter, cancellationToken); + } + public Task> ListAsync(CancellationToken cancellationToken = default) { return store.ListAsync(cancellationToken); @@ -58,13 +63,24 @@ public class DefaultSecretManager(ISecretStore store, IEncryptor encryptor, ISec public async Task DeleteAsync(Secret entity, CancellationToken cancellationToken = default) { - await store.DeleteAsync(entity, cancellationToken); + var filter = new SecretFilter + { + SecretId = entity.SecretId + }; + await store.DeleteManyAsync(filter, cancellationToken); await notificationSender.SendAsync(new SecretDeleted(entity), cancellationToken); } public async Task DeleteManyAsync(SecretFilter filter, CancellationToken cancellationToken = default) { - var count = await store.DeleteManyAsync(filter, cancellationToken); + var secretVersions = await store.FindManyAsync(filter, cancellationToken); + var secretIds = secretVersions.Select(x => x.Id).Distinct().ToList(); + var allSecretsAndVersionsFilter = new SecretFilter + { + SecretIds = secretIds + }; + + var count = await store.DeleteManyAsync(allSecretsAndVersionsFilter, cancellationToken); await notificationSender.SendAsync(new SecretsDeletedInBulk(), cancellationToken); return count; } diff --git a/src/modules/Elsa.Secrets.Management/Services/DefaultSecretNameValidator.cs b/src/modules/Elsa.Secrets.Management/Services/DefaultSecretNameValidator.cs index d0771e0de..5031f810a 100644 --- a/src/modules/Elsa.Secrets.Management/Services/DefaultSecretNameValidator.cs +++ b/src/modules/Elsa.Secrets.Management/Services/DefaultSecretNameValidator.cs @@ -7,7 +7,7 @@ public class DefaultSecretNameValidator(ISecretStore store) : ISecretNameValidat var filter = new SecretFilter { Name = name, - NotId = notId + NotSecretId = notId }; return await store.FindAsync(filter, cancellationToken) == null; } diff --git a/src/modules/Elsa.Secrets.Management/Services/DefaultSecretUpdater.cs b/src/modules/Elsa.Secrets.Management/Services/DefaultSecretUpdater.cs index f29f41970..7f8eeab93 100644 --- a/src/modules/Elsa.Secrets.Management/Services/DefaultSecretUpdater.cs +++ b/src/modules/Elsa.Secrets.Management/Services/DefaultSecretUpdater.cs @@ -1,8 +1,9 @@ using Elsa.Common.Contracts; +using Elsa.Workflows.Contracts; namespace Elsa.Secrets.Management; -public class DefaultSecretUpdater(ISecretStore store, IEncryptor encryptor, ISystemClock systemClock) : ISecretUpdater +public class DefaultSecretUpdater(ISecretStore store, IEncryptor encryptor, IIdentityGenerator identityGenerator, ISystemClock systemClock) : ISecretUpdater { public async Task UpdateAsync(Secret secret, SecretInputModel input, CancellationToken cancellationToken = default) { @@ -15,17 +16,21 @@ public class DefaultSecretUpdater(ISecretStore store, IEncryptor encryptor, ISys // There should always be at most one latest version of the secret, but we'll use FindManyAsync to be safe as a defensive programming measure. var currentLatestVersions = (await store.FindManyAsync(filter, cancellationToken)).OrderBy(x => x.Version).ToList(); - var currentLatestVersion = currentLatestVersions.Last(); + var currentLatestVersion = currentLatestVersions.LastOrDefault() ?? secret; foreach (var version in currentLatestVersions) { version.IsLatest = false; - version.Status = SecretStatus.Retired; + + // Only retire the version if it's active. + if(version.Status == SecretStatus.Active) + version.Status = SecretStatus.Retired; await store.UpdateAsync(version, cancellationToken); } var newVersion = secret.Clone(); var encryptedValue = await encryptor.EncryptAsync(input.Value, cancellationToken); + newVersion.Id = identityGenerator.GenerateId(); newVersion.IsLatest = true; newVersion.Version = currentLatestVersion.Version + 1; newVersion.Name = input.Name.Trim(); @@ -36,7 +41,7 @@ public class DefaultSecretUpdater(ISecretStore store, IEncryptor encryptor, ISys newVersion.ExpiresAt = input.ExpiresIn != null ? systemClock.UtcNow + input.ExpiresIn.Value : null; newVersion.Status = SecretStatus.Active; - await store.UpdateAsync(newVersion, cancellationToken); + await store.AddAsync(newVersion, cancellationToken); return newVersion; } } \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Models/SecretStatus.cs b/src/modules/Elsa.Secrets.Models/SecretStatus.cs index f6b8625eb..a6f3af816 100644 --- a/src/modules/Elsa.Secrets.Models/SecretStatus.cs +++ b/src/modules/Elsa.Secrets.Models/SecretStatus.cs @@ -2,7 +2,23 @@ namespace Elsa.Secrets; public enum SecretStatus { + /// + /// The secret is active. + /// Active, + + /// + /// The secret is retired due to an update which created a new version. + /// Retired, + + /// + /// The secret has expired. + /// + Expired, + + /// + /// The secret has been revoked. + /// Revoked } \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Scripting/Elsa.Secrets.Scripting.csproj b/src/modules/Elsa.Secrets.Scripting/Elsa.Secrets.Scripting.csproj new file mode 100644 index 000000000..f0a73cba0 --- /dev/null +++ b/src/modules/Elsa.Secrets.Scripting/Elsa.Secrets.Scripting.csproj @@ -0,0 +1,13 @@ + + + + Provides scripting integration with various languages like JavaScript, C#, Python and Liquid. + elsa module secrets scripting + + + + + + + + \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Scripting/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Secrets.Scripting/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..24209acf0 --- /dev/null +++ b/src/modules/Elsa.Secrets.Scripting/Extensions/ModuleExtensions.cs @@ -0,0 +1,14 @@ +using Elsa.Features.Services; +using Elsa.Secrets.Management.Features; +using Elsa.Secrets.Scripting.Features; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +public static class ModuleExtensions +{ + public static IModule UseSecretsScripting(this IModule module, Action? setup = null) + { + return module.Use(setup); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Scripting/Features/SecretsScriptingFeature.cs b/src/modules/Elsa.Secrets.Scripting/Features/SecretsScriptingFeature.cs new file mode 100644 index 000000000..e62ca86b6 --- /dev/null +++ b/src/modules/Elsa.Secrets.Scripting/Features/SecretsScriptingFeature.cs @@ -0,0 +1,23 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.JavaScript.Features; +using Elsa.JavaScript.Extensions; +using Elsa.Secrets.Management.Features; +using Elsa.Secrets.Scripting.JavaScript; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Secrets.Scripting.Features; + +[DependencyOf(typeof(SecretManagementFeature))] +[DependencyOf(typeof(JavaScriptFeature))] +public class SecretsScriptingFeature(IModule module) : FeatureBase(module) +{ + public override void Configure() + { + Services.AddHandlersFrom(); + Services.AddScoped(); + Services.AddTypeDefinitionProvider(sp => sp.GetRequiredService()); + Services.AddVariableDefinitionProvider(sp => sp.GetRequiredService()); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Scripting/FodyWeavers.xml b/src/modules/Elsa.Secrets.Scripting/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Secrets.Scripting/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Scripting/JavaScript/ConfigureEngineWithSecrets.cs b/src/modules/Elsa.Secrets.Scripting/JavaScript/ConfigureEngineWithSecrets.cs new file mode 100644 index 000000000..230995080 --- /dev/null +++ b/src/modules/Elsa.Secrets.Scripting/JavaScript/ConfigureEngineWithSecrets.cs @@ -0,0 +1,67 @@ +using System.Dynamic; +using System.Text.RegularExpressions; +using Elsa.JavaScript.Notifications; +using Elsa.Mediator.Contracts; +using Elsa.Secrets.Management; +using JetBrains.Annotations; + +namespace Elsa.Secrets.Scripting.JavaScript; + +/// A handler that configures the Jint engine with secrets. +[UsedImplicitly] +public partial class ConfigureEngineWithSecrets(ISecretManager secretManager, IDecryptor decryptor) : INotificationHandler +{ + /// + public async Task HandleAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken) + { + await CopySecretsIntoEngineAsync(notification, cancellationToken); + } + + private async Task CopySecretsIntoEngineAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken) + { + var engine = notification.Engine; + var expression = notification.Expression; + var secretNames = GetSecretNamesFromExpression(expression); + + if (secretNames.Count == 0) + return; + + var filter = new SecretFilter + { + Names = secretNames, + Status = SecretStatus.Active + }; + var secrets = await secretManager.FindManyAsync(filter, cancellationToken); + IDictionary secretsContainer = new ExpandoObject(); + + foreach (var secret in secrets) + { + var secretValue = await decryptor.DecryptAsync(secret.EncryptedValue, cancellationToken); + secretsContainer[secret.Name] = secretValue; + } + + engine.SetValue("secrets", secretsContainer); + } + + private ICollection GetSecretNamesFromExpression(string expression) + { + var secretNames = new List(); + +#if NET6_0 + const string pattern = @"(?<=secrets\.)\w+"; + var matches = Regex.Matches(expression, pattern); +#elif NET7_0_OR_GREATER + var matches = SecretsRegex().Matches(expression); +#endif + + foreach (Match match in matches) + secretNames.Add(match.Value); + + return secretNames; + } + +#if NET7_0_OR_GREATER + [GeneratedRegex(@"(?<=secrets\.)\w+")] + private static partial Regex SecretsRegex(); +#endif +} \ No newline at end of file diff --git a/src/modules/Elsa.Secrets.Scripting/JavaScript/SecretsTypeDefinitionProvider.cs b/src/modules/Elsa.Secrets.Scripting/JavaScript/SecretsTypeDefinitionProvider.cs new file mode 100644 index 000000000..0f841e046 --- /dev/null +++ b/src/modules/Elsa.Secrets.Scripting/JavaScript/SecretsTypeDefinitionProvider.cs @@ -0,0 +1,49 @@ +using Elsa.JavaScript.TypeDefinitions.Builders; +using Elsa.JavaScript.TypeDefinitions.Contracts; +using Elsa.JavaScript.TypeDefinitions.Models; +using Elsa.Secrets.Management; +using JetBrains.Annotations; + +namespace Elsa.Secrets.Scripting.JavaScript; + +[UsedImplicitly] +internal class SecretsTypeDefinitionProvider(ISecretManager secretManager) : ITypeDefinitionProvider, IVariableDefinitionProvider +{ + public async ValueTask> GetTypeDefinitionsAsync(TypeDefinitionContext context) + { + var cancellationToken = context.CancellationToken; + var filter = new SecretFilter + { + Status = SecretStatus.Active + }; + var secrets = await secretManager.FindManyAsync(filter, cancellationToken); + + var secretsContainerClass = new TypeDefinition + { + Name = "SecretVariables", + DeclarationKeyword = "class" + }; + + foreach (var secret in secrets) + { + secretsContainerClass.Properties.Add(new PropertyDefinition + { + Name = secret.Name, + Type = "string" + }); + } + + return [secretsContainerClass]; + } + + public ValueTask> GetVariableDefinitionsAsync(TypeDefinitionContext context) + { + var definitions = new List + { + new VariableDefinitionBuilder().Name("secrets").Type("SecretVariables").Build() + }; + + + return new (definitions); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Abstractions/ActivityStateFilterBase.cs b/src/modules/Elsa.Workflows.Core/Abstractions/ActivityStateFilterBase.cs new file mode 100644 index 000000000..52b2aed60 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Abstractions/ActivityStateFilterBase.cs @@ -0,0 +1,26 @@ +using JetBrains.Annotations; + +namespace Elsa.Workflows; + +[UsedImplicitly] +public abstract class ActivityStateFilterBase : IActivityStateFilter +{ + protected virtual Task OnExecuteAsync(ActivityStateFilterContext context) + { + var result = OnExecute(context); + return Task.FromResult(result); + } + + protected virtual ActivityStateFilterResult OnExecute(ActivityStateFilterContext context) + { + return Pass(); + } + + protected ActivityStateFilterResult Pass() => ActivityStateFilterResult.Pass(); + protected ActivityStateFilterResult Filtered(string filteredValue) => ActivityStateFilterResult.Filtered(filteredValue); + + Task IActivityStateFilter.ExecuteAsync(ActivityStateFilterContext context) + { + return OnExecuteAsync(context); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs b/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs index dfb1948a0..1db5c7314 100644 --- a/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs +++ b/src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs @@ -84,6 +84,12 @@ public class InputAttribute : Attribute /// A value indicating whether this input can be serialized as part of the workflow instance, /// public bool IsSerializable { get; set; } = true; + + /// + /// Gets or sets a value indicating whether this input can contain secrets. + /// When set to true, the input will be treated as a secret and will be encrypted, masked or otherwise protected, depending on the configured policy. + /// + public bool CanContainSecrets { get; set; } /// /// A type that can be used to customize the UI for this property. diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityStateFilterContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityStateFilterContext.cs new file mode 100644 index 000000000..b191f8041 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityStateFilterContext.cs @@ -0,0 +1,6 @@ +using System.Text.Json; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows; + +public record ActivityStateFilterContext(ActivityExecutionContext ActivityExecutionContext, InputDescriptor InputDescriptor, JsonElement Value, CancellationToken CancellationToken); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionMiddleware.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionMiddleware.cs index bb786f283..d67958272 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionMiddleware.cs @@ -1,7 +1,13 @@ namespace Elsa.Workflows; +/// +/// The interface for activity execution middleware components. +/// public interface IActivityExecutionMiddleware { + /// + /// The method that is called to execute the middleware. + /// ValueTask InvokeAsync(ActivityExecutionContext context); } diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityStateFilter.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityStateFilter.cs new file mode 100644 index 000000000..04e190fb2 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IActivityStateFilter.cs @@ -0,0 +1,6 @@ +namespace Elsa.Workflows; + +public interface IActivityStateFilter +{ + Task ExecuteAsync(ActivityStateFilterContext context); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityStateFilterManager.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityStateFilterManager.cs new file mode 100644 index 000000000..15df4895e --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IActivityStateFilterManager.cs @@ -0,0 +1,6 @@ +namespace Elsa.Workflows; + +public interface IActivityStateFilterManager +{ + Task RunFiltersAsync(ActivityStateFilterContext context); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj.DotSettings b/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj.DotSettings index b50a58f5d..a9f483ae3 100644 --- a/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj.DotSettings +++ b/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj.DotSettings @@ -12,4 +12,5 @@ True True True + True True \ 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 new file mode 100644 index 000000000..9955bb31d --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs @@ -0,0 +1,132 @@ +using System.Linq.Expressions; +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Helpers; +using Elsa.Expressions.Models; +using Elsa.Workflows; +using Elsa.Workflows.Contracts; +using Elsa.Workflows.Models; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +public static partial class ActivityExecutionContextExtensions +{ + /// + /// Evaluates each input property of the activity. + /// + public static async Task EvaluateInputPropertiesAsync(this ActivityExecutionContext context) + { + var activityDescriptor = context.ActivityDescriptor; + var inputDescriptors = activityDescriptor.Inputs.Where(x => x.AutoEvaluate).ToList(); + + // Evaluate inputs. + foreach (var inputDescriptor in inputDescriptors) + await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor); + + context.SetHasEvaluatedProperties(); + } + + /// + /// Evaluates the specified input property of the activity. + /// + public static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, Expression>> propertyExpression) + { + var inputName = propertyExpression.GetProperty()!.Name; + var input = await EvaluateInputPropertyAsync(context, inputName); + return input.ConvertTo(); + } + + /// + /// Evaluates a specific input property of the activity. + /// + public static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, string inputName) + { + var activity = context.Activity; + var activityRegistryLookup = context.GetRequiredService(); + var activityDescriptor = await activityRegistryLookup.FindAsync(activity.Type) ?? throw new Exception("Activity descriptor not found"); + var inputDescriptor = activityDescriptor.GetWrappedInputPropertyDescriptor(activity, inputName); + + if (inputDescriptor == null) + throw new Exception($"No input with name {inputName} could be found"); + + return await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor); + } + + /// + /// Evaluates the specified input and sets the result in the activity execution context's memory space. + /// + /// The being extended. + /// The input to evaluate. + /// The type of the input. + /// The evaluated value. + public static async Task EvaluateAsync(this ActivityExecutionContext context, Input input) + { + var evaluator = context.GetRequiredService(); + var memoryBlockReference = input.MemoryBlockReference(); + var value = await evaluator.EvaluateAsync(input, context.ExpressionExecutionContext); + memoryBlockReference.Set(context, value); + return value; + } + + private static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, ActivityDescriptor activityDescriptor, InputDescriptor inputDescriptor) + { + var activity = context.Activity; + var defaultValue = inputDescriptor.DefaultValue; + var value = defaultValue; + var input = inputDescriptor.ValueGetter(activity); + var identityGenerator = context.GetRequiredService(); + + if (inputDescriptor.IsWrapped) + { + var wrappedInput = (Input?)input; + + if (defaultValue != null && wrappedInput == null) + { + var typedInput = typeof(Input<>).MakeGenericType(inputDescriptor.Type); + var valueExpression = new Literal(defaultValue) + { + Id = identityGenerator.GenerateId(), + }; + wrappedInput = (Input)Activator.CreateInstance(typedInput, valueExpression)!; + inputDescriptor.ValueSetter(activity, wrappedInput); + } + else + { + var evaluator = context.GetRequiredService(); + var expressionExecutionContext = context.ExpressionExecutionContext; + value = wrappedInput?.Expression != null ? await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext) : defaultValue; + } + + var memoryReference = wrappedInput?.MemoryBlockReference(); + + // When input is created from an activity provider, there may be no memory block reference. + if (memoryReference?.Id != null!) + { + // Declare the input memory block on the current context. + context.ExpressionExecutionContext.Set(memoryReference, value!); + } + } + else + { + value = input; + } + + await StoreInputValueAsync(context, inputDescriptor, value); + + return value; + } + + private static async Task StoreInputValueAsync(ActivityExecutionContext context, InputDescriptor inputDescriptor, object? value) + { + // 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) + { + var serializedValue = await context.GetRequiredService().SerializeToElementAsync(value); + var manager = context.GetRequiredService(); + var filterContext = new ActivityStateFilterContext(context, inputDescriptor, serializedValue, context.CancellationToken); + var filterResult = await manager.RunFiltersAsync(filterContext); + context.ActivityState[inputDescriptor.Name] = filterResult; + } + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs index ae6f936d1..a075530c0 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; -using System.Linq.Expressions; using System.Reflection; using System.Text.Json; using Elsa.Expressions.Contracts; @@ -23,7 +21,7 @@ namespace Elsa.Extensions; /// Provides extension methods for . /// [PublicAPI] -public static class ActivityExecutionContextExtensions +public static partial class ActivityExecutionContextExtensions { /// /// Attempts to get a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T. @@ -110,47 +108,6 @@ public static class ActivityExecutionContextExtensions /// public static IDictionary GetVariableValues(this ActivityExecutionContext activityExecutionContext) => activityExecutionContext.ExpressionExecutionContext.ReadAndFlattenMemoryBlocks(); - /// - /// Evaluates each input property of the activity. - /// - public static async Task EvaluateInputPropertiesAsync(this ActivityExecutionContext context) - { - var activityDescriptor = context.ActivityDescriptor; - var inputDescriptors = activityDescriptor.Inputs.Where(x => x.AutoEvaluate).ToList(); - - // Evaluate inputs. - foreach (var inputDescriptor in inputDescriptors) - await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor); - - context.SetHasEvaluatedProperties(); - } - - /// - /// Evaluates the specified input property of the activity. - /// - public static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, Expression>> propertyExpression) - { - var inputName = propertyExpression.GetProperty()!.Name; - var input = await EvaluateInputPropertyAsync(context, inputName); - return input.ConvertTo(); - } - - /// - /// Evaluates a specific input property of the activity. - /// - public static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, string inputName) - { - var activity = context.Activity; - var activityRegistryLookup = context.GetRequiredService(); - var activityDescriptor = await activityRegistryLookup.FindAsync(activity.Type) ?? throw new Exception("Activity descriptor not found"); - var inputDescriptor = activityDescriptor.GetWrappedInputPropertyDescriptor(activity, inputName); - - if (inputDescriptor == null) - throw new Exception($"No input with name {inputName} could be found"); - - return await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor); - } - /// /// Returns a set of tuples containing the activity and its descriptor for all activities with outputs. /// @@ -207,59 +164,6 @@ public static class ActivityExecutionContextExtensions return node?.Activity; } - private static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, ActivityDescriptor activityDescriptor, InputDescriptor inputDescriptor) - { - var activity = context.Activity; - var defaultValue = inputDescriptor.DefaultValue; - var value = defaultValue; - var input = inputDescriptor.ValueGetter(activity); - - if (inputDescriptor.IsWrapped) - { - var wrappedInput = (Input?)input; - - if (defaultValue != null && wrappedInput == null) - { - var typedInput = typeof(Input<>).MakeGenericType(inputDescriptor.Type); - var valueExpression = new Literal(defaultValue) - { - Id = Guid.NewGuid().ToString() - }; - wrappedInput = (Input)Activator.CreateInstance(typedInput, valueExpression)!; - inputDescriptor.ValueSetter(activity, wrappedInput); - } - else - { - var evaluator = context.GetRequiredService(); - var expressionExecutionContext = context.ExpressionExecutionContext; - value = wrappedInput?.Expression != null ? await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext) : defaultValue; - } - - var memoryReference = wrappedInput?.MemoryBlockReference(); - - // When input is created from an activity provider, there may be no memory block reference. - if (memoryReference?.Id != null!) - { - // Declare the input memory block on the current context. - context.ExpressionExecutionContext.Set(memoryReference, value!); - } - } - else - { - value = input; - } - - // 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) - { - var serializedValue = await context.GetRequiredService().SerializeToElementAsync(value); - context.ActivityState[inputDescriptor.Name] = serializedValue; - } - - return value; - } - /// /// Returns the outcome name for the specified port property name. /// @@ -280,22 +184,6 @@ public static class ActivityExecutionContextExtensions return portProperty.GetCustomAttribute()?.Name ?? portProperty.Name; } - /// - /// Evaluates the specified input and sets the result in the activity execution context's memory space. - /// - /// The being extended. - /// The input to evaluate. - /// The type of the input. - /// The evaluated value. - public static async Task EvaluateAsync(this ActivityExecutionContext context, Input input) - { - var evaluator = context.GetRequiredService(); - var memoryBlockReference = input.MemoryBlockReference(); - var value = await evaluator.EvaluateAsync(input, context.ExpressionExecutionContext); - memoryBlockReference.Set(context, value); - return value; - } - /// /// Returns a flattened list of the current context's ancestors. /// diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ModuleExtensions.cs index 0796f6a5e..ccd559f5c 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ModuleExtensions.cs @@ -1,7 +1,5 @@ using Elsa.Features.Services; -using Elsa.Workflows.Contracts; using Elsa.Workflows.Features; -using Microsoft.Extensions.DependencyInjection; // ReSharper disable once CheckNamespace namespace Elsa.Extensions; @@ -13,9 +11,4 @@ public static class ModuleExtensions configuration.Configure(configure); return configuration; } - - public static IServiceCollection AddStorageDriver(this IServiceCollection services) where T : class, IStorageDriver - { - return services.AddScoped(); - } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..e09de6040 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using Elsa.Workflows; +using Elsa.Workflows.Contracts; +using Microsoft.Extensions.DependencyInjection; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddStorageDriver(this IServiceCollection services) where T : class, IStorageDriver + { + return services.AddScoped(); + } + + public static IServiceCollection AddActivityStateFilter(this IServiceCollection services) where T : class, IActivityStateFilter + { + return services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index 582dbf026..048c657e9 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -129,6 +129,7 @@ public class WorkflowsFeature : FeatureBase .AddScoped() .AddScoped() .AddScoped() + .AddScoped() // Incident Strategies. .AddTransient() diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExecutionLogMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExecutionLogMiddleware.cs index 2ae1746e7..d1e8790a3 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExecutionLogMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExecutionLogMiddleware.cs @@ -20,18 +20,8 @@ public static class ExecutionLogMiddlewareExtensions /// An activity execution middleware component that extracts execution details as objects. /// [UsedImplicitly] -public class ExecutionLogMiddleware : IActivityExecutionMiddleware +public class ExecutionLogMiddleware(ActivityMiddlewareDelegate next) : IActivityExecutionMiddleware { - private readonly ActivityMiddlewareDelegate _next; - - /// - /// Constructor. - /// - public ExecutionLogMiddleware(ActivityMiddlewareDelegate next) - { - _next = next; - } - /// public async ValueTask InvokeAsync(ActivityExecutionContext context) { @@ -39,7 +29,7 @@ public class ExecutionLogMiddleware : IActivityExecutionMiddleware try { - await _next(context); + await next(context); if (context.Status == ActivityStatus.Running) { @@ -65,6 +55,8 @@ public class ExecutionLogMiddleware : IActivityExecutionMiddleware } } - private static bool IsActivityBookmarked(ActivityExecutionContext context) => - context.WorkflowExecutionContext.Bookmarks.Any(b => b.ActivityNodeId.Equals(context.ActivityNode.NodeId)); + private static bool IsActivityBookmarked(ActivityExecutionContext context) + { + return context.WorkflowExecutionContext.Bookmarks.Any(b => b.ActivityNodeId.Equals(context.ActivityNode.NodeId)); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/ActivityStateFilterResult.cs b/src/modules/Elsa.Workflows.Core/Models/ActivityStateFilterResult.cs new file mode 100644 index 000000000..f93236978 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Models/ActivityStateFilterResult.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; + +namespace Elsa.Workflows; + +[UsedImplicitly] +public class ActivityStateFilterResult +{ + public string? FilteredValue { get; set; } + public bool IsFiltered { get; set; } + + public static ActivityStateFilterResult Filtered(string filteredValue) => new() { IsFiltered = true, FilteredValue = filteredValue }; + public static ActivityStateFilterResult Pass() => new() { IsFiltered = false }; +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs b/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs index 11627ea13..7863424a9 100644 --- a/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs +++ b/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs @@ -89,6 +89,12 @@ public class InputDescriptor : PropertyDescriptor /// public bool? IsReadOnly { get; set; } + /// + /// Gets or sets a value indicating whether this input can contain secrets. + /// When set to true, the input will be treated as a secret and will be encrypted, masked or otherwise protected, depending on the configured policy. + /// + 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. diff --git a/src/modules/Elsa.Workflows.Core/Services/DefaultActivityStateFilterManager.cs b/src/modules/Elsa.Workflows.Core/Services/DefaultActivityStateFilterManager.cs new file mode 100644 index 000000000..a6bdf90b3 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Services/DefaultActivityStateFilterManager.cs @@ -0,0 +1,17 @@ +namespace Elsa.Workflows; + +public class DefaultActivityStateFilterManager(IEnumerable filters) : IActivityStateFilterManager +{ + public async Task RunFiltersAsync(ActivityStateFilterContext context) + { + foreach (var filter in filters) + { + var result = await filter.ExecuteAsync(context); + + if (result.IsFiltered) + return result.FilteredValue!; + } + + return context.Value.ToString(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultActivityExecutionMapper.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultActivityExecutionMapper.cs index 972fc261a..f6ed24440 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/DefaultActivityExecutionMapper.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultActivityExecutionMapper.cs @@ -62,7 +62,7 @@ public class DefaultActivityExecutionMapper(IOptions options) }); outputs = StorePropertyUsingPersistenceMode(outputs, activityPersistenceProperties!.GetValueOrDefault("outputs", () => new Dictionary())!, activityPersistencePropertyDefault); - var activityState = StorePropertyUsingPersistenceMode(source.ActivityState, activityPersistenceProperties!.GetValueOrDefault("inputs", () => new Dictionary())!, activityPersistencePropertyDefault); + var inputs = StorePropertyUsingPersistenceMode(source.ActivityState, activityPersistenceProperties!.GetValueOrDefault("inputs", () => new Dictionary())!, activityPersistencePropertyDefault); return new ActivityExecutionRecord { @@ -72,7 +72,7 @@ public class DefaultActivityExecutionMapper(IOptions options) WorkflowInstanceId = source.WorkflowExecutionContext.Id, ActivityType = source.Activity.Type, ActivityName = source.Activity.Name, - ActivityState = activityState, + ActivityState = inputs, Outputs = outputs, Properties = source.Properties, Payload = payload, @@ -95,18 +95,16 @@ public class DefaultActivityExecutionMapper(IOptions options) return persistencePropertyDefault; } - private static Dictionary StorePropertyUsingPersistenceMode(IDictionary inputs, - IDictionary persistenceModeConfiguration, - LogPersistenceMode defaultLogPersistenceMode) + private static Dictionary StorePropertyUsingPersistenceMode(IDictionary state, IDictionary persistenceModeConfiguration, LogPersistenceMode defaultLogPersistenceMode) { var result = new Dictionary(); - foreach (var input in inputs) + foreach (var value in state) { - var persistence = persistenceModeConfiguration.GetValueOrDefault(input.Key.Camelize(), () => defaultLogPersistenceMode); + var persistence = persistenceModeConfiguration.GetValueOrDefault(value.Key.Camelize(), () => defaultLogPersistenceMode); if (persistence.Equals(LogPersistenceMode.Include) || (persistence.Equals(LogPersistenceMode.Inherit) && defaultLogPersistenceMode is LogPersistenceMode.Include or LogPersistenceMode.Inherit)) - result.Add(input.Key, input.Value); + result.Add(value.Key, value.Value); } return result;