From 39c3504a1698d1e1d4e72285fef1e602611d74df Mon Sep 17 00:00:00 2001 From: tanelkuhi <89777473+tanelkuhi@users.noreply.github.com> Date: Tue, 17 Oct 2023 10:02:25 +0300 Subject: [PATCH] Feature/encrypt secret properties (#4347) * Enable encrypting secret properties * Hide secret encrypted properties from frontend with asterisks * Change encryption implementation * Use SecretsManager instead of ISecretsStore where decryption is required * Stop storing sensitive data into activity storages * Stop get workflow-registry endpoint from exposing properties that come from sensitive data * Change how its decided whether to store or expose activity properties * Change according to code review --- .../Events/SerializingProperty.cs | 21 +++ .../Models/ActivityBlueprintWrapper.cs | 2 +- .../Models/IActivityBlueprintWrapper.cs | 1 + .../Handlers/PersistActivityPropertyState.cs | 18 ++- .../Services/Workflows/ActivityActivator.cs | 35 ++++- .../elsa-workflows-studio/src/components.d.ts | 2 + .../elsa-single-line-property.tsx | 11 +- .../src/drivers/single-line-driver.tsx | 4 +- .../credential-manager-list-screen.tsx | 7 +- .../elsa-secret-editor-modal.tsx | 17 ++- .../credential-manager/models/secret.model.ts | 1 + .../src/services/property-display-driver.ts | 2 +- .../src/services/property-display-manager.ts | 4 +- .../Endpoints/OAuth2/Callback.cs | 9 +- .../Endpoints/OAuth2/GetUrl.cs | 11 +- .../Endpoints/Secrets/List.cs | 12 +- .../Endpoints/Secrets/Save.cs | 17 +-- .../Services/OAuth2TokenService.cs | 13 +- .../OAuth2SecretValueFormatter.cs | 5 + .../EntityFrameworkSecretsStartupBase.cs | 2 +- .../Startup.cs | 2 +- .../Elsa.Secrets/Encryption/AesEncryption.cs | 55 +++++++ .../SecretsOptionsBuilderExtensions.cs | 6 +- .../ValidatePropertyStoringHandler.cs | 47 ++++++ .../Elsa.Secrets/Manager/ISecretsManager.cs | 15 +- .../Elsa.Secrets/Manager/SecretsManager.cs | 144 +++++++++++++++++- .../secrets/Elsa.Secrets/Models/Secret.cs | 17 ++- .../Elsa.Secrets/Models/SecretProperty.cs | 17 ++- .../Options/SecretsConfigOptions.cs | 11 ++ .../Providers/ISecretsProvider.cs | 13 ++ .../Elsa.Secrets/Providers/SecretsProvider.cs | 28 +++- ...AuthorizationHeaderSecretValueFormatter.cs | 5 + .../ValueFormatters/ISecretValueFormatter.cs | 1 + .../SqlSecretValueFormatter.cs | 13 +- .../Elsa.Samples.Server.Host/appsettings.json | 5 +- .../Services/WorkflowBlueprintMapper.cs | 26 +++- .../unit/Elsa.UnitTests/Elsa.UnitTests.csproj | 1 + 37 files changed, 522 insertions(+), 78 deletions(-) create mode 100644 src/core/Elsa.Abstractions/Events/SerializingProperty.cs create mode 100644 src/modules/secrets/Elsa.Secrets/Encryption/AesEncryption.cs create mode 100644 src/modules/secrets/Elsa.Secrets/Handlers/ValidatePropertyStoringHandler.cs create mode 100644 src/modules/secrets/Elsa.Secrets/Options/SecretsConfigOptions.cs diff --git a/src/core/Elsa.Abstractions/Events/SerializingProperty.cs b/src/core/Elsa.Abstractions/Events/SerializingProperty.cs new file mode 100644 index 0000000000..cfe1d8fbbd --- /dev/null +++ b/src/core/Elsa.Abstractions/Events/SerializingProperty.cs @@ -0,0 +1,21 @@ +using Elsa.Services.Models; +using MediatR; + +namespace Elsa.Events; + +public class SerializingProperty : INotification +{ + public IWorkflowBlueprint WorkflowBlueprint { get; } + public string ActivityId { get; } + public string PropertyName { get; } + + public SerializingProperty(IWorkflowBlueprint workflowBlueprint, string activityId, string propertyName) + { + WorkflowBlueprint = workflowBlueprint; + ActivityId = activityId; + PropertyName = propertyName; + } + + public bool CanSerialize { get; private set; } = true; + public void PreventSerialization() => CanSerialize = false; +} \ No newline at end of file diff --git a/src/core/Elsa.Abstractions/Services/Models/ActivityBlueprintWrapper.cs b/src/core/Elsa.Abstractions/Services/Models/ActivityBlueprintWrapper.cs index e076dc1e77..c099f6e406 100644 --- a/src/core/Elsa.Abstractions/Services/Models/ActivityBlueprintWrapper.cs +++ b/src/core/Elsa.Abstractions/Services/Models/ActivityBlueprintWrapper.cs @@ -7,7 +7,7 @@ namespace Elsa.Services.Models { public class ActivityBlueprintWrapper : IActivityBlueprintWrapper { - protected ActivityExecutionContext ActivityExecutionContext { get; } + public ActivityExecutionContext ActivityExecutionContext { get; } public ActivityBlueprintWrapper(ActivityExecutionContext activityExecutionContext) { diff --git a/src/core/Elsa.Abstractions/Services/Models/IActivityBlueprintWrapper.cs b/src/core/Elsa.Abstractions/Services/Models/IActivityBlueprintWrapper.cs index 75c2873a2d..ed13ad9335 100644 --- a/src/core/Elsa.Abstractions/Services/Models/IActivityBlueprintWrapper.cs +++ b/src/core/Elsa.Abstractions/Services/Models/IActivityBlueprintWrapper.cs @@ -7,6 +7,7 @@ namespace Elsa.Services.Models { public interface IActivityBlueprintWrapper { + ActivityExecutionContext ActivityExecutionContext { get; } IActivityBlueprint ActivityBlueprint { get; } IActivityBlueprintWrapper As() where TActivity : IActivity; ValueTask EvaluatePropertyValueAsync(string propertyName, CancellationToken cancellationToken = default); diff --git a/src/core/Elsa.Core/Handlers/PersistActivityPropertyState.cs b/src/core/Elsa.Core/Handlers/PersistActivityPropertyState.cs index 2540c36bfc..0f8575e2a6 100644 --- a/src/core/Elsa.Core/Handlers/PersistActivityPropertyState.cs +++ b/src/core/Elsa.Core/Handlers/PersistActivityPropertyState.cs @@ -17,10 +17,12 @@ namespace Elsa.Handlers public class PersistActivityPropertyState : INotificationHandler { private readonly IWorkflowStorageService _workflowStorageService; + private readonly IMediator _mediator; - public PersistActivityPropertyState(IWorkflowStorageService workflowStorageService) + public PersistActivityPropertyState(IWorkflowStorageService workflowStorageService, IMediator mediator) { _workflowStorageService = workflowStorageService; + _mediator = mediator; } public async Task Handle(ActivityExecuted notification, CancellationToken cancellationToken) @@ -35,6 +37,13 @@ public async Task Handle(ActivityExecuted notification, CancellationToken cancel // Persist input properties. foreach (var property in inputProperties) { + var serializingProperty = new SerializingProperty(activityExecutionContext.WorkflowExecutionContext.WorkflowBlueprint, activity.Id, property.Name); + await _mediator.Publish(serializingProperty, cancellationToken); + if (!serializingProperty.CanSerialize) + { + continue; + } + var value = property.GetValue(activity); var inputAttr = property.GetCustomAttribute(); var defaultProviderName = inputAttr.DefaultWorkflowStorageProvider; @@ -44,6 +53,13 @@ public async Task Handle(ActivityExecuted notification, CancellationToken cancel // Persist output properties. foreach (var property in outputProperties) { + var serializingProperty = new SerializingProperty(activityExecutionContext.WorkflowExecutionContext.WorkflowBlueprint, activity.Id, property.Name); + await _mediator.Publish(serializingProperty, cancellationToken); + if (!serializingProperty.CanSerialize) + { + continue; + } + var value = property.GetValue(activity); var outputAttr = property.GetCustomAttribute(); var defaultProviderName = outputAttr.DefaultWorkflowStorageProvider; diff --git a/src/core/Elsa.Core/Services/Workflows/ActivityActivator.cs b/src/core/Elsa.Core/Services/Workflows/ActivityActivator.cs index 7fe76c4d9f..ed642b2b20 100644 --- a/src/core/Elsa.Core/Services/Workflows/ActivityActivator.cs +++ b/src/core/Elsa.Core/Services/Workflows/ActivityActivator.cs @@ -6,10 +6,13 @@ using System.Threading.Tasks; using Elsa.Activities.ControlFlow; using Elsa.Attributes; +using Elsa.Events; using Elsa.Options; using Elsa.Providers.WorkflowStorage; using Elsa.Services.Models; using Elsa.Services.WorkflowStorage; +using MediatR; +using Microsoft.Extensions.DependencyInjection; namespace Elsa.Services.Workflows { @@ -17,11 +20,13 @@ public class ActivityActivator : IActivityActivator { private readonly ElsaOptions _elsaOptions; private readonly IWorkflowStorageService _workflowStorageService; + private readonly IServiceProvider _serviceProvider; - public ActivityActivator(ElsaOptions options, IWorkflowStorageService workflowStorageService) + public ActivityActivator(ElsaOptions options, IWorkflowStorageService workflowStorageService, IServiceProvider serviceProvider) { _elsaOptions = options; _workflowStorageService = workflowStorageService; + _serviceProvider = serviceProvider; } public async Task ActivateActivityAsync(ActivityExecutionContext context, Type type, CancellationToken cancellationToken = default) @@ -82,20 +87,36 @@ private async ValueTask ApplyStoredObjectValuesAsync(ActivityExecutionContext co private async ValueTask StoreAppliedValuesAsync(ActivityExecutionContext context, IActivity activity, CancellationToken cancellationToken) { - await StoreAppliedObjectValuesAsync(context, activity, cancellationToken); + await StoreAppliedObjectValuesAsync(context, activity, activity, cancellationToken); } - private async ValueTask StoreAppliedObjectValuesAsync(ActivityExecutionContext context, object activity, CancellationToken cancellationToken, string? parentName = null) + /// + /// Recursively store activity's properties + /// + /// The parent activity of all the activity properties + /// The activity or the recursively generated object from the activity's properties + private async ValueTask StoreAppliedObjectValuesAsync(ActivityExecutionContext context, IActivity activity, object nestedInstance, CancellationToken cancellationToken, string? parentName = null) { - var properties = activity.GetType().GetProperties().Where(IsActivityProperty).ToList(); - var nestedProperties = activity.GetType().GetProperties().Where(IsActivityObjectProperty).ToList(); + using var scope = _serviceProvider.CreateScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + + var properties = nestedInstance.GetType().GetProperties().Where(IsActivityProperty).ToList(); + var nestedProperties = nestedInstance.GetType().GetProperties().Where(IsActivityObjectProperty).ToList(); var propertyStorageProviderDictionary = context.ActivityBlueprint.PropertyStorageProviders; var workflowStorageContext = new WorkflowStorageContext(context.WorkflowInstance, context.ActivityId); foreach (var property in properties) { var propertyName = parentName == null ? property.Name : $"{parentName}_{property.Name}"; - var value = property.GetValue(activity); + + var serializingProperty = new SerializingProperty(context.WorkflowExecutionContext.WorkflowBlueprint, activity.Id, propertyName); + await mediator.Publish(serializingProperty, cancellationToken); + if (!serializingProperty.CanSerialize) + { + continue; + } + + var value = property.GetValue(nestedInstance); var attr = property.GetCustomAttributes().First(); var providerName = propertyStorageProviderDictionary.GetItem(propertyName) ?? attr.DefaultWorkflowStorageProvider; await _workflowStorageService.SaveAsync(providerName, workflowStorageContext, propertyName, value, cancellationToken); @@ -105,7 +126,7 @@ private async ValueTask StoreAppliedObjectValuesAsync(ActivityExecutionContext c { var instance = Activator.CreateInstance(nestedProperty.PropertyType); var propertyName = parentName == null ? nestedProperty.Name : $"{parentName}_{nestedProperty.Name}"; - await StoreAppliedObjectValuesAsync(context, instance, cancellationToken, propertyName); + await StoreAppliedObjectValuesAsync(context, activity, instance, cancellationToken, propertyName); } } diff --git a/src/designer/elsa-workflows-studio/src/components.d.ts b/src/designer/elsa-workflows-studio/src/components.d.ts index 52fa6f8161..95336be095 100644 --- a/src/designer/elsa-workflows-studio/src/components.d.ts +++ b/src/designer/elsa-workflows-studio/src/components.d.ts @@ -225,6 +225,7 @@ export namespace Components { } interface ElsaSingleLineProperty { "activityModel": ActivityModel; + "isEncypted"?: boolean; "propertyDescriptor": ActivityPropertyDescriptor; "propertyModel": ActivityDefinitionProperty; } @@ -1148,6 +1149,7 @@ declare namespace LocalJSX { } interface ElsaSingleLineProperty { "activityModel"?: ActivityModel; + "isEncypted"?: boolean; "propertyDescriptor"?: ActivityPropertyDescriptor; "propertyModel"?: ActivityDefinitionProperty; } diff --git a/src/designer/elsa-workflows-studio/src/components/editors/properties/elsa-single-line-property/elsa-single-line-property.tsx b/src/designer/elsa-workflows-studio/src/components/editors/properties/elsa-single-line-property/elsa-single-line-property.tsx index 5814ebacb3..23071d3005 100644 --- a/src/designer/elsa-workflows-studio/src/components/editors/properties/elsa-single-line-property/elsa-single-line-property.tsx +++ b/src/designer/elsa-workflows-studio/src/components/editors/properties/elsa-single-line-property/elsa-single-line-property.tsx @@ -10,6 +10,7 @@ export class ElsaSingleLineProperty { @Prop() activityModel: ActivityModel; @Prop() propertyDescriptor: ActivityPropertyDescriptor; @Prop() propertyModel: ActivityDefinitionProperty; + @Prop() isEncypted?: boolean; @State() currentValue: string; onChange(e: Event) { @@ -18,6 +19,14 @@ export class ElsaSingleLineProperty { this.propertyModel.expressions[defaultSyntax] = this.currentValue = input.value; } + onFocus(e: Event) { + if (this.isEncypted) { + const input = e.currentTarget as HTMLInputElement; + const defaultSyntax = this.propertyDescriptor.defaultSyntax || SyntaxNames.Literal; + input.value = this.propertyModel.expressions[defaultSyntax] = this.currentValue = ""; + } + } + componentWillLoad() { const defaultSyntax = this.propertyDescriptor.defaultSyntax || SyntaxNames.Literal; this.currentValue = this.propertyModel.expressions[defaultSyntax] || undefined; @@ -54,7 +63,7 @@ export class ElsaSingleLineProperty { onDefaultSyntaxValueChanged={e => this.onDefaultSyntaxValueChanged(e)} editor-height="5em" single-line={true}> - this.onChange(e)} + this.onFocus(e)} onChange={e => this.onChange(e)} class="disabled:elsa-opacity-50 disabled:elsa-cursor-not-allowed focus:elsa-ring-blue-500 focus:elsa-border-blue-500 elsa-block elsa-w-full elsa-min-w-0 elsa-rounded-md sm:elsa-text-sm elsa-border-gray-300" disabled={isReadOnly}/> diff --git a/src/designer/elsa-workflows-studio/src/drivers/single-line-driver.tsx b/src/designer/elsa-workflows-studio/src/drivers/single-line-driver.tsx index c69b09b6a6..4b5c92ac95 100644 --- a/src/designer/elsa-workflows-studio/src/drivers/single-line-driver.tsx +++ b/src/designer/elsa-workflows-studio/src/drivers/single-line-driver.tsx @@ -5,8 +5,8 @@ import {getOrCreateProperty} from "../utils/utils"; export class SingleLineDriver implements PropertyDisplayDriver { - display(activity: ActivityModel, property: ActivityPropertyDescriptor) { + display(activity: ActivityModel, property: ActivityPropertyDescriptor, onUpdated?: () => void, isEncypted?: boolean) { const prop = getOrCreateProperty(activity, property.name); - return ; + return ; } } diff --git a/src/designer/elsa-workflows-studio/src/modules/credential-manager/components/credential-manager-list-screen.tsx b/src/designer/elsa-workflows-studio/src/modules/credential-manager/components/credential-manager-list-screen.tsx index 73d30ba3a3..aab5bc560d 100644 --- a/src/designer/elsa-workflows-studio/src/modules/credential-manager/components/credential-manager-list-screen.tsx +++ b/src/designer/elsa-workflows-studio/src/modules/credential-manager/components/credential-manager-list-screen.tsx @@ -4,7 +4,7 @@ import { eventBus } from "../../.."; import { WebhookDefinitionSummary } from "../../elsa-webhooks/models"; import Tunnel from "../../../data/dashboard"; import { createElsaSecretsClient, ElsaSecretsClient } from "../services/credential-manager.client"; -import { SecretDescriptor, SecretModel } from "../models/secret.model"; +import { SecretDefinitionProperty, SecretDescriptor, SecretModel } from "../models/secret.model"; import { SecretEventTypes } from "../models/secret.events"; @Component({ @@ -68,13 +68,14 @@ export class CredentialManagerListScreen { await this.showSecretEditorInternal(secretModel, true); } - mapProperties(properties) { + mapProperties(properties: SecretDefinitionProperty[]) { return properties.map(prop => { return { expressions: { Literal: prop.expressions.Literal }, - name: prop.name + name: prop.name, + isEncrypted: prop.isEncrypted } }); } diff --git a/src/designer/elsa-workflows-studio/src/modules/credential-manager/elsa-secret-editor-modal/elsa-secret-editor-modal.tsx b/src/designer/elsa-workflows-studio/src/modules/credential-manager/elsa-secret-editor-modal/elsa-secret-editor-modal.tsx index 49bebbd5b2..6f741de1e4 100644 --- a/src/designer/elsa-workflows-studio/src/modules/credential-manager/elsa-secret-editor-modal/elsa-secret-editor-modal.tsx +++ b/src/designer/elsa-workflows-studio/src/modules/credential-manager/elsa-secret-editor-modal/elsa-secret-editor-modal.tsx @@ -6,7 +6,7 @@ import { loadTranslations } from "../../../components/i18n/i18n-loader"; import { eventBus, propertyDisplayManager } from "../../../services"; import { FormContext, textInput } from "../../../utils/forms"; import secretState from "../utils/secret.store"; -import { SecretDescriptor, SecretEditorRenderProps, SecretModel, SecretPropertyDescriptor } from "../models/secret.model"; +import { SecretDefinitionProperty, SecretDescriptor, SecretEditorRenderProps, SecretModel, SecretPropertyDescriptor } from "../models/secret.model"; import { SecretEventTypes } from "../models/secret.events"; import state from '../../../utils/store'; import { SyntaxNames } from '../../../models'; @@ -146,6 +146,7 @@ export class ElsaSecretEditorModal { }; onShowSecretEditor = async (secret: SecretModel, animate: boolean) => { + this.secretModel = JSON.parse(JSON.stringify(secret)); this.secretDescriptor = secretState.secretsDescriptors.find(x => x.type == secret.type); this.formContext = new FormContext(this.secretModel, newValue => this.secretModel = newValue); @@ -250,11 +251,21 @@ export class ElsaSecretEditorModal { ); } + propertyChanged(property: SecretDefinitionProperty) { + this.updateCounter++ + + if (property?.isEncrypted) { + property.isEncrypted = false; + } + } + renderPropertyEditor(secret: SecretModel, property: SecretPropertyDescriptor) { + var propertyValue = secret.properties.find(x => x.name === property.name); + const key = `secret-property-input:${secret.id}:${property.name}`; - const display = propertyDisplayManager.display(secret, property); + const display = propertyDisplayManager.display(secret, property, null, propertyValue?.isEncrypted); const id = `${property.name}Control`; - return this.updateCounter++}/>; + return this.propertyChanged(propertyValue)}/>; } } diff --git a/src/designer/elsa-workflows-studio/src/modules/credential-manager/models/secret.model.ts b/src/designer/elsa-workflows-studio/src/modules/credential-manager/models/secret.model.ts index 0e33fdaaa8..b2cc7f15b1 100644 --- a/src/designer/elsa-workflows-studio/src/modules/credential-manager/models/secret.model.ts +++ b/src/designer/elsa-workflows-studio/src/modules/credential-manager/models/secret.model.ts @@ -23,6 +23,7 @@ export interface SecretDefinitionProperty { syntax?: string; expressions: Map; value?: any; + isEncrypted?: boolean; } export interface SecretDescriptor { diff --git a/src/designer/elsa-workflows-studio/src/services/property-display-driver.ts b/src/designer/elsa-workflows-studio/src/services/property-display-driver.ts index a3b1d6e09c..6078b6e4ac 100644 --- a/src/designer/elsa-workflows-studio/src/services/property-display-driver.ts +++ b/src/designer/elsa-workflows-studio/src/services/property-display-driver.ts @@ -2,7 +2,7 @@ import {SecretModel, SecretPropertyDescriptor} from "../modules/credential-manager/models/secret.model"; export interface PropertyDisplayDriver { - display(model: ActivityModel | SecretModel, property: ActivityPropertyDescriptor | SecretPropertyDescriptor, onUpdated?: () => void) + display(model: ActivityModel | SecretModel, property: ActivityPropertyDescriptor | SecretPropertyDescriptor, onUpdated?: () => void, isEncrypted?: boolean) update?(model: ActivityModel | SecretModel, property: ActivityPropertyDescriptor | SecretPropertyDescriptor, form: FormData) } diff --git a/src/designer/elsa-workflows-studio/src/services/property-display-manager.ts b/src/designer/elsa-workflows-studio/src/services/property-display-manager.ts index 0d7471cfc6..54e44e92c7 100644 --- a/src/designer/elsa-workflows-studio/src/services/property-display-manager.ts +++ b/src/designer/elsa-workflows-studio/src/services/property-display-manager.ts @@ -24,9 +24,9 @@ export class PropertyDisplayManager { this.drivers[controlType] = driverFactory; } - display(model: ActivityModel | SecretModel, property: ActivityPropertyDescriptor | SecretPropertyDescriptor, onUpdated?: () => void) { + display(model: ActivityModel | SecretModel, property: ActivityPropertyDescriptor | SecretPropertyDescriptor, onUpdated?: () => void, isEncrypted?: boolean) { const driver = this.getDriver(property.uiHint); - return driver.display(model, property, onUpdated); + return driver.display(model, property, onUpdated, isEncrypted); } update(model: ActivityModel | SecretModel, property: ActivityPropertyDescriptor | SecretPropertyDescriptor, form: FormData) { diff --git a/src/modules/secrets/Elsa.Secrets.Api/Endpoints/OAuth2/Callback.cs b/src/modules/secrets/Elsa.Secrets.Api/Endpoints/OAuth2/Callback.cs index 319ca667f0..55c87467de 100644 --- a/src/modules/secrets/Elsa.Secrets.Api/Endpoints/OAuth2/Callback.cs +++ b/src/modules/secrets/Elsa.Secrets.Api/Endpoints/OAuth2/Callback.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Elsa.Secrets.Extensions; using Elsa.Secrets.Http.Services; +using Elsa.Secrets.Manager; using Elsa.Secrets.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; @@ -14,13 +15,13 @@ namespace Elsa.Secrets.Api.Endpoints.OAuth2 [Produces("application/json")] public class SetAuthCodeCallback : Controller { - private readonly ISecretsStore _secretsStore; + private readonly ISecretsManager _secretsManager; private readonly IOAuth2TokenService _tokenService; private readonly IConfiguration _configuration; - public SetAuthCodeCallback(ISecretsStore secretsStore, IOAuth2TokenService tokenService, IConfiguration configuration) + public SetAuthCodeCallback(ISecretsManager secretsManager, IOAuth2TokenService tokenService, IConfiguration configuration) { - _secretsStore = secretsStore; + _secretsManager = secretsManager; _tokenService = tokenService; _configuration = configuration; } @@ -28,7 +29,7 @@ public SetAuthCodeCallback(ISecretsStore secretsStore, IOAuth2TokenService token [HttpGet] public async Task Handle(string state, string code, CancellationToken cancellationToken = default) { - var secret = await _secretsStore.FindByIdAsync(state, cancellationToken); + var secret = await _secretsManager.GetSecretById(state, cancellationToken); if (secret == null) return NotFound(); diff --git a/src/modules/secrets/Elsa.Secrets.Api/Endpoints/OAuth2/GetUrl.cs b/src/modules/secrets/Elsa.Secrets.Api/Endpoints/OAuth2/GetUrl.cs index 2a4975b77a..182abf0f7e 100644 --- a/src/modules/secrets/Elsa.Secrets.Api/Endpoints/OAuth2/GetUrl.cs +++ b/src/modules/secrets/Elsa.Secrets.Api/Endpoints/OAuth2/GetUrl.cs @@ -4,9 +4,8 @@ using System.Threading; using System.Threading.Tasks; using System.Web; -using Elsa.Secrets.Extensions; +using Elsa.Secrets.Manager; using Elsa.Secrets.Models; -using Elsa.Secrets.Persistence; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; @@ -19,12 +18,12 @@ namespace Elsa.Secrets.Api.Endpoints.OAuth2; [Produces(MediaTypeNames.Application.Json)] public class GetUrl : Controller { - private readonly ISecretsStore _secretStore; + private readonly ISecretsManager _secretsManager; private readonly IConfiguration _configuration; - public GetUrl(ISecretsStore secretStore, IConfiguration configuration) + public GetUrl(ISecretsManager secretsManager, IConfiguration configuration) { - _secretStore = secretStore; + _secretsManager = secretsManager; _configuration = configuration; } @@ -32,7 +31,7 @@ public GetUrl(ISecretsStore secretStore, IConfiguration configuration) [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] public async Task>> Handle(string secretId, CancellationToken cancellationToken = default) { - var secret = await _secretStore.FindByIdAsync(secretId, cancellationToken); + var secret = await _secretsManager.GetSecretById(secretId, cancellationToken); if (secret == null) return NotFound(); diff --git a/src/modules/secrets/Elsa.Secrets.Api/Endpoints/Secrets/List.cs b/src/modules/secrets/Elsa.Secrets.Api/Endpoints/Secrets/List.cs index ac67b5bacb..1f8c3efb0e 100644 --- a/src/modules/secrets/Elsa.Secrets.Api/Endpoints/Secrets/List.cs +++ b/src/modules/secrets/Elsa.Secrets.Api/Endpoints/Secrets/List.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Elsa.Persistence.Specifications; +using Elsa.Secrets.Manager; using Elsa.Secrets.Models; using Elsa.Secrets.Persistence; using Microsoft.AspNetCore.Http; @@ -16,18 +17,17 @@ namespace Elsa.Secrets.Api.Endpoints.Secrets [Produces(MediaTypeNames.Application.Json)] public class List : Controller { - private readonly ISecretsStore _secretStore; - public List(ISecretsStore secretStore) - { - _secretStore = secretStore; + private readonly ISecretsManager _secretsManager; + public List(ISecretsManager secretsManager) + { + _secretsManager = secretsManager; } [HttpGet] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] public async Task>> Handle(CancellationToken cancellationToken = default) { - var specification = Specification.Identity; - var items = await _secretStore.FindManyAsync(specification, cancellationToken: cancellationToken); + var items = await _secretsManager.GetSecretViewModels(cancellationToken); return Json(items); } diff --git a/src/modules/secrets/Elsa.Secrets.Api/Endpoints/Secrets/Save.cs b/src/modules/secrets/Elsa.Secrets.Api/Endpoints/Secrets/Save.cs index ec7e83a98c..6fac9ee1d8 100644 --- a/src/modules/secrets/Elsa.Secrets.Api/Endpoints/Secrets/Save.cs +++ b/src/modules/secrets/Elsa.Secrets.Api/Endpoints/Secrets/Save.cs @@ -1,8 +1,9 @@ +using System.Linq; using System.Threading; using System.Threading.Tasks; using Elsa.Secrets.Api.Models; +using Elsa.Secrets.Manager; using Elsa.Secrets.Models; -using Elsa.Secrets.Persistence; using Microsoft.AspNetCore.Mvc; namespace Elsa.Secrets.Api.Endpoints.Secrets @@ -13,10 +14,10 @@ namespace Elsa.Secrets.Api.Endpoints.Secrets [Produces("application/json")] public class Save : Controller { - private readonly ISecretsStore _secretsStore; - public Save(ISecretsStore secretsStore) + private readonly ISecretsManager _secretsManager; + public Save(ISecretsManager secretsManager) { - _secretsStore = secretsStore; + _secretsManager = secretsManager; } [HttpPost] @@ -27,14 +28,10 @@ public async Task> Handle([FromBody] SaveSecretRequet reque Id = request.SecretId, DisplayName = request.Name, Name = request.Name, - Type = request.Type, + Type = request.Type, Properties = request.Properties }; - - if (model.Id == null) - await _secretsStore.AddAsync(model); - else - await _secretsStore.UpdateAsync(model); + model = await _secretsManager.AddOrUpdateSecret(model, true, cancellationToken); return Ok(model); } diff --git a/src/modules/secrets/Elsa.Secrets.Http/Services/OAuth2TokenService.cs b/src/modules/secrets/Elsa.Secrets.Http/Services/OAuth2TokenService.cs index b37843cfba..397221d595 100644 --- a/src/modules/secrets/Elsa.Secrets.Http/Services/OAuth2TokenService.cs +++ b/src/modules/secrets/Elsa.Secrets.Http/Services/OAuth2TokenService.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Elsa.Secrets.Http.Extensions; using Elsa.Secrets.Http.Models; +using Elsa.Secrets.Manager; using Elsa.Secrets.Models; using Elsa.Secrets.Persistence; using Microsoft.Extensions.Logging; @@ -24,13 +25,13 @@ public class OAuth2TokenService : IOAuth2TokenService { private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; - private readonly ISecretsStore _secretsStore; + private readonly ISecretsManager _secretsManager; - public OAuth2TokenService(IHttpClientFactory httpClientFactory, ILogger logger, ISecretsStore secretsStore) + public OAuth2TokenService(IHttpClientFactory httpClientFactory, ILogger logger, ISecretsManager secretsManager) { _logger = logger; _httpClientFactory = httpClientFactory; - _secretsStore = secretsStore; + _secretsManager = secretsManager; } private static string Base64Encode(string plainText) @@ -89,7 +90,7 @@ public async Task GetToken(Secret secret, string? authCode, strin var tokenData = result.ToTokenData(); secret.AddOrUpdateProperty("Token", JsonConvert.SerializeObject(tokenData)); - await _secretsStore.UpdateAsync(secret); + await _secretsManager.AddOrUpdateSecret(secret); return result; } @@ -131,7 +132,7 @@ public async Task GetTokenByRefreshToken(Secret secret, string re if (response.StatusCode == HttpStatusCode.Unauthorized || !string.IsNullOrEmpty(result?.Error)) { secret.RemoveProperty("Token"); - await _secretsStore.UpdateAsync(secret); + await _secretsManager.AddOrUpdateSecret(secret); throw new Exception("OAuth2 refresh token has expired - credential must be authorized with OAuth2 provider"); } @@ -146,7 +147,7 @@ public async Task GetTokenByRefreshToken(Secret secret, string re tokenData.RefreshToken = refreshToken; } secret.AddOrUpdateProperty("Token", JsonConvert.SerializeObject(tokenData)); - await _secretsStore.UpdateAsync(secret); + await _secretsManager.AddOrUpdateSecret(secret); return result; } diff --git a/src/modules/secrets/Elsa.Secrets.Http/ValueFormatters/OAuth2SecretValueFormatter.cs b/src/modules/secrets/Elsa.Secrets.Http/ValueFormatters/OAuth2SecretValueFormatter.cs index 60ba0fd353..1b464802d7 100644 --- a/src/modules/secrets/Elsa.Secrets.Http/ValueFormatters/OAuth2SecretValueFormatter.cs +++ b/src/modules/secrets/Elsa.Secrets.Http/ValueFormatters/OAuth2SecretValueFormatter.cs @@ -41,5 +41,10 @@ public async Task FormatSecretValue(Secret secret) return $"{response.TokenType ?? "Bearer"} {response.AccessToken}"; } + + public bool IsSecretValueSensitiveData(Secret secret) + { + return false; + } } } \ No newline at end of file diff --git a/src/modules/secrets/Elsa.Secrets.Persistence.EntityFramework.Core/EntityFrameworkSecretsStartupBase.cs b/src/modules/secrets/Elsa.Secrets.Persistence.EntityFramework.Core/EntityFrameworkSecretsStartupBase.cs index de4044e222..2ec37fe31a 100644 --- a/src/modules/secrets/Elsa.Secrets.Persistence.EntityFramework.Core/EntityFrameworkSecretsStartupBase.cs +++ b/src/modules/secrets/Elsa.Secrets.Persistence.EntityFramework.Core/EntityFrameworkSecretsStartupBase.cs @@ -35,7 +35,7 @@ public override void ConfigureElsa(ElsaOptionsBuilder elsa, IConfiguration confi secretsOptionsBuilder.UseEntityFrameworkPersistence(options => Configure(options, connectionString)); services.AddScoped(sp => secretsOptionsBuilder.SecretsOptions.SecretsStoreFactory(sp)); - elsa.AddSecrets(); + elsa.AddSecrets(configuration); } protected virtual string GetDefaultConnectionString() => throw new Exception($"No connection string specified for the {ProviderName} provider"); diff --git a/src/modules/secrets/Elsa.Secrets.Persistence.MongoDb/Startup.cs b/src/modules/secrets/Elsa.Secrets.Persistence.MongoDb/Startup.cs index 449b880412..4e65225d03 100644 --- a/src/modules/secrets/Elsa.Secrets.Persistence.MongoDb/Startup.cs +++ b/src/modules/secrets/Elsa.Secrets.Persistence.MongoDb/Startup.cs @@ -33,7 +33,7 @@ public override void ConfigureElsa(ElsaOptionsBuilder elsa, IConfiguration confi secretsOptionsBuilder.UseSecretsMongoDbPersistence(options => options.ConnectionString = connectionString); services.AddScoped(sp => secretsOptionsBuilder.SecretsOptions.SecretsStoreFactory(sp)); - elsa.AddSecrets(); + elsa.AddSecrets(configuration); } } } \ No newline at end of file diff --git a/src/modules/secrets/Elsa.Secrets/Encryption/AesEncryption.cs b/src/modules/secrets/Elsa.Secrets/Encryption/AesEncryption.cs new file mode 100644 index 0000000000..beaab5a8e5 --- /dev/null +++ b/src/modules/secrets/Elsa.Secrets/Encryption/AesEncryption.cs @@ -0,0 +1,55 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace Elsa.Secrets.Encryption; + +public class AesEncryption +{ + private static byte[] Encrypt(byte[] input, ICryptoTransform cryptoTransform) + { + using var ms = new MemoryStream(); + using (var cs = new CryptoStream(ms, cryptoTransform, CryptoStreamMode.Write)) + { + cs.Write(input, 0, input.Length); + cs.Close(); + } + + return ms.ToArray(); + } + + private static Aes CreateAes(string encryptionKey) + { + var encryptor = Aes.Create(); + encryptor.Mode = CipherMode.ECB; + encryptor.Key = Encoding.UTF8.GetBytes(encryptionKey); + return encryptor; + } + + public static string Encrypt(string encryptionKey, string input) + { + using var encryptor = CreateAes(encryptionKey); + + var bytes = Encoding.Unicode.GetBytes(input); + var result = Encrypt(bytes, encryptor.CreateEncryptor()); + return Convert.ToBase64String(result); + } + + public static string Decrypt(string encryptionKey, string input) + { + using var encryptor = CreateAes(encryptionKey); + + try + { + var bytes = Convert.FromBase64String(input); + var result = Encrypt(bytes, encryptor.CreateDecryptor()); + return Encoding.Unicode.GetString(result); + } + catch (FormatException) + { + //In case the value is for some reason not a base64 string, we don't want to block the subsequent operations + return input; + } + } +} \ No newline at end of file diff --git a/src/modules/secrets/Elsa.Secrets/Extensions/SecretsOptionsBuilderExtensions.cs b/src/modules/secrets/Elsa.Secrets/Extensions/SecretsOptionsBuilderExtensions.cs index 7d65ea263b..a53e456c6d 100644 --- a/src/modules/secrets/Elsa.Secrets/Extensions/SecretsOptionsBuilderExtensions.cs +++ b/src/modules/secrets/Elsa.Secrets/Extensions/SecretsOptionsBuilderExtensions.cs @@ -2,17 +2,19 @@ using Elsa.Options; using Elsa.Secrets.Handlers; using Elsa.Secrets.Manager; +using Elsa.Secrets.Options; using Elsa.Secrets.Persistence; using Elsa.Secrets.Persistence.Decorators; using Elsa.Secrets.Providers; using Elsa.Secrets.ValueFormatters; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Secrets.Extensions { public static class SecretsOptionsBuilderExtensions { - public static ElsaOptionsBuilder AddSecrets(this ElsaOptionsBuilder elsaOptions) + public static ElsaOptionsBuilder AddSecrets(this ElsaOptionsBuilder elsaOptions, IConfiguration configuration) { elsaOptions.Services .AddScoped() @@ -26,6 +28,8 @@ public static ElsaOptionsBuilder AddSecrets(this ElsaOptionsBuilder elsaOptions) elsaOptions.Services .TryAddProvider(ServiceLifetime.Scoped); + elsaOptions.Services.Configure(configuration.GetSection("Elsa:Features:Secrets")); + return elsaOptions; } } diff --git a/src/modules/secrets/Elsa.Secrets/Handlers/ValidatePropertyStoringHandler.cs b/src/modules/secrets/Elsa.Secrets/Handlers/ValidatePropertyStoringHandler.cs new file mode 100644 index 0000000000..464b986c47 --- /dev/null +++ b/src/modules/secrets/Elsa.Secrets/Handlers/ValidatePropertyStoringHandler.cs @@ -0,0 +1,47 @@ +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Elsa.Events; +using Elsa.Secrets.Providers; +using Elsa.Services.Workflows; +using MediatR; + +namespace Elsa.Secrets.Handlers; + +public class SerializingPropertyHandler : INotificationHandler +{ + private readonly Regex _fullyQualifiedName = new Regex("(?[^:]+):(?.*)", RegexOptions.IgnoreCase | RegexOptions.Singleline); + private readonly ISecretsProvider _secretsProvider; + + public SerializingPropertyHandler(ISecretsProvider secretsProvider) + { + _secretsProvider = secretsProvider; + } + + public async Task Handle(SerializingProperty notification, CancellationToken cancellationToken) + { + var propProvider = notification.WorkflowBlueprint.ActivityPropertyProviders.GetProvider(notification.ActivityId, notification.PropertyName); + var expressionProvider = propProvider as ExpressionActivityPropertyValueProvider; + + if (expressionProvider is not { Syntax: "Secret" }) + { + return; + } + + Match m; + if ((m = _fullyQualifiedName.Match(expressionProvider.Expression)).Success) + { + if (await _secretsProvider.IsSecretValueSensitiveData(m.Groups["Type"].Value, m.Groups["Name"].Value)) + { + notification.PreventSerialization(); + } + } + else + { + if (await _secretsProvider.IsSecretValueSensitiveData(expressionProvider.Expression)) + { + notification.PreventSerialization(); + } + } + } +} \ No newline at end of file diff --git a/src/modules/secrets/Elsa.Secrets/Manager/ISecretsManager.cs b/src/modules/secrets/Elsa.Secrets/Manager/ISecretsManager.cs index 7c3206b8f6..185f117634 100644 --- a/src/modules/secrets/Elsa.Secrets/Manager/ISecretsManager.cs +++ b/src/modules/secrets/Elsa.Secrets/Manager/ISecretsManager.cs @@ -33,6 +33,19 @@ public interface ISecretsManager /// Type of secrets. /// /// - Task> GetSecrets(string type, CancellationToken cancellationToken = default); + Task> GetSecrets(string type, bool decrypt = true, CancellationToken cancellationToken = default); + /// + /// Get view models of all secrets + /// + /// + /// + Task> GetSecretViewModels(CancellationToken cancellationToken = default); + /// + /// Add or update secret if it exists + /// + /// Secret to be saved + /// In case of saving from frontend, properties will be be hidden and must be restored + /// The saved model, which may differ from the input + Task AddOrUpdateSecret(Secret secret, bool restoreHiddenProperties = false, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/src/modules/secrets/Elsa.Secrets/Manager/SecretsManager.cs b/src/modules/secrets/Elsa.Secrets/Manager/SecretsManager.cs index 1da1a3402c..1926a56f5d 100644 --- a/src/modules/secrets/Elsa.Secrets/Manager/SecretsManager.cs +++ b/src/modules/secrets/Elsa.Secrets/Manager/SecretsManager.cs @@ -1,3 +1,4 @@ +using System; using Elsa.Persistence.Specifications; using Elsa.Secrets.Models; using Elsa.Secrets.Persistence; @@ -7,45 +8,176 @@ using System.Threading.Tasks; using Elsa.Secrets.Persistence.Specifications; using System.Linq; +using Elsa.Secrets.Encryption; +using Elsa.Secrets.Options; +using Microsoft.Extensions.Options; namespace Elsa.Secrets.Manager { public class SecretsManager : ISecretsManager { private readonly ISecretsStore _secretsStore; - public SecretsManager(ISecretsStore secretsStore) - { + private readonly bool _encryptionEnabled; + private readonly string _encryptionKey; + private readonly string[] _encryptedProperties; + + public SecretsManager(ISecretsStore secretsStore, IOptions options) + { _secretsStore = secretsStore; + + _encryptionEnabled = options.Value.Enabled ?? false; + _encryptionKey = options.Value.EncryptionKey; + _encryptedProperties = options.Value.EncryptedProperties; } public async Task GetSecretById(string id, CancellationToken cancellationToken = default) { var specification = new SecretsIdSpecification(id); var secret = await _secretsStore.FindAsync(specification, cancellationToken: cancellationToken); + DecryptProperties(secret); return secret; } public async Task GetSecretByName(string name, CancellationToken cancellationToken = default) { var specification = new SecretsNameSpecification(name); - var secret = await _secretsStore.FindManyAsync(specification, OrderBySpecification.OrderBy(s => s.Type), cancellationToken: cancellationToken); - - return secret.FirstOrDefault(); ; + var secrets = await _secretsStore.FindManyAsync(specification, OrderBySpecification.OrderBy(s => s.Type), cancellationToken: cancellationToken); + var secret = secrets.FirstOrDefault(); + DecryptProperties(secret); + + return secret; } public async Task> GetSecrets(CancellationToken cancellationToken = default) { var specification = Specification.Identity; var secrets = await _secretsStore.FindManyAsync(specification, cancellationToken: cancellationToken); + foreach (var secret in secrets) + { + DecryptProperties(secret); + } return secrets; } + + public async Task> GetSecretViewModels(CancellationToken cancellationToken = default) + { + var specification = Specification.Identity; + var secrets = await _secretsStore.FindManyAsync(specification, cancellationToken: cancellationToken); + foreach (var secret in secrets) + { + HideEncryptedProperties(secret); + } - public async Task> GetSecrets(string type, CancellationToken cancellationToken = default) + return secrets; + } + + public async Task> GetSecrets(string type, bool decrypt = true, CancellationToken cancellationToken = default) { var specification = new SecretTypeSpecification(type); var secrets = await _secretsStore.FindManyAsync(specification, cancellationToken: cancellationToken); + + if (decrypt) + { + foreach (var secret in secrets) + { + DecryptProperties(secret); + } + } + return secrets; } + + public async Task AddOrUpdateSecret(Secret secret, bool restoreHiddenProperties, CancellationToken cancellationToken = default) + { + var clone = secret.Clone() as Secret; + + if (restoreHiddenProperties) + { + await RestoreHiddenProperties(clone, cancellationToken); + } + EncryptProperties(clone); + + if (clone.Id == null) + await _secretsStore.AddAsync(clone); + else + await _secretsStore.UpdateAsync(clone); + return clone; + } + + private async Task RestoreHiddenProperties(Secret secret, CancellationToken cancellationToken) + { + var specification = new SecretsIdSpecification(secret.Id); + var existingSecret = await _secretsStore.FindAsync(specification, cancellationToken: cancellationToken); + if (existingSecret != null) + { + foreach (var property in secret.Properties) + { + if (property.IsEncrypted) + { + property.Expressions = existingSecret.Properties.First(x => x.Name == property.Name).Expressions; + } + } + } + } + + private void HideEncryptedProperties(Secret secret) + { + foreach (var secretProperty in secret.Properties) + { + if (!secretProperty.IsEncrypted) continue; + foreach (var key in secretProperty.Expressions.Keys) + { + secretProperty.Expressions[key] = new string('*', 8); + } + } + } + + private void EncryptProperties(Secret secret) + { + if (!_encryptionEnabled) + { + return; + } + foreach (var property in secret.Properties) + { + var encrypt = _encryptedProperties.Contains(property.Name, StringComparer.OrdinalIgnoreCase); + if (!encrypt || property.IsEncrypted) + { + continue; + } + + foreach (var key in property.Expressions.Keys) + { + var value = property.Expressions[key]; + property.Expressions[key] = AesEncryption.Encrypt(_encryptionKey, value); + } + + property.IsEncrypted = true; + } + } + + private void DecryptProperties(Secret secret) + { + if (!_encryptionEnabled) + { + return; + } + foreach (var property in secret.Properties) + { + if (!property.IsEncrypted) + { + continue; + } + + foreach (var key in property.Expressions.Keys) + { + var value = property.Expressions[key]; + property.Expressions[key] = AesEncryption.Decrypt(_encryptionKey, value); + } + + property.IsEncrypted = false; + } + } } } diff --git a/src/modules/secrets/Elsa.Secrets/Models/Secret.cs b/src/modules/secrets/Elsa.Secrets/Models/Secret.cs index 6dfea4a505..e079e5a249 100644 --- a/src/modules/secrets/Elsa.Secrets/Models/Secret.cs +++ b/src/modules/secrets/Elsa.Secrets/Models/Secret.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Elsa.Expressions; @@ -5,13 +6,13 @@ namespace Elsa.Secrets.Models { - public class Secret : Entity + public class Secret : Entity, ICloneable { public string Type { get; set; } = default!; public string Name { get; set; } = null!; public string? DisplayName { get; set; } public ICollection Properties { get; set; } - + public string? GetProperty(string name, string syntax = SyntaxNames.Literal) => Properties?.FirstOrDefault(r => r.Name == name)?.GetExpression(syntax); public void AddOrUpdateProperty(string name, string value, string syntax = SyntaxNames.Literal) @@ -29,5 +30,17 @@ public void RemoveProperty(string name) Properties.Remove(property); } } + + public object Clone() + { + return new Secret + { + Id = Id, + Type = Type, + Name = Name, + DisplayName = DisplayName, + Properties = Properties.Select(x => x.Clone() as SecretProperty).ToList() + }; + } } } diff --git a/src/modules/secrets/Elsa.Secrets/Models/SecretProperty.cs b/src/modules/secrets/Elsa.Secrets/Models/SecretProperty.cs index 1f4b502125..bd3fa34b90 100644 --- a/src/modules/secrets/Elsa.Secrets/Models/SecretProperty.cs +++ b/src/modules/secrets/Elsa.Secrets/Models/SecretProperty.cs @@ -1,8 +1,10 @@ +using System; using System.Collections.Generic; +using System.Linq; namespace Elsa.Secrets.Models { - public class SecretProperty + public class SecretProperty : ICloneable { public static SecretProperty Literal(string name, string expression) => new(name, CreateSingleExpression("Literal", expression), null); public static SecretProperty Liquid(string name, string expression) => new(name, CreateSingleExpression("Liquid", expression), "Liquid"); @@ -19,6 +21,8 @@ public SecretProperty(string name, IDictionary expressions, str Expressions = expressions; Syntax = syntax; } + + public bool IsEncrypted { get; set; } /// /// The name of the property. @@ -42,5 +46,16 @@ public SecretProperty(string name, IDictionary expressions, str public override string ToString() => this.Name; + + public object Clone() + { + return new SecretProperty + { + Name = Name, + Syntax = Syntax, + Expressions = Expressions.ToDictionary(x => x.Key, x=> x.Value), + IsEncrypted = IsEncrypted + }; + } } } diff --git a/src/modules/secrets/Elsa.Secrets/Options/SecretsConfigOptions.cs b/src/modules/secrets/Elsa.Secrets/Options/SecretsConfigOptions.cs new file mode 100644 index 0000000000..5ec9146649 --- /dev/null +++ b/src/modules/secrets/Elsa.Secrets/Options/SecretsConfigOptions.cs @@ -0,0 +1,11 @@ +namespace Elsa.Secrets.Options; + +public class SecretsConfigOptions +{ + public bool? Enabled { get; set; } + public bool? EncryptionEnabled { get; set; } + public string? EncryptionKey { get; set; } + public string[]? EncryptedProperties { get; set; } + public string? ConnectionStringIdentifier { get; set; } + public string? ConnectionString { get; set; } +} \ No newline at end of file diff --git a/src/modules/secrets/Elsa.Secrets/Providers/ISecretsProvider.cs b/src/modules/secrets/Elsa.Secrets/Providers/ISecretsProvider.cs index 531b77d2de..5c8e22a84d 100644 --- a/src/modules/secrets/Elsa.Secrets/Providers/ISecretsProvider.cs +++ b/src/modules/secrets/Elsa.Secrets/Providers/ISecretsProvider.cs @@ -32,6 +32,19 @@ public interface ISecretsProvider /// Task GetSecretByNameAsync(string type, string name); /// + /// Get whether the secret contains sensitive data + /// + /// Type of secret to retrieve. + /// Name of secret. + /// + Task IsSecretValueSensitiveData(string type, string name); + /// + /// Get whether the secret contains sensitive data + /// + /// Name of secret. + /// + Task IsSecretValueSensitiveData(string name); + /// /// List all secrets for specific type as name value dictionary /// /// Type of secrets to retrieve. diff --git a/src/modules/secrets/Elsa.Secrets/Providers/SecretsProvider.cs b/src/modules/secrets/Elsa.Secrets/Providers/SecretsProvider.cs index 916fad1d5e..63a99329a0 100644 --- a/src/modules/secrets/Elsa.Secrets/Providers/SecretsProvider.cs +++ b/src/modules/secrets/Elsa.Secrets/Providers/SecretsProvider.cs @@ -72,12 +72,38 @@ public async Task> GetSecretsAsync(string type) public async Task> GetSecretsDictionaryAsync(string type) { - var secrets = await _secretsManager.GetSecrets(type); + var secrets = await _secretsManager.GetSecrets(type, false); return secrets .GroupBy(x => $"{type}:{x.Name}").Select(x => x.First()) .ToDictionary(x => $"{type}:{x.Name}", x => x.Name ?? x.DisplayName ?? x.Id); } + public async Task IsSecretValueSensitiveData(string type, string name) + { + var secrets = await _secretsManager.GetSecrets(type, false); + var formatter = _valueFormatters.FirstOrDefault(x => x.Type == type); + var secret = secrets.Where(x => x.Name?.Equals(name, StringComparison.InvariantCultureIgnoreCase) == true && x.Type?.Equals(type, StringComparison.InvariantCultureIgnoreCase) == true) + ?.FirstOrDefault(); + if (secret == null) + { + return false; + } + + return formatter?.IsSecretValueSensitiveData(secret) ?? false; + } + + public async Task IsSecretValueSensitiveData(string name) + { + var secret = await _secretsManager.GetSecretByName(name); + if (secret == null) + { + return false; + } + var formatter = _valueFormatters.FirstOrDefault(x => x.Type == secret.Type); + + return formatter?.IsSecretValueSensitiveData(secret) ?? false; + } + public async Task> GetSecretsForSelectListAsync(string type) => await GetSecretsDictionaryAsync(type); } diff --git a/src/modules/secrets/Elsa.Secrets/ValueFormatters/AuthorizationHeaderSecretValueFormatter.cs b/src/modules/secrets/Elsa.Secrets/ValueFormatters/AuthorizationHeaderSecretValueFormatter.cs index 4bc735753c..0aae85a5fa 100644 --- a/src/modules/secrets/Elsa.Secrets/ValueFormatters/AuthorizationHeaderSecretValueFormatter.cs +++ b/src/modules/secrets/Elsa.Secrets/ValueFormatters/AuthorizationHeaderSecretValueFormatter.cs @@ -11,6 +11,11 @@ public class AuthorizationHeaderSecretValueFormatter : ISecretValueFormatter public string Type => "Authorization"; public Task FormatSecretValue(Secret secret) => Task.FromResult(ConvertPropertiesToString(secret.Properties)); + public bool IsSecretValueSensitiveData(Secret secret) + { + var usedProperties = secret.Properties.Where(x => x.Expressions.Count > 0); + return usedProperties.Any(x => x.IsEncrypted); + } private static string ConvertPropertiesToString(ICollection properties) { diff --git a/src/modules/secrets/Elsa.Secrets/ValueFormatters/ISecretValueFormatter.cs b/src/modules/secrets/Elsa.Secrets/ValueFormatters/ISecretValueFormatter.cs index 0d7d618b6b..bc91a65005 100644 --- a/src/modules/secrets/Elsa.Secrets/ValueFormatters/ISecretValueFormatter.cs +++ b/src/modules/secrets/Elsa.Secrets/ValueFormatters/ISecretValueFormatter.cs @@ -7,5 +7,6 @@ public interface ISecretValueFormatter { string Type { get; } Task FormatSecretValue(Secret secret); + bool IsSecretValueSensitiveData(Secret secret); } } diff --git a/src/modules/secrets/Elsa.Secrets/ValueFormatters/SqlSecretValueFormatter.cs b/src/modules/secrets/Elsa.Secrets/ValueFormatters/SqlSecretValueFormatter.cs index 64e52bb407..abf8154b34 100644 --- a/src/modules/secrets/Elsa.Secrets/ValueFormatters/SqlSecretValueFormatter.cs +++ b/src/modules/secrets/Elsa.Secrets/ValueFormatters/SqlSecretValueFormatter.cs @@ -14,9 +14,18 @@ public abstract class SqlSecretValueFormatter : ISecretValueFormatter public virtual string SettingSeparator => ";"; - public Task FormatSecretValue(Secret secret) => Task.FromResult(ConvertPropertiesToString(secret.Properties, KeyValueSeparator, SettingSeparator)); + public Task FormatSecretValue(Secret secret) + { + return Task.FromResult(ConvertPropertiesToString(secret.Properties, KeyValueSeparator, SettingSeparator)); + } + + public bool IsSecretValueSensitiveData(Secret secret) + { + var usedProperties = secret.Properties.Where(x => x.Expressions.Count > 0); + return usedProperties.Any(x => x.IsEncrypted); + } - private static string ConvertPropertiesToString(ICollection properties, string keyValueSeparator, string settingSeparator) + private static string ConvertPropertiesToString(IEnumerable properties, string keyValueSeparator, string settingSeparator) { var sb = new StringBuilder(); diff --git a/src/samples/server/Elsa.Samples.Server.Host/appsettings.json b/src/samples/server/Elsa.Samples.Server.Host/appsettings.json index 15576b1dc1..ddf7607cc7 100644 --- a/src/samples/server/Elsa.Samples.Server.Host/appsettings.json +++ b/src/samples/server/Elsa.Samples.Server.Host/appsettings.json @@ -68,7 +68,10 @@ "ConnectionStringIdentifier": "Sqlite", "Sql": true, "Http": true, - "MySql": true + "MySql": true, + "EncryptionEnabled": false, + "EncryptedProperties": ["clientsecret", "clientid", "user id", "password", "user"], + "EncryptionKey": "" } }, diff --git a/src/server/Elsa.Server.Api/Services/WorkflowBlueprintMapper.cs b/src/server/Elsa.Server.Api/Services/WorkflowBlueprintMapper.cs index 020bac9b0d..79c6172788 100644 --- a/src/server/Elsa.Server.Api/Services/WorkflowBlueprintMapper.cs +++ b/src/server/Elsa.Server.Api/Services/WorkflowBlueprintMapper.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using AutoMapper; +using Elsa.Events; using Elsa.Exceptions; using Elsa.Metadata; using Elsa.Models; @@ -10,6 +11,7 @@ using Elsa.Server.Api.Mapping; using Elsa.Services; using Elsa.Services.Models; +using MediatR; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Server.Api.Services @@ -20,13 +22,15 @@ public class WorkflowBlueprintMapper : IWorkflowBlueprintMapper private readonly IActivityTypeService _activityTypeService; private readonly IMapper _mapper; private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IMediator _mediator; - public WorkflowBlueprintMapper(IWorkflowBlueprintReflector workflowBlueprintReflector, IActivityTypeService activityTypeService, IMapper mapper, IServiceScopeFactory serviceScopeFactory) + public WorkflowBlueprintMapper(IWorkflowBlueprintReflector workflowBlueprintReflector, IActivityTypeService activityTypeService, IMapper mapper, IServiceScopeFactory serviceScopeFactory, IMediator mediator) { _workflowBlueprintReflector = workflowBlueprintReflector; _activityTypeService = activityTypeService; _mapper = mapper; _serviceScopeFactory = serviceScopeFactory; + _mediator = mediator; } public async ValueTask MapAsync(IWorkflowBlueprint workflowBlueprint, CancellationToken cancellationToken = default) @@ -70,17 +74,23 @@ public async ValueTask MapAsync(IWorkflowBlueprint workf return (inputProperties, outputProperties); } - private static async Task GetPropertyValueAsync(IWorkflowBlueprint workflowBlueprint, IActivityBlueprintWrapper activityBlueprintWrapper, ActivityInputDescriptor propertyDescriptor, CancellationToken cancellationToken) + private async Task GetPropertyValueAsync(IWorkflowBlueprint workflowBlueprint, IActivityBlueprintWrapper activityBlueprintWrapper, ActivityInputDescriptor propertyDescriptor, CancellationToken cancellationToken) { if (propertyDescriptor.IsDesignerCritical) { - try - { - return await activityBlueprintWrapper.EvaluatePropertyValueAsync(propertyDescriptor.Name, cancellationToken); - } - catch (Exception e) + var serializingProperty = new SerializingProperty(workflowBlueprint, activityBlueprintWrapper.ActivityBlueprint.Id, propertyDescriptor.Name); + await _mediator.Publish(serializingProperty, cancellationToken); + + if (serializingProperty.CanSerialize) { - throw new WorkflowException("Failed to evaluate a designer-critical property value. Please make sure that the value does not rely on external context.", e); + try + { + return await activityBlueprintWrapper.EvaluatePropertyValueAsync(propertyDescriptor.Name, cancellationToken); + } + catch (Exception e) + { + throw new WorkflowException("Failed to evaluate a designer-critical property value. Please make sure that the value does not rely on external context.", e); + } } } diff --git a/test/unit/Elsa.UnitTests/Elsa.UnitTests.csproj b/test/unit/Elsa.UnitTests/Elsa.UnitTests.csproj index 4e5ec9b737..f6eff937ed 100644 --- a/test/unit/Elsa.UnitTests/Elsa.UnitTests.csproj +++ b/test/unit/Elsa.UnitTests/Elsa.UnitTests.csproj @@ -27,6 +27,7 @@ +