Skip to content

Commit

Permalink
Feature/encrypt secret properties (#4347)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
tanelkuhi committed Oct 17, 2023
1 parent b159d13 commit 39c3504
Show file tree
Hide file tree
Showing 37 changed files with 522 additions and 78 deletions.
21 changes: 21 additions & 0 deletions src/core/Elsa.Abstractions/Events/SerializingProperty.cs
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Elsa.Services.Models
{
public class ActivityBlueprintWrapper : IActivityBlueprintWrapper
{
protected ActivityExecutionContext ActivityExecutionContext { get; }
public ActivityExecutionContext ActivityExecutionContext { get; }

public ActivityBlueprintWrapper(ActivityExecutionContext activityExecutionContext)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace Elsa.Services.Models
{
public interface IActivityBlueprintWrapper
{
ActivityExecutionContext ActivityExecutionContext { get; }
IActivityBlueprint ActivityBlueprint { get; }
IActivityBlueprintWrapper<TActivity> As<TActivity>() where TActivity : IActivity;
ValueTask<object?> EvaluatePropertyValueAsync(string propertyName, CancellationToken cancellationToken = default);
Expand Down
18 changes: 17 additions & 1 deletion src/core/Elsa.Core/Handlers/PersistActivityPropertyState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ namespace Elsa.Handlers
public class PersistActivityPropertyState : INotificationHandler<ActivityExecuted>
{
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)
Expand All @@ -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<ActivityInputAttribute>();
var defaultProviderName = inputAttr.DefaultWorkflowStorageProvider;
Expand All @@ -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<ActivityOutputAttribute>();
var defaultProviderName = outputAttr.DefaultWorkflowStorageProvider;
Expand Down
35 changes: 28 additions & 7 deletions src/core/Elsa.Core/Services/Workflows/ActivityActivator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,27 @@
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
{
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<IActivity> ActivateActivityAsync(ActivityExecutionContext context, Type type, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -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)
/// <summary>
/// Recursively store activity's properties
/// </summary>
/// <param name="activity">The parent activity of all the activity properties</param>
/// <param name="nestedInstance">The activity or the recursively generated object from the activity's properties</param>
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<IMediator>();

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<ActivityPropertyAttributeBase>().First();
var providerName = propertyStorageProviderDictionary.GetItem(propertyName) ?? attr.DefaultWorkflowStorageProvider;
await _workflowStorageService.SaveAsync(providerName, workflowStorageContext, propertyName, value, cancellationToken);
Expand All @@ -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);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/designer/elsa-workflows-studio/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export namespace Components {
}
interface ElsaSingleLineProperty {
"activityModel": ActivityModel;
"isEncypted"?: boolean;
"propertyDescriptor": ActivityPropertyDescriptor;
"propertyModel": ActivityDefinitionProperty;
}
Expand Down Expand Up @@ -1148,6 +1149,7 @@ declare namespace LocalJSX {
}
interface ElsaSingleLineProperty {
"activityModel"?: ActivityModel;
"isEncypted"?: boolean;
"propertyDescriptor"?: ActivityPropertyDescriptor;
"propertyModel"?: ActivityDefinitionProperty;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -54,7 +63,7 @@ export class ElsaSingleLineProperty {
onDefaultSyntaxValueChanged={e => this.onDefaultSyntaxValueChanged(e)}
editor-height="5em"
single-line={true}>
<input type="text" id={fieldId} name={fieldName} value={value} onChange={e => this.onChange(e)}
<input type="text" id={fieldId} name={fieldName} value={value} onFocus={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}/>
</elsa-property-editor>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <elsa-single-line-property activityModel={activity} propertyDescriptor={property} propertyModel={prop}/>;
return <elsa-single-line-property activityModel={activity} propertyDescriptor={property} propertyModel={prop} isEncypted={isEncypted} />;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 <elsa-control key={key} id={id} class="sm:elsa-col-span-6" content={display} onChange={() => this.updateCounter++}/>;
return <elsa-control key={key} id={id} class="sm:elsa-col-span-6" content={display} onChange={() => this.propertyChanged(propertyValue)}/>;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface SecretDefinitionProperty {
syntax?: string;
expressions: Map<string>;
value?: any;
isEncrypted?: boolean;
}

export interface SecretDescriptor {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,21 +15,21 @@ 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;
}

[HttpGet]
public async Task<ActionResult> 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();
Expand Down
11 changes: 5 additions & 6 deletions src/modules/secrets/Elsa.Secrets.Api/Endpoints/OAuth2/GetUrl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,20 +18,20 @@ 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;
}

[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))]
public async Task<ActionResult<IEnumerable<Secret>>> 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();

Expand Down
Loading

0 comments on commit 39c3504

Please sign in to comment.