Validate workflow secret reference adoption

This commit is contained in:
Sipke Schoorstra 2026-06-01 09:49:32 +02:00
parent 4a6db8aa98
commit e85a40b620
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
3 changed files with 108 additions and 2 deletions

View file

@ -114,7 +114,6 @@ Start in [src/modules/Elsa.Secrets](../../src/modules/Elsa.Secrets).
- [ISecretManager](../../src/modules/Elsa.Secrets/Contracts/ISecretManager.cs): create, get, rotate, revoke, delete, and test secrets.
- [ISecretResolver](../../src/modules/Elsa.Secrets/Contracts/ISecretResolver.cs): resolve the latest active secret value by immutable technical name.
- [ISecretProvider](../../src/modules/Elsa.Secrets/Contracts/ISecretProvider.cs): legacy-compatible provider adapter backed by `ISecretResolver`.
- [ISecretStore](../../src/modules/Elsa.Secrets/Contracts/ISecretStore.cs) / [ISecretStoreRegistry](../../src/modules/Elsa.Secrets/Contracts/ISecretStoreRegistry.cs): pluggable backend stores.
- [ISecretTypeRegistry](../../src/modules/Elsa.Secrets/Contracts/ISecretTypeRegistry.cs): extensible secret types (text, RSA key, X.509 certificate reference).
- [ISecretRepository](../../src/modules/Elsa.Secrets/Contracts/ISecretRepository.cs): durable secret and version storage.
@ -150,11 +149,26 @@ Permission constants are in [SecretsPermissions](../../src/modules/Elsa.Secrets/
### Using Secrets In Workflows
Activities with sensitive inputs can use a `SecretReference` in place of a literal value. The runtime calls `ISecretResolver` at the point of use so workflow definitions store only the reference, not the resolved value.
Activities with sensitive inputs can use the `Secret` expression in place of a literal value. Studio presents this as a no-code picker for inputs marked as sensitive, such as the HTTP Request `Authorization` input. The picker stores a `SecretReference` with the secret name, optional type, and optional scope; it does not store the current secret value in the workflow definition.
At runtime, the `Secret` expression calls `ISecretResolver` at the point of use and returns the latest active version. Rotating a secret through `ISecretManager.RotateAsync` updates future workflow runs without editing workflow JSON. If a reference includes a type or scope, resolution must match those constraints; for example, a text token reference should not resolve an RSA key, and a `production` reference should not resolve a `development` secret. Expired, revoked, missing, or incompatible secrets fail with a non-secret error message.
Sensitive activity inputs are not written to activity state after evaluation. Prefer a `Secret` expression for credentials such as bearer tokens, API keys, passwords, and connection strings instead of literals, variables, logs, workflow outputs, incident messages, or custom headers that are not marked sensitive. Treat any custom activity input that can carry credentials as sensitive by setting `CanContainSecrets = true`.
JavaScript expressions can resolve secrets when the host enables `Elsa.Secrets.JavaScript`:
```javascript
const token = await getSecret("crm:token");
return `Bearer ${token}`;
```
`getSecret(name)` returns a `Promise<string>`, so JavaScript must either `await` it inside an async function/IIFE or compose it with `.then(...)`. Do not write resolved values to logs, variables, outputs, exceptions, or activity state. Use the `Secret` expression for simple no-code binding, and use `getSecret` only when a script needs to combine a secret with runtime data.
### Tests
- [test/unit/Elsa.Secrets.UnitTests](../../test/unit/Elsa.Secrets.UnitTests)
- [test/integration/Elsa.JavaScript.IntegrationTests](../../test/integration/Elsa.JavaScript.IntegrationTests)
- [test/integration/Elsa.Activities.IntegrationTests](../../test/integration/Elsa.Activities.IntegrationTests)
Spec: [specs/007-secrets-module/spec.md](../../specs/007-secrets-module/spec.md).

View file

@ -9,6 +9,7 @@
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared.Integration\Elsa.Testing.Shared.Integration.csproj" />
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Http\Elsa.Http.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Secrets\Elsa.Secrets.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
<ProjectReference Include="..\..\unit\Elsa.Activities.UnitTests\Elsa.Activities.UnitTests.csproj" />
</ItemGroup>

View file

@ -0,0 +1,91 @@
using System.Net;
using Elsa.Activities.UnitTests.Http.Helpers;
using Elsa.Expressions.Contracts;
using Elsa.Extensions;
using Elsa.Http;
using Elsa.Secrets.Contracts;
using Elsa.Secrets.Expressions;
using Elsa.Secrets.Models;
using Elsa.Secrets.Providers;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Management;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Activities.IntegrationTests.Http;
public class SendHttpRequestSecretExpressionTests(ITestOutputHelper testOutputHelper)
{
private const string SecretName = "api:authorization";
private const string SecretValue = "Bearer resolved-token";
[Fact(DisplayName = "SendHttpRequest resolves Authorization from Secret expression without persisting the secret")]
public async Task ResolvesAuthorizationSecretExpressionWithoutPersistingSecret()
{
var capturedRequests = new List<HttpRequestMessage>();
var fixture = CreateFixture(CreateCapturingHandler(capturedRequests));
var activity = new SendHttpRequest
{
Url = new(new Uri("https://api.example.com/secure")),
Method = new("GET"),
Authorization = new(SecretExpression.Create(new SecretReference(SecretName, SecretTypeNames.Text, "production"))),
ExpectedStatusCodes = new List<HttpStatusCodeCase>()
};
await fixture.BuildAsync();
var workflowJson = fixture.Services.GetRequiredService<IWorkflowSerializer>().Serialize(Workflow.FromActivity(activity));
Assert.Contains(SecretName, workflowJson);
Assert.DoesNotContain(SecretValue, workflowJson);
var result = await fixture.RunActivityAsync(activity);
var capturedRequest = Assert.Single(capturedRequests);
Assert.NotNull(capturedRequest.Headers.Authorization);
Assert.Equal(SecretValue, capturedRequest.Headers.Authorization.ToString());
var activityContext = Assert.Single(result.Journal.ActivityExecutionContexts, x => x.Activity == activity);
Assert.False(activityContext.ActivityState.ContainsKey(nameof(SendHttpRequestBase.Authorization)));
var workflowStateJson = fixture.Services.GetRequiredService<IWorkflowStateSerializer>().Serialize(result.WorkflowState);
Assert.DoesNotContain(SecretValue, workflowStateJson);
}
private WorkflowTestFixture CreateFixture(HttpMessageHandler handler)
{
return new WorkflowTestFixture(testOutputHelper)
.ConfigureServices(services =>
{
services.AddSingleton<ISecretResolver>(new TestSecretResolver());
services.AddSingleton<IExpressionDescriptorProvider, SecretExpressionDescriptorProvider>();
})
.ConfigureElsa(elsa => elsa.UseHttp(http =>
{
http.HttpClientBuilder = builder => builder.ConfigurePrimaryHttpMessageHandler(() => handler);
}));
}
private static HttpMessageHandler CreateCapturingHandler(ICollection<HttpRequestMessage> capturedRequests) =>
new TestHttpMessageHandler((request, _) =>
{
capturedRequests.Add(request);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
});
private class TestSecretResolver : ISecretResolver
{
public Task<string> ResolveAsync(string name, CancellationToken cancellationToken = default)
{
Assert.Equal(SecretName, name);
return Task.FromResult(SecretValue);
}
public Task<string> ResolveAsync(SecretReference reference, CancellationToken cancellationToken = default)
{
Assert.Equal(new SecretReference(SecretName, SecretTypeNames.Text, "production"), reference);
return Task.FromResult(SecretValue);
}
}
}