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;