diff --git a/docs/guides/Expressions.mdx b/docs/guides/Expressions.mdx new file mode 100644 index 0000000000..95e03637b9 --- /dev/null +++ b/docs/guides/Expressions.mdx @@ -0,0 +1,44 @@ +--- +title: "Expressions" +sidebar: + order: 2 +--- + +SuperPlane components evaluate expressions to compute dynamic field values, route events, and reference data from other nodes in the canvas. + +## Where expressions are evaluated + +Any text or expression field on a component accepts the standard `{{ ... }}` placeholder syntax. At execution time, every placeholder is replaced with the value of the expression inside. The same expression body (without the surrounding braces) is also used wherever a component asks for an expression directly (for example the Filter or If condition). + +``` +https://api.example.com/repos/{{ $['GitHub PR'].data.repository }} +``` + +## Expression environment + +Inside an expression you have access to: + +- `$` — the run context. Use `$['Node Name'].data.foo` to read fields emitted by an upstream node. The literal `$` alone resolves to the full run context map. +- `root()` — the event that started the current run. +- `previous()` — the payload from the immediate predecessor that emitted into the current node. Optionally pass an integer depth (`previous(2)`) to walk further upstream. +- `secrets("name")` — an organization secret, returned as a map of its keys. See [Secrets in expressions](#secrets-in-expressions) below. + +Standard helpers such as `date`, `duration`, `now`, `timezone`, and `int` are also available. + +## Secrets in expressions + +Use `secrets("name").key` inside any expression to inject the value of an organization secret. For example: + +``` +{{ secrets("api").token }} +``` + +resolves to the value stored under the `token` key of the `api` secret. + +Secrets resolved this way: + +- Are looked up at execution time, so each run picks up the latest value of the secret. +- Are never written back into the saved component configuration. Only the expression `secrets("api").token` is persisted; the actual token only exists in memory during execution. +- Must select a specific key. Embedding the whole secret (`secrets("api")` without a key) is rejected so the entire decrypted secret cannot leak into a URL, header, payload, or log. + +If a referenced secret or key does not exist at execution time, the run fails with an error identifying the missing secret. diff --git a/pkg/components/merge/merge_test.go b/pkg/components/merge/merge_test.go index de40cd4eba..22784f3d44 100644 --- a/pkg/components/merge/merge_test.go +++ b/pkg/components/merge/merge_test.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/crypto" "github.com/superplanehq/superplane/pkg/database" "github.com/superplanehq/superplane/pkg/models" "github.com/superplanehq/superplane/pkg/workers/contexts" @@ -525,7 +526,7 @@ func (s *MergeTestSteps) CreateSingleQueueItemForProcess1() { func (s *MergeTestSteps) ProcessFirstEvent(m *Merge) { fmt.Println("Processing first event") - ctx1, err := contexts.BuildProcessQueueContext(http.DefaultClient, s.Tx, s.MergeNode, s.QueureItem1, nil, nil, nil) + ctx1, err := contexts.BuildProcessQueueContext(http.DefaultClient, crypto.NewNoOpEncryptor(), s.Tx, s.MergeNode, s.QueureItem1, nil, nil, nil) assert.NoError(s.t, err) execution, err := m.ProcessQueueItem(*ctx1) @@ -538,7 +539,7 @@ func (s *MergeTestSteps) ProcessFirstEvent(m *Merge) { func (s *MergeTestSteps) ProcessFirstEventExpectFinish(m *Merge) { fmt.Println("Processing first event (expect finish)") - ctx1, err := contexts.BuildProcessQueueContext(http.DefaultClient, s.Tx, s.MergeNode, s.QueureItem1, nil, nil, nil) + ctx1, err := contexts.BuildProcessQueueContext(http.DefaultClient, crypto.NewNoOpEncryptor(), s.Tx, s.MergeNode, s.QueureItem1, nil, nil, nil) assert.NoError(s.t, err) execution, err := m.ProcessQueueItem(*ctx1) @@ -549,7 +550,7 @@ func (s *MergeTestSteps) ProcessFirstEventExpectFinish(m *Merge) { func (s *MergeTestSteps) ProcessSecondEvent(m *Merge) { fmt.Println("Processing second event") - ctx2, err := contexts.BuildProcessQueueContext(http.DefaultClient, s.Tx, s.MergeNode, s.QueureItem2, nil, nil, nil) + ctx2, err := contexts.BuildProcessQueueContext(http.DefaultClient, crypto.NewNoOpEncryptor(), s.Tx, s.MergeNode, s.QueureItem2, nil, nil, nil) assert.NoError(s.t, err) execution, err := m.ProcessQueueItem(*ctx2) @@ -560,7 +561,7 @@ func (s *MergeTestSteps) ProcessSecondEvent(m *Merge) { func (s *MergeTestSteps) ProcessSecondEventExpectNoFinish(m *Merge) { fmt.Println("Processing second event") - ctx2, err := contexts.BuildProcessQueueContext(http.DefaultClient, s.Tx, s.MergeNode, s.QueureItem2, nil, nil, nil) + ctx2, err := contexts.BuildProcessQueueContext(http.DefaultClient, crypto.NewNoOpEncryptor(), s.Tx, s.MergeNode, s.QueureItem2, nil, nil, nil) assert.NoError(s.t, err) execution, err := m.ProcessQueueItem(*ctx2) diff --git a/pkg/configuration/expressionvalidation/canvas_test.go b/pkg/configuration/expressionvalidation/canvas_test.go index ef9ad71d9b..91b7bc3020 100644 --- a/pkg/configuration/expressionvalidation/canvas_test.go +++ b/pkg/configuration/expressionvalidation/canvas_test.go @@ -220,6 +220,20 @@ func TestValidateNodeExpressions_NestedPaths(t *testing.T) { }, fields, knownSet()) assertOneError(t, errs, "extra.unknown", "unknown node reference 'Missing'") }) + + t.Run("secrets() calls validate as ordinary expressions", func(t *testing.T) { + fields := []configuration.Field{ + {Name: "url", Type: configuration.FieldTypeString}, + {Name: "text", Type: configuration.FieldTypeText}, + } + errs := validateNodeExpressions("n1", "HTTP", map[string]any{ + "url": `https://api.example.com/?token={{ secrets("api").token }}`, + "text": `Bearer {{ secrets("svc").key }} for {{ $['Build'].user }}`, + }, fields, knownSet("Build")) + if len(errs) != 0 { + t.Fatalf("expected no errors, got %+v", errs) + } + }) } func TestValidateNodeExpressions_Aggregation(t *testing.T) { diff --git a/pkg/configuration/expressionvalidation/validator.go b/pkg/configuration/expressionvalidation/validator.go index 9955245c00..b996ea7cde 100644 --- a/pkg/configuration/expressionvalidation/validator.go +++ b/pkg/configuration/expressionvalidation/validator.go @@ -136,6 +136,15 @@ func checkTopLevelCall(name string, args []ast.Node) error { return fmt.Errorf("previous() depth must be an integer literal") } } + case "secrets": + if len(args) != 1 { + return fmt.Errorf("secrets() takes exactly one argument (secret name), got %d", len(args)) + } + switch args[0].(type) { + case *ast.StringNode, *ast.IdentifierNode, *ast.MemberNode, *ast.CallNode: + default: + return fmt.Errorf("secrets() argument must be a string") + } } return nil } @@ -173,6 +182,7 @@ func compileWithStubEnv(body string, knownNodeNames map[string]struct{}, extraEn exprruntime.DateFunctionOption(), expr.Function("root", func(params ...any) (any, error) { return nil, nil }), expr.Function("previous", func(params ...any) (any, error) { return nil, nil }), + expr.Function("secrets", func(params ...any) (any, error) { return map[string]string{}, nil }), } if _, err := expr.Compile(body, opts...); err != nil { diff --git a/pkg/configuration/expressionvalidation/validator_test.go b/pkg/configuration/expressionvalidation/validator_test.go index a712c3aeea..3c56c17414 100644 --- a/pkg/configuration/expressionvalidation/validator_test.go +++ b/pkg/configuration/expressionvalidation/validator_test.go @@ -54,6 +54,9 @@ func TestValidateExpression_Valid(t *testing.T) { {name: "date builtin", raw: `date('2026-01-01')`}, {name: "type conversion", raw: `int($['Build'].count) + 1`, knownNames: []string{"Build"}}, {name: "standalone dollar", raw: `$`}, + {name: "secrets call", raw: `secrets('api').token`}, + {name: "secrets in concat", raw: `'Bearer ' + secrets('api').token`}, + {name: "secrets dashed name", raw: `secrets('my-api').token`}, }) } @@ -86,6 +89,9 @@ func TestValidateExpression_BadArity(t *testing.T) { {name: "memory.find no args", raw: `memory.find()`, wantErr: "memory.find() requires a namespace and matches"}, {name: "memory.findFirst no args", raw: `memory.findFirst()`, wantErr: "memory.findFirst() requires a namespace and matches"}, {name: "memory.find too many", raw: `memory.find('ns', {}, 'extra')`, wantErr: "memory.find() requires a namespace and matches"}, + {name: "secrets no args", raw: `secrets()`, wantErr: "secrets() takes exactly one argument"}, + {name: "secrets too many", raw: `secrets('a', 'b')`, wantErr: "secrets() takes exactly one argument"}, + {name: "secrets int argument", raw: `secrets(1)`, wantErr: "secrets() argument must be a string"}, }) } diff --git a/pkg/grpc/actions/canvases/invoke_node_trigger_hook.go b/pkg/grpc/actions/canvases/invoke_node_trigger_hook.go index 7a7d88b465..4ed651b8a5 100644 --- a/pkg/grpc/actions/canvases/invoke_node_trigger_hook.go +++ b/pkg/grpc/actions/canvases/invoke_node_trigger_hook.go @@ -86,6 +86,7 @@ func InvokeNodeTriggerHook( "parameters": expressionParameters, }). WithConfigurationFields(hookProvider.Configuration()). + WithSecretResolver(contexts.NewRuntimeSecretResolver(tx, encryptor, models.DomainTypeOrganization, orgID)). Build(contexts.WithoutRunTitleConfiguration(node.Configuration.Data())) if err != nil { return nil, grpcerrors.InvalidArgument(err, "failed to resolve trigger configuration") diff --git a/pkg/server/server.go b/pkg/server/server.go index 1a93f66b4f..b11a664157 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -171,7 +171,7 @@ func startWorkers( if os.Getenv("START_WORKFLOW_NODE_QUEUE_WORKER") == "yes" || os.Getenv("START_NODE_QUEUE_WORKER") == "yes" { log.Println("Starting Node Queue Worker") - w := workers.NewNodeQueueWorker(registry, gitProvider, rabbitMQURL) + w := workers.NewNodeQueueWorker(encryptor, registry, gitProvider, rabbitMQURL) go w.Start(context.Background()) } diff --git a/pkg/workers/contexts/node_configuration_builder.go b/pkg/workers/contexts/node_configuration_builder.go index 9d15343fe1..fe7b95e99b 100644 --- a/pkg/workers/contexts/node_configuration_builder.go +++ b/pkg/workers/contexts/node_configuration_builder.go @@ -10,6 +10,8 @@ import ( "time" "github.com/expr-lang/expr" + "github.com/expr-lang/expr/ast" + "github.com/expr-lang/expr/parser" "github.com/google/uuid" "github.com/superplanehq/superplane/pkg/configuration" "github.com/superplanehq/superplane/pkg/configuration/expressionvalidation" @@ -31,6 +33,7 @@ type NodeConfigurationBuilder struct { input any expressionVariables map[string]any configurationFields []configuration.Field + secretResolver SecretResolver } func NewNodeConfigurationBuilder(tx *gorm.DB, workflowID uuid.UUID) *NodeConfigurationBuilder { @@ -82,6 +85,16 @@ func (b *NodeConfigurationBuilder) WithConfigurationFields(fields []configuratio return b } +// WithSecretResolver switches the builder to runtime mode for secrets: when +// resolver is non-nil, secrets() placeholders are evaluated and every other +// placeholder is left untouched. When the resolver is nil (the default), the +// behaviour is the opposite: secrets() placeholders are deferred so the +// secret values never reach the stored configuration. +func (b *NodeConfigurationBuilder) WithSecretResolver(resolver SecretResolver) *NodeConfigurationBuilder { + b.secretResolver = resolver + return b +} + func (b *NodeConfigurationBuilder) Build(configuration map[string]any) (map[string]any, error) { if len(b.configurationFields) > 0 { return b.resolveWithSchema(configuration, b.configurationFields) @@ -248,6 +261,7 @@ func (b *NodeConfigurationBuilder) ResolveTemplateExpressions(expression string) } var err error + deferred := b.secretResolver == nil result := expressionRegex.ReplaceAllStringFunc(expression, func(match string) string { matches := expressionRegex.FindStringSubmatch(match) @@ -255,6 +269,25 @@ func (b *NodeConfigurationBuilder) ResolveTemplateExpressions(expression string) return match } + injectsSecret := expressionInjectsSecret(matches[1]) + + // + // Without a SecretResolver (the deferred / queue phase), placeholders + // that call secrets() are left untouched so the secret value never + // reaches the stored configuration or any log of it. With a + // resolver (the runtime phase, and one-shot resolutions such as + // trigger hooks), every placeholder is evaluated normally. + // + if deferred && injectsSecret { + return match + } + + // + // ResolveExpression rejects expressions that resolve to a whole + // secret map (secrets("name") without a key), so a decrypted secret + // can never be formatted into the output here. See the guard in + // ResolveExpressionWithExtraVariables. + // value, e := b.ResolveExpression(matches[1]) if e != nil { err = e @@ -271,6 +304,105 @@ func (b *NodeConfigurationBuilder) ResolveTemplateExpressions(expression string) return result, nil } +// expressionInjectsSecret returns true when the expression body contains a +// call to the secrets() function. Used by ResolveTemplateExpressions to gate +// deferred vs runtime resolution; treats unparsable expressions as +// non-secret so the existing expression error surfaces naturally during +// evaluation. +func expressionInjectsSecret(body string) bool { + tree, err := parser.Parse(body) + if err != nil { + return false + } + collector := &secretsCallCollector{} + ast.Walk(&tree.Node, collector) + return collector.found +} + +type secretsCallCollector struct { + found bool +} + +func (c *secretsCallCollector) Visit(node *ast.Node) { + if c.found { + return + } + call, ok := (*node).(*ast.CallNode) + if !ok { + return + } + if id, ok := call.Callee.(*ast.IdentifierNode); ok && id.Value == "secrets" { + c.found = true + } +} + +// validateSecretKeyReferences ensures that every statically-known +// secrets("name").key reference in the expression points at a key that +// actually exists in the resolved secret. It uses the provided resolver +// (memoized by the caller) so each secret is fetched at most once. Dynamic key +// lookups (e.g. secrets("name")[someVar]) cannot be checked statically and are +// left for normal evaluation. Parse failures are ignored so the real compile +// error surfaces during evaluation. +func validateSecretKeyReferences(expression string, resolveSecret func(string) (map[string]string, error)) error { + tree, err := parser.Parse(expression) + if err != nil { + return nil + } + + collector := &secretKeyReferenceCollector{} + ast.Walk(&tree.Node, collector) + + for _, reference := range collector.references { + values, err := resolveSecret(reference.name) + if err != nil { + return err + } + if _, ok := values[reference.key]; !ok { + return fmt.Errorf("secret %q has no key %q", reference.name, reference.key) + } + } + + return nil +} + +type secretKeyReference struct { + name string + key string +} + +type secretKeyReferenceCollector struct { + references []secretKeyReference +} + +func (c *secretKeyReferenceCollector) Visit(node *ast.Node) { + member, ok := (*node).(*ast.MemberNode) + if !ok { + return + } + + call, ok := member.Node.(*ast.CallNode) + if !ok { + return + } + + id, ok := call.Callee.(*ast.IdentifierNode) + if !ok || id.Value != "secrets" || len(call.Arguments) != 1 { + return + } + + name, ok := call.Arguments[0].(*ast.StringNode) + if !ok { + return + } + + property, ok := member.Property.(*ast.StringNode) + if !ok { + return + } + + c.references = append(c.references, secretKeyReference{name: name.Value, key: property.Value}) +} + func (b *NodeConfigurationBuilder) ResolveExpression(expression string) (any, error) { return b.ResolveExpressionWithExtraVariables(expression, b.expressionVariables) } @@ -278,7 +410,7 @@ func (b *NodeConfigurationBuilder) ResolveExpression(expression string) (any, er // ResolveExpressionWithExtraVariables evaluates an expression with extra // variables merged into the eval environment. Provided keys cannot override // built-ins; we reject any attempt to shadow reserved names so that `$`, -// `memory`, `config`, `root`, and `previous` stay deterministic. +// `memory`, `config`, `root`, `previous`, and `secrets` stay deterministic. func (b *NodeConfigurationBuilder) ResolveExpressionWithExtraVariables(expression string, variables map[string]any) (any, error) { referencedNodes, err := expressionvalidation.ParseReferencedNodes(expression) if err != nil { @@ -302,6 +434,42 @@ func (b *NodeConfigurationBuilder) ResolveExpressionWithExtraVariables(expressio env[key] = value } + // + // resolveSecret memoizes secret resolution for the lifetime of this single + // evaluation, so the static key-existence check below and the secrets() + // calls executed by the VM hit the database (and decrypt) at most once per + // distinct secret name. + // + resolvedSecrets := map[string]map[string]string{} + resolveSecret := func(name string) (map[string]string, error) { + if cached, ok := resolvedSecrets[name]; ok { + return cached, nil + } + if b.secretResolver == nil { + return nil, fmt.Errorf("secrets() is not available in this context") + } + values, err := b.secretResolver.Resolve(name) + if err != nil { + return nil, err + } + resolvedSecrets[name] = values + return values, nil + } + + // + // Fail fast when an expression references a secret key that does not + // exist (e.g. secrets("api").token where the secret has no "token" key). + // expr returns the zero value (an empty string) for a missing map key, so + // without this check a typo'd key would silently produce a blank token or + // credential. Only statically-known keys are validated; dynamic lookups + // fall through to normal evaluation. + // + if b.secretResolver != nil { + if err := validateSecretKeyReferences(expression, resolveSecret); err != nil { + return "", err + } + } + exprOptions := []expr.Option{ expr.Env(env), expr.AsAny(), @@ -330,6 +498,16 @@ func (b *NodeConfigurationBuilder) ResolveExpressionWithExtraVariables(expressio return b.resolvePreviousPayload(depth) }), + expr.Function("secrets", func(params ...any) (any, error) { + if len(params) != 1 { + return nil, fmt.Errorf("secrets() takes exactly one argument (secret name)") + } + name, ok := params[0].(string) + if !ok { + return nil, fmt.Errorf("secrets() argument must be a string") + } + return resolveSecret(name) + }), } vm, err := expr.Compile(expression, exprOptions...) @@ -342,6 +520,23 @@ func (b *NodeConfigurationBuilder) ResolveExpressionWithExtraVariables(expressio return "", fmt.Errorf("expression evaluation failed: %w", err) } + // + // Guard against bulk secret exposure for bare-expression callers (If, + // Filter, Merge stop-if, Loop until, ...). An expression that calls + // secrets() must select a specific key (e.g. secrets("api").token); if it + // resolves to the secret map itself, callers that format the non-boolean + // result with %v would persist every decrypted key/value into the + // execution failure message. Reject it before it leaves this function so + // the secret never reaches a log, payload, or stored error. The template + // path (ResolveTemplateExpressions) routes through here too, so this is + // the single place the rule is enforced. + // + if expressionInjectsSecret(expression) { + if _, isMap := asAnyMap(output); isMap { + return "", fmt.Errorf("secrets() must select a specific key (for example secrets(\"name\").key); embedding the entire secret is not allowed") + } + } + return output, nil } @@ -892,6 +1087,7 @@ var reservedExpressionIdentifiers = map[string]struct{}{ "config": {}, "root": {}, "previous": {}, + "secrets": {}, "ctx": {}, } diff --git a/pkg/workers/contexts/node_configuration_builder_secrets_test.go b/pkg/workers/contexts/node_configuration_builder_secrets_test.go new file mode 100644 index 0000000000..d5f80488db --- /dev/null +++ b/pkg/workers/contexts/node_configuration_builder_secrets_test.go @@ -0,0 +1,273 @@ +package contexts + +import ( + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeSecretResolver records calls and returns canned values, so the builder +// can be tested in isolation without spinning up a database, encryptor, or +// real secrets provider. +type fakeSecretResolver struct { + values map[string]map[string]string + err error + calls []string +} + +func (f *fakeSecretResolver) Resolve(name string) (map[string]string, error) { + f.calls = append(f.calls, name) + if f.err != nil { + return nil, f.err + } + values, ok := f.values[name] + if !ok { + return nil, fmt.Errorf("secret %q not found", name) + } + return values, nil +} + +func newSecretsBuilder(resolver SecretResolver) *NodeConfigurationBuilder { + return NewNodeConfigurationBuilder(nil, uuid.New()).WithSecretResolver(resolver) +} + +func TestNodeConfigurationBuilder_DeferredPhase_KeepsSecretsPlaceholderIntact(t *testing.T) { + builder := NewNodeConfigurationBuilder(nil, uuid.New()) + + out, err := builder.Build(map[string]any{ + "url": `{{ secrets("api").token }}`, + "plain": `prefix-{{ secrets("api").token }}-suffix`, + }) + require.NoError(t, err) + assert.Equal(t, `{{ secrets("api").token }}`, out["url"]) + assert.Equal(t, `prefix-{{ secrets("api").token }}-suffix`, out["plain"]) +} + +func TestNodeConfigurationBuilder_DeferredPhase_KeepsTransformedSecretExpression(t *testing.T) { + builder := NewNodeConfigurationBuilder(nil, uuid.New()) + + out, err := builder.Build(map[string]any{ + "sshKey": `{{ secrets("server").sshKey + "aaa" }}`, + "bearer": `{{ "Bearer " + secrets("api").token }}`, + }) + require.NoError(t, err) + assert.Equal(t, `{{ secrets("server").sshKey + "aaa" }}`, out["sshKey"]) + assert.Equal(t, `{{ "Bearer " + secrets("api").token }}`, out["bearer"]) +} + +func TestNodeConfigurationBuilder_RuntimePhase_ResolvesSecretCall(t *testing.T) { + resolver := &fakeSecretResolver{ + values: map[string]map[string]string{"api": {"token": "abc123"}}, + } + + out, err := newSecretsBuilder(resolver).Build(map[string]any{ + "url": `https://example.com/{{ secrets("api").token }}/resource`, + }) + require.NoError(t, err) + assert.Equal(t, "https://example.com/abc123/resource", out["url"]) + assert.Equal(t, []string{"api"}, resolver.calls) +} + +func TestNodeConfigurationBuilder_RuntimePhase_ResolvesTransformedSecret(t *testing.T) { + resolver := &fakeSecretResolver{ + values: map[string]map[string]string{ + "server": {"sshKey": "secret-key"}, + "api": {"token": "abc"}, + }, + } + + out, err := newSecretsBuilder(resolver).Build(map[string]any{ + "sshKey": `{{ secrets("server").sshKey + "aaa" }}`, + "authHeader": `{{ "Bearer " + secrets("api").token }}`, + }) + require.NoError(t, err) + assert.Equal(t, "secret-keyaaa", out["sshKey"]) + assert.Equal(t, "Bearer abc", out["authHeader"]) +} + +func TestNodeConfigurationBuilder_RuntimePhase_MissingSecretReturnsError(t *testing.T) { + resolver := &fakeSecretResolver{values: map[string]map[string]string{}} + + _, err := newSecretsBuilder(resolver).Build(map[string]any{ + "url": `{{ secrets("nonexistent").token }}`, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "nonexistent") + assert.Contains(t, err.Error(), "not found") +} + +func TestNodeConfigurationBuilder_RuntimePhase_MissingKeyFails(t *testing.T) { + // + // A reference to a key the secret does not define must fail the run + // instead of silently resolving to an empty string, which would otherwise + // send a blank token or credential downstream. + // + resolver := &fakeSecretResolver{ + values: map[string]map[string]string{"api": {"token": "abc"}}, + } + + _, err := newSecretsBuilder(resolver).Build(map[string]any{ + "value": `prefix-{{ secrets("api").missing }}-suffix`, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), `secret "api" has no key "missing"`) + assert.NotContains(t, err.Error(), "abc") +} + +func TestNodeConfigurationBuilder_RuntimePhase_MissingKeyFailsForBareExpression(t *testing.T) { + // + // Bare-expression fields (If, Filter, Merge stop-if, Loop until) route + // through ResolveExpression and must reject missing keys too. + // + resolver := &fakeSecretResolver{ + values: map[string]map[string]string{"api": {"token": "abc"}}, + } + + _, err := newSecretsBuilder(resolver).ResolveExpression(`secrets("api").missing == "x"`) + require.Error(t, err) + assert.Contains(t, err.Error(), `secret "api" has no key "missing"`) +} + +func TestNodeConfigurationBuilder_RuntimePhase_ResolvesSecretOncePerEvaluation(t *testing.T) { + // + // The static key-existence check and the VM evaluation must share a single + // resolution per secret name, so repeating the same secret in one + // expression does not hit the resolver twice. + // + resolver := &fakeSecretResolver{ + values: map[string]map[string]string{"api": {"token": "abc"}}, + } + + out, err := newSecretsBuilder(resolver).ResolveExpression(`secrets("api").token + secrets("api").token`) + require.NoError(t, err) + assert.Equal(t, "abcabc", out) + assert.Equal(t, []string{"api"}, resolver.calls) +} + +func TestNodeConfigurationBuilder_RuntimePhase_WholeSecretMapIsRejected(t *testing.T) { + // + // Selecting no key (secrets("api") instead of secrets("api").token) would + // otherwise stringify the whole decrypted map and leak every key/value. + // It must be rejected, and the error must not contain any secret value. + // + resolver := &fakeSecretResolver{ + values: map[string]map[string]string{"api": {"token": "supersecret", "key": "anotherone"}}, + } + + for _, expression := range []string{ + `{{ secrets("api") }}`, + `prefix-{{ secrets("api") }}-suffix`, + `{{ secrets("api") }} and {{ secrets("api").token }}`, + } { + t.Run(expression, func(t *testing.T) { + _, err := newSecretsBuilder(resolver).Build(map[string]any{"value": expression}) + require.Error(t, err) + assert.Contains(t, err.Error(), "secrets() must select a specific key") + assert.NotContains(t, err.Error(), "supersecret") + assert.NotContains(t, err.Error(), "anotherone") + }) + } +} + +func TestNodeConfigurationBuilder_RuntimePhase_BareExpressionRejectsWholeSecretMap(t *testing.T) { + // + // Bare-expression fields (If, Filter, Merge stop-if, Loop until) evaluate + // through ResolveExpression rather than ResolveTemplateExpressions. A + // whole-secret-map expression (secrets("api") without a key) must be + // rejected here too, otherwise components format the non-boolean result + // with %v and persist the decrypted secret in their failure message. + // + resolver := &fakeSecretResolver{ + values: map[string]map[string]string{"api": {"token": "supersecret", "key": "anotherone"}}, + } + builder := newSecretsBuilder(resolver) + + _, err := builder.ResolveExpression(`secrets("api")`) + require.Error(t, err) + assert.Contains(t, err.Error(), "secrets() must select a specific key") + assert.NotContains(t, err.Error(), "supersecret") + assert.NotContains(t, err.Error(), "anotherone") + + // + // Selecting a specific key still works through the bare-expression path. + // + out, err := builder.ResolveExpression(`secrets("api").token`) + require.NoError(t, err) + assert.Equal(t, "supersecret", out) +} + +func TestNodeConfigurationBuilder_NoResolver_SecretsCallErrors(t *testing.T) { + // + // If a placeholder containing secrets() somehow reaches a builder with no + // resolver and bypasses the deferred-mode skip (eg. via the + // ResolveExpression API), the call should fail rather than silently + // returning an empty value. + // + builder := NewNodeConfigurationBuilder(nil, uuid.New()) + + _, err := builder.ResolveExpression(`secrets("api").token`) + require.Error(t, err) + assert.Contains(t, err.Error(), "secrets()") +} + +func TestNodeConfigurationBuilder_SecretsIdentifierIsReserved(t *testing.T) { + resolver := &fakeSecretResolver{values: map[string]map[string]string{}} + builder := newSecretsBuilder(resolver) + + _, err := builder.ResolveExpressionWithExtraVariables(`"x"`, map[string]any{ + "secrets": "shadowed", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), `"secrets"`) + assert.Contains(t, err.Error(), "reserved") +} + +func TestNodeConfigurationBuilder_RuntimePhase_RebuildsResolveAfresh(t *testing.T) { + // + // Simulates the retry path: each call into invokeExecutionComponentHook + // reuses the stored execution.Configuration (with deferred secret + // placeholders) and a fresh RuntimeSecretResolver. Each call must call + // the resolver, so updated secret values flow through every attempt. + // + resolver := &fakeSecretResolver{ + values: map[string]map[string]string{"api": {"token": "v1"}}, + } + stored := map[string]any{"url": `https://example.com/{{ secrets("api").token }}`} + + first, err := newSecretsBuilder(resolver).Build(stored) + require.NoError(t, err) + assert.Equal(t, "https://example.com/v1", first["url"]) + + resolver.values["api"]["token"] = "v2" + resolver.calls = nil + + second, err := newSecretsBuilder(resolver).Build(stored) + require.NoError(t, err) + assert.Equal(t, "https://example.com/v2", second["url"]) + assert.Equal(t, []string{"api"}, resolver.calls, "resolver must be invoked on every attempt") +} + +func TestExpressionInjectsSecret(t *testing.T) { + cases := []struct { + expression string + expected bool + }{ + {`secrets("x").y`, true}, + {`secrets("x").y + "aaa"`, true}, + {`"Bearer " + secrets("api").token`, true}, + {`$["node"].field`, false}, + {`root().path`, false}, + {`previous()`, false}, + {`"just a string with secrets( in it"`, false}, + {`malformed (`, false}, + } + + for _, tc := range cases { + t.Run(tc.expression, func(t *testing.T) { + assert.Equal(t, tc.expected, expressionInjectsSecret(tc.expression)) + }) + } +} diff --git a/pkg/workers/contexts/node_configuration_builder_test.go b/pkg/workers/contexts/node_configuration_builder_test.go index 5081baa6d5..b190230963 100644 --- a/pkg/workers/contexts/node_configuration_builder_test.go +++ b/pkg/workers/contexts/node_configuration_builder_test.go @@ -1929,3 +1929,41 @@ func Test_NodeConfigurationBuilder_ForEachBranchPayload(t *testing.T) { require.NoError(t, err) assert.Equal(t, "b", result["item"]) } + +func Test_NodeConfigurationBuilder_SecretCallsAreDeferredWithoutResolver(t *testing.T) { + r := support.Setup(t) + defer r.Close() + + triggerNode := "trigger-1" + canvas, _ := support.CreateCanvas( + t, + r.Organization.ID, + r.User, + []models.CanvasNode{ + { + NodeID: triggerNode, + Name: triggerNode, + Type: models.NodeTypeTrigger, + Ref: datatypes.NewJSONType(models.NodeRef{Trigger: &models.TriggerRef{Name: "start"}}), + }, + }, + []models.Edge{}, + ) + + rootEventData := map[string]any{"user": "alice"} + rootEvent := support.EmitCanvasEventForNodeWithData(t, canvas.ID, triggerNode, "default", nil, rootEventData) + + builder := NewNodeConfigurationBuilder(database.Conn(), canvas.ID). + WithRootEvent(&rootEvent.ID). + WithInput(map[string]any{triggerNode: rootEventData}) + + result, err := builder.Build(map[string]any{ + "plain": `{{ secrets("api").token }}`, + "inline": `Bearer {{ secrets("svc").key }} for {{ $["` + triggerNode + `"].user }}`, + "only": `{{ $["` + triggerNode + `"].user }}`, + }) + require.NoError(t, err) + assert.Equal(t, `{{ secrets("api").token }}`, result["plain"]) + assert.Equal(t, `Bearer {{ secrets("svc").key }} for alice`, result["inline"]) + assert.Equal(t, "alice", result["only"]) +} diff --git a/pkg/workers/contexts/process_queue_context.go b/pkg/workers/contexts/process_queue_context.go index 7b67204072..9ce6434e7f 100644 --- a/pkg/workers/contexts/process_queue_context.go +++ b/pkg/workers/contexts/process_queue_context.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/superplanehq/superplane/pkg/configuration" "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/crypto" "github.com/superplanehq/superplane/pkg/logging" "github.com/superplanehq/superplane/pkg/models" "gorm.io/datatypes" @@ -32,6 +33,7 @@ func (e *ConfigurationBuildError) Unwrap() error { func BuildProcessQueueContext( httpCtx core.HTTPContext, + encryptor crypto.Encryptor, tx *gorm.DB, node *models.CanvasNode, queueItem *models.CanvasNodeQueueItem, @@ -44,6 +46,11 @@ func BuildProcessQueueContext( return nil, err } + workflow, err := models.FindCanvasWithoutOrgScopeInTransaction(tx, node.WorkflowID) + if err != nil { + return nil, err + } + configBuilder := NewNodeConfigurationBuilder(tx, queueItem.WorkflowID). WithNodeID(node.NodeID). WithRootEvent(&queueItem.RootEventID). @@ -65,11 +72,21 @@ func BuildProcessQueueContext( } } + // + // The Expressions context evaluates bare expressions (e.g. Merge's + // stopIfExpression, Loop's untilExpression) immediately during queue + // processing to decide control flow. Those results are never persisted, + // so we give this builder a runtime secret resolver - canvas validation + // allows secrets() in these fields, and without a resolver evaluation + // would fail with "secrets() is not available in this context". + // + secretResolver := NewRuntimeSecretResolver(tx, encryptor, models.DomainTypeOrganization, workflow.OrganizationID) builder := NewNodeConfigurationBuilder(tx, queueItem.WorkflowID). WithNodeID(node.NodeID). WithRootEvent(&queueItem.RootEventID). WithIncomingEventID(&event.ID). - WithInput(map[string]any{event.NodeID: event.Data.Data()}) + WithInput(map[string]any{event.NodeID: event.Data.Data()}). + WithSecretResolver(secretResolver) if event.ExecutionID != nil { builder = builder.WithPreviousExecution(event.ExecutionID) } diff --git a/pkg/workers/contexts/secret_resolver.go b/pkg/workers/contexts/secret_resolver.go new file mode 100644 index 0000000000..f4adc0c2bd --- /dev/null +++ b/pkg/workers/contexts/secret_resolver.go @@ -0,0 +1,70 @@ +package contexts + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/superplanehq/superplane/pkg/crypto" + "github.com/superplanehq/superplane/pkg/secrets" + "gorm.io/gorm" +) + +// SecretResolver loads the keys of a named organization secret for use inside +// the expression engine. A nil resolver puts the expression engine into +// deferred mode: secrets() placeholders are left untouched so they can be +// resolved later, at execution time, with a non-nil resolver. +type SecretResolver interface { + Resolve(name string) (map[string]string, error) +} + +// RuntimeSecretResolver looks up secrets in the database, decrypts them and +// returns their keys. It is used at execution time only; the resolved values +// are kept in-memory and never written back to the stored configuration. +type RuntimeSecretResolver struct { + tx *gorm.DB + encryptor crypto.Encryptor + domainType string + domainID uuid.UUID +} + +// NewRuntimeSecretResolver returns a resolver scoped to the given domain. +// Component executions and execution hooks always run within an organization +// transaction, so this is the resolver used everywhere except in the +// deferred (queue) phase. +func NewRuntimeSecretResolver(tx *gorm.DB, encryptor crypto.Encryptor, domainType string, domainID uuid.UUID) *RuntimeSecretResolver { + return &RuntimeSecretResolver{ + tx: tx, + encryptor: encryptor, + domainType: domainType, + domainID: domainID, + } +} + +// Resolve fetches and decrypts the named secret, returning its keys as a map. +// Missing secrets surface as an error whose message contains the secret name +// and the phrase "not found". +func (r *RuntimeSecretResolver) Resolve(name string) (map[string]string, error) { + provider, err := secrets.NewProvider(r.tx, r.encryptor, name, r.domainType, r.domainID) + if err != nil { + return nil, fmt.Errorf("secret %q: %v", name, err) + } + + values, err := provider.Load(context.Background()) + if err != nil { + return nil, fmt.Errorf("secret %q: %v", name, err) + } + + return values, nil +} + +// ResolveStoredConfiguration runs a runtime-mode builder over the stored +// configuration map, returning the in-memory result with secrets() calls +// resolved. The stored map itself is left untouched, so the secret values +// never reach the database or logs that emit the persisted configuration. +func ResolveStoredConfiguration(builder *NodeConfigurationBuilder, stored map[string]any) (map[string]any, error) { + if stored == nil { + return nil, nil + } + return builder.Build(stored) +} diff --git a/pkg/workers/node_executor.go b/pkg/workers/node_executor.go index b862210f83..ca67d89e5d 100644 --- a/pkg/workers/node_executor.go +++ b/pkg/workers/node_executor.go @@ -320,15 +320,25 @@ func (w *NodeExecutor) executeActionNode(tx *gorm.DB, execution *models.CanvasNo return fmt.Errorf("failed to find workflow: %v", err) } + secretResolver := contexts.NewRuntimeSecretResolver(tx, w.encryptor, models.DomainTypeOrganization, workflow.OrganizationID) + builder := contexts.NewNodeConfigurationBuilder(tx, execution.WorkflowID). WithNodeID(node.NodeID). WithRootEvent(&execution.RootEventID). WithIncomingEventID(&execution.EventID). - WithInput(map[string]any{inputEvent.NodeID: input}) + WithInput(map[string]any{inputEvent.NodeID: input}). + WithSecretResolver(secretResolver) if execution.PreviousExecutionID != nil { builder = builder.WithPreviousExecution(execution.PreviousExecutionID) } + resolvedConfiguration, err := contexts.ResolveStoredConfiguration(builder, execution.Configuration.Data()) + if err != nil { + logger.Errorf("failed to resolve runtime configuration: %v", err) + execState := contexts.NewExecutionStateContext(tx, execution, onNewEvents) + return execState.Fail(models.CanvasNodeExecutionResultReasonError, fmt.Sprintf("failed to resolve configuration: %v", err)) + } + ctx := core.ExecutionContext{ ID: execution.ID, WorkflowID: execution.WorkflowID.String(), @@ -338,7 +348,7 @@ func (w *NodeExecutor) executeActionNode(tx *gorm.DB, execution *models.CanvasNo NodeName: node.Name, SourceNodeID: inputEvent.NodeID, BaseURL: w.baseURL, - Configuration: execution.Configuration.Data(), + Configuration: resolvedConfiguration, Data: input, HTTP: w.registry.HTTPContextInTransaction(tx), Metadata: contexts.NewExecutionMetadataContext(tx, execution), diff --git a/pkg/workers/node_queue_worker.go b/pkg/workers/node_queue_worker.go index 94e01d4652..0f72c93e2d 100644 --- a/pkg/workers/node_queue_worker.go +++ b/pkg/workers/node_queue_worker.go @@ -15,6 +15,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/superplanehq/superplane/pkg/configuration" "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/crypto" "github.com/superplanehq/superplane/pkg/database" gitprovider "github.com/superplanehq/superplane/pkg/git/provider" "github.com/superplanehq/superplane/pkg/grpc/actions/messages" @@ -27,6 +28,7 @@ import ( ) type NodeQueueWorker struct { + encryptor crypto.Encryptor registry *registry.Registry gitProvider gitprovider.Provider semaphore *semaphore.Weighted @@ -37,7 +39,7 @@ type NodeQueueWorker struct { executionFinishedConsumer *tackle.Consumer } -func NewNodeQueueWorker(registry *registry.Registry, gitProvider gitprovider.Provider, rabbitMQURL string) *NodeQueueWorker { +func NewNodeQueueWorker(encryptor crypto.Encryptor, registry *registry.Registry, gitProvider gitprovider.Provider, rabbitMQURL string) *NodeQueueWorker { logger := log.WithFields(log.Fields{"worker": "NodeQueueWorker"}) queueItemConsumer := tackle.NewConsumer() @@ -47,6 +49,7 @@ func NewNodeQueueWorker(registry *registry.Registry, gitProvider gitprovider.Pro executionFinishedConsumer.SetLogger(logging.NewTackleLogger(logger)) return &NodeQueueWorker{ + encryptor: encryptor, registry: registry, gitProvider: gitProvider, rabbitMQURL: rabbitMQURL, @@ -327,7 +330,7 @@ func (w *NodeQueueWorker) processNode(tx *gorm.DB, logger *log.Entry, node *mode repoFiles := contexts.NewRepositoryFilesContext(w.gitProvider, queueItem.WorkflowID) - ctx, err := contexts.BuildProcessQueueContext(w.registry.HTTPContextInTransaction(tx), tx, node, queueItem, configFields, onNewEvents, repoFiles) + ctx, err := contexts.BuildProcessQueueContext(w.registry.HTTPContextInTransaction(tx), w.encryptor, tx, node, queueItem, configFields, onNewEvents, repoFiles) if err != nil { // diff --git a/pkg/workers/node_queue_worker_test.go b/pkg/workers/node_queue_worker_test.go index 44c80e6b2f..85beb02d59 100644 --- a/pkg/workers/node_queue_worker_test.go +++ b/pkg/workers/node_queue_worker_test.go @@ -25,7 +25,7 @@ func Test__NodeQueueWorker_ComponentNodeQueueIsProcessed(t *testing.T) { defer r.Close() amqpURL, _ := config.RabbitMQURL() - worker := NewNodeQueueWorker(r.Registry, r.GitProvider, amqpURL) + worker := NewNodeQueueWorker(r.Encryptor, r.Registry, r.GitProvider, amqpURL) logger := log.NewEntry(log.New()) executionConsumer := testconsumer.NewExecutions(amqpURL, messages.ExecutionPendingRoutingKey) @@ -114,7 +114,7 @@ func Test__NodeQueueWorker_DoesNotProcessQueueForSoftDeletedOrganization(t *test defer r.Close() amqpURL, _ := config.RabbitMQURL() - worker := NewNodeQueueWorker(r.Registry, r.GitProvider, amqpURL) + worker := NewNodeQueueWorker(r.Encryptor, r.Registry, r.GitProvider, amqpURL) logger := log.NewEntry(log.New()) executionConsumer := testconsumer.NewExecutions(amqpURL, messages.ExecutionPendingRoutingKey) @@ -172,7 +172,7 @@ func Test__NodeQueueWorker_PicksOldestQueueItem(t *testing.T) { defer r.Close() amqpURL, _ := config.RabbitMQURL() - worker := NewNodeQueueWorker(r.Registry, r.GitProvider, amqpURL) + worker := NewNodeQueueWorker(r.Encryptor, r.Registry, r.GitProvider, amqpURL) logger := log.NewEntry(log.New()) executionConsumer := testconsumer.NewExecutions(amqpURL, messages.ExecutionPendingRoutingKey) @@ -277,7 +277,7 @@ func Test__NodeQueueWorker_EmptyQueue(t *testing.T) { defer r.Close() amqpURL, _ := config.RabbitMQURL() - worker := NewNodeQueueWorker(r.Registry, r.GitProvider, amqpURL) + worker := NewNodeQueueWorker(r.Encryptor, r.Registry, r.GitProvider, amqpURL) logger := log.NewEntry(log.New()) executionConsumer := testconsumer.NewExecutions(amqpURL, messages.ExecutionPendingRoutingKey) @@ -380,13 +380,13 @@ func Test__NodeQueueWorker_PreventsConcurrentProcessing(t *testing.T) { // Create two workers and have them try to process the node concurrently. // go func() { - worker1 := NewNodeQueueWorker(r.Registry, r.GitProvider, amqpURL) + worker1 := NewNodeQueueWorker(r.Encryptor, r.Registry, r.GitProvider, amqpURL) logger := log.NewEntry(log.New()) results <- worker1.LockAndProcessNode(logger, *node, time.Now()) }() go func() { - worker2 := NewNodeQueueWorker(r.Registry, r.GitProvider, amqpURL) + worker2 := NewNodeQueueWorker(r.Encryptor, r.Registry, r.GitProvider, amqpURL) logger := log.NewEntry(log.New()) results <- worker2.LockAndProcessNode(logger, *node, time.Now()) }() @@ -422,7 +422,7 @@ func Test__NodeQueueWorker_ConfigurationBuildFailure(t *testing.T) { defer r.Close() amqpURL, _ := config.RabbitMQURL() - worker := NewNodeQueueWorker(r.Registry, r.GitProvider, amqpURL) + worker := NewNodeQueueWorker(r.Encryptor, r.Registry, r.GitProvider, amqpURL) logger := log.NewEntry(log.New()) executionConsumer := testconsumer.NewExecutions(amqpURL, messages.ExecutionFinishedRoutingKey) @@ -517,7 +517,7 @@ func Test__NodeQueueWorker_ProcessesNextQueueItemOnExecutionFinished(t *testing. defer r.Close() amqpURL, _ := config.RabbitMQURL() - worker := NewNodeQueueWorker(r.Registry, r.GitProvider, amqpURL) + worker := NewNodeQueueWorker(r.Encryptor, r.Registry, r.GitProvider, amqpURL) logger := log.NewEntry(log.New()) triggerNode := "trigger-1" diff --git a/pkg/workers/node_request_worker.go b/pkg/workers/node_request_worker.go index 1b4ce3e0f8..ff09c2ec3a 100644 --- a/pkg/workers/node_request_worker.go +++ b/pkg/workers/node_request_worker.go @@ -181,12 +181,18 @@ func (w *NodeRequestWorker) invokeTriggerHook(logger *log.Entry, tx *gorm.DB, re return fmt.Errorf("failed to find hook: %v", err) } + workflow, err := models.FindCanvasWithoutOrgScopeInTransaction(tx, node.WorkflowID) + if err != nil { + return fmt.Errorf("workflow not found: %w", err) + } + resolvedConfiguration, err := contexts.NewNodeConfigurationBuilder(tx, node.WorkflowID). WithNodeID(node.NodeID). WithExpressionVariables(map[string]any{ "parameters": spec.InvokeAction.Parameters, }). WithConfigurationFields(hookProvider.Configuration()). + WithSecretResolver(contexts.NewRuntimeSecretResolver(tx, w.encryptor, models.DomainTypeOrganization, workflow.OrganizationID)). Build(contexts.WithoutRunTitleConfiguration(node.Configuration.Data())) if err != nil { return fmt.Errorf("failed to resolve trigger configuration: %w", err) @@ -348,10 +354,42 @@ func (w *NodeRequestWorker) invokeExecutionComponentHook( return fmt.Errorf("workflow not found: %w", err) } + inputEvent, err := models.FindCanvasEventInTransaction(tx, execution.EventID) + if err != nil { + return fmt.Errorf("input event not found: %w", err) + } + + // + // Build a runtime-mode configuration builder so secrets() placeholders + // (left intact by the deferred queue build) are resolved fresh on every + // hook invocation - including HTTP retries that re-enter via the + // retryRequest hook with the stored execution configuration. + // + // The incoming event payload is supplied via WithInput so placeholders + // that were fully deferred at queue time (because they mix secrets() with + // $ node references in a single block) still resolve their $ references + // correctly when the hook runs. + // + secretResolver := contexts.NewRuntimeSecretResolver(tx, w.encryptor, models.DomainTypeOrganization, workflow.OrganizationID) + builder := contexts.NewNodeConfigurationBuilder(tx, execution.WorkflowID). + WithNodeID(node.NodeID). + WithRootEvent(&execution.RootEventID). + WithIncomingEventID(&execution.EventID). + WithInput(map[string]any{inputEvent.NodeID: inputEvent.Data.Data()}). + WithSecretResolver(secretResolver) + if execution.PreviousExecutionID != nil { + builder = builder.WithPreviousExecution(execution.PreviousExecutionID) + } + + resolvedConfiguration, err := contexts.ResolveStoredConfiguration(builder, execution.Configuration.Data()) + if err != nil { + return fmt.Errorf("failed to resolve configuration: %w", err) + } + logger = logging.WithExecution(logger, execution) hookCtx := core.ActionHookContext{ Name: spec.InvokeAction.ActionName, - Configuration: execution.Configuration.Data(), + Configuration: resolvedConfiguration, Parameters: spec.InvokeAction.Parameters, HTTP: w.registry.HTTPContextInTransaction(tx), Metadata: contexts.NewExecutionMetadataContext(tx, execution), diff --git a/web_src/src/components/AutoCompleteInput/core.ts b/web_src/src/components/AutoCompleteInput/core.ts index 5c35a6b71b..f9a2e31517 100644 --- a/web_src/src/components/AutoCompleteInput/core.ts +++ b/web_src/src/components/AutoCompleteInput/core.ts @@ -98,6 +98,13 @@ export const EXPR_FUNCTIONS: readonly ExprFunction[] = [ "Returns the payload from the immediate predecessor that emitted this event. Provide depth to walk upstream.", example: "previous(2).data.image.version", }, + { + name: "secrets", + snippet: 'secrets("${1:secret-name}").', + description: + "Returns the keys of an organization secret as a map. Resolved at execution time so the secret value never reaches the stored configuration.", + example: 'secrets("api").token', + }, // String { name: "trim", diff --git a/web_src/src/pages/app/mappers/wait/expressionHelp.tsx b/web_src/src/pages/app/mappers/wait/expressionHelp.tsx index f95616489c..fc264769d1 100644 --- a/web_src/src/pages/app/mappers/wait/expressionHelp.tsx +++ b/web_src/src/pages/app/mappers/wait/expressionHelp.tsx @@ -30,7 +30,7 @@ export const ExpressionTooltip: React.FC<{ expression: string; children: React.R }; const EXPRESSION_TOKEN_PATTERN = - /({{|}}|\$|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\b\d+(?:\.\d+)?\b|\b(?:root|previous|date|duration|now|timezone|int)\b|\b(?:true|false|null)\b|==|!=|>=|<=|&&|\|\||[?:+\-*/%().,[\]]|[A-Za-z_][A-Za-z0-9_]*|\s+)/g; + /({{|}}|\$|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\b\d+(?:\.\d+)?\b|\b(?:root|previous|secrets|date|duration|now|timezone|int)\b|\b(?:true|false|null)\b|==|!=|>=|<=|&&|\|\||[?:+\-*/%().,[\]]|[A-Za-z_][A-Za-z0-9_]*|\s+)/g; function tokenClass(token: string): string | undefined { if (/^\s+$/.test(token)) return undefined; @@ -38,7 +38,7 @@ function tokenClass(token: string): string | undefined { if (token === "$") return "text-emerald-700"; if (token.startsWith('"') || token.startsWith("'")) return "text-amber-700"; if (/^\b\d+(\.\d+)?\b$/.test(token)) return "text-blue-700"; - if (/^\b(?:root|previous|date|duration|now|timezone|int)\b$/.test(token)) return "text-purple-700"; + if (/^\b(?:root|previous|secrets|date|duration|now|timezone|int)\b$/.test(token)) return "text-purple-700"; if (/^\b(?:true|false|null)\b$/.test(token)) return "text-emerald-700"; if (/^(==|!=|>=|<=|&&|\|\||[?:+\-*/%().,[\]])$/.test(token)) return "text-gray-600"; return "text-gray-800 dark:text-gray-100"; @@ -73,6 +73,10 @@ export function ExpressionEnvironment() {
  • previous(): previous node outputs (optional depth)
  • +
  • + secrets("name").key: organization secret value (resolved at + execution time) +
  • );