diff --git a/doc/wiki/identity-tenancy-security.md b/doc/wiki/identity-tenancy-security.md index 5c035c7dc..6fb45f09f 100644 --- a/doc/wiki/identity-tenancy-security.md +++ b/doc/wiki/identity-tenancy-security.md @@ -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`, 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). diff --git a/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj b/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj index 5f9082087..237b35e80 100644 --- a/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj +++ b/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj @@ -9,6 +9,7 @@ + diff --git a/test/integration/Elsa.Activities.IntegrationTests/Http/SendHttpRequestSecretExpressionTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Http/SendHttpRequestSecretExpressionTests.cs new file mode 100644 index 000000000..1a6a26bb4 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Http/SendHttpRequestSecretExpressionTests.cs @@ -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(); + 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() + }; + + await fixture.BuildAsync(); + + var workflowJson = fixture.Services.GetRequiredService().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().Serialize(result.WorkflowState); + Assert.DoesNotContain(SecretValue, workflowStateJson); + } + + private WorkflowTestFixture CreateFixture(HttpMessageHandler handler) + { + return new WorkflowTestFixture(testOutputHelper) + .ConfigureServices(services => + { + services.AddSingleton(new TestSecretResolver()); + services.AddSingleton(); + }) + .ConfigureElsa(elsa => elsa.UseHttp(http => + { + http.HttpClientBuilder = builder => builder.ConfigurePrimaryHttpMessageHandler(() => handler); + })); + } + + private static HttpMessageHandler CreateCapturingHandler(ICollection capturedRequests) => + new TestHttpMessageHandler((request, _) => + { + capturedRequests.Add(request); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + private class TestSecretResolver : ISecretResolver + { + public Task ResolveAsync(string name, CancellationToken cancellationToken = default) + { + Assert.Equal(SecretName, name); + return Task.FromResult(SecretValue); + } + + public Task ResolveAsync(SecretReference reference, CancellationToken cancellationToken = default) + { + Assert.Equal(new SecretReference(SecretName, SecretTypeNames.Text, "production"), reference); + return Task.FromResult(SecretValue); + } + } +}