Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ flowchart TD
| `step.hash` | Computes a cryptographic hash (md5/sha256/sha512) of a template-resolved input | pipelinesteps |
| `step.regex_match` | Matches a regular expression against a template-resolved input | pipelinesteps |
| `step.secret_fetch` | Fetches one or more secrets from a secrets module (secrets.aws, secrets.vault) with dynamic tenant-aware secret ID resolution | pipelinesteps |
| `step.secret_set` | Writes one or more secrets to a secrets module; values are Go template expressions resolved against the pipeline context | pipelinesteps |
| `step.jq` | Applies a JQ expression to pipeline data for complex transformations | pipelinesteps |
| `step.ai_complete` | AI text completion using a configured provider | ai |
| `step.ai_classify` | AI text classification into named categories | ai |
Expand Down Expand Up @@ -1285,6 +1286,33 @@ steps:

---

### `step.secret_set`

Writes one or more secrets to a named secrets module (`secrets.aws`, `secrets.vault`, etc.). Secret values are Go template expressions evaluated against the live pipeline context, enabling values from prior step outputs or trigger data to be persisted into a secrets provider.

**Configuration:**

| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `module` | string | yes | Service name of the secrets module (the `name` field in the module config). |
| `secrets` | map[string]string | yes | Map of secret key → value (or template expression). Values support Go template syntax for dynamic resolution. |

**Output fields:** `set_keys` — sorted list of secret keys that were written.

**Example:**

```yaml
- type: step.secret_set
name: save-creds
config:
module: zoom-secrets
secrets:
client_id: "{{ .steps.setup_form.client_id }}"
client_secret: "{{ .steps.setup_form.client_secret }}"
```

---

### `step.ai_complete`

Invokes an AI provider to produce a text completion. Provider resolution order: explicit `provider` name, then model-based lookup, then first registered provider.
Expand Down
5 changes: 5 additions & 0 deletions cmd/wfctl/type_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -1618,6 +1618,11 @@ func KnownStepTypes() map[string]StepTypeInfo {
Plugin: "pipelinesteps",
ConfigKeys: []string{"module", "secrets"},
},
"step.secret_set": {
Type: "step.secret_set",
Plugin: "pipelinesteps",
ConfigKeys: []string{"module", "secrets"},
},
}
// Include any step types registered dynamically (e.g. from external plugins).
for _, t := range schema.KnownModuleTypes() {
Expand Down
171 changes: 171 additions & 0 deletions module/pipeline_step_secret_set.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package module

import (
"context"
"fmt"
"sort"
"strings"

"github.com/GoCodeAlone/modular"
"github.com/GoCodeAlone/workflow/secrets"
)

// SecretSetProvider is the minimal interface required by SecretSetStep.
// Any module used by step.secret_set must expose a Set method matching this
// signature — either directly on the registered service, or on the underlying
// secrets.Provider accessible via a Provider() accessor. Built-in secrets
// modules (secrets.aws, secrets.vault) satisfy this via their Provider()
// method since the module wrappers don't expose Set directly.
type SecretSetProvider interface {
Set(ctx context.Context, key, value string) error
}

// SecretSetStep writes one or more secrets to a named secrets module
// (e.g. secrets.aws, secrets.vault). Secret values are Go template expressions
// evaluated against the live PipelineContext, enabling dynamic values from
// prior step outputs or trigger data:
//
// config:
// module: zoom-secrets
// secrets:
// client_id: "{{.steps.form.client_id}}"
// client_secret: "{{.steps.form.client_secret}}"
type SecretSetStep struct {
name string
moduleName string // service name registered by the secrets module
secrets map[string]string // secret key → value template (may contain Go templates)
app modular.Application
tmpl *TemplateEngine
}

// NewSecretSetStepFactory returns a StepFactory that creates SecretSetStep instances.
func NewSecretSetStepFactory() StepFactory {
return func(name string, config map[string]any, app modular.Application) (PipelineStep, error) {
moduleName, _ := config["module"].(string)
if moduleName == "" {
return nil, fmt.Errorf("secret_set step %q: 'module' is required", name)
}

raw, _ := config["secrets"].(map[string]any)
if len(raw) == 0 {
return nil, fmt.Errorf("secret_set step %q: 'secrets' map is required and must not be empty", name)
}

secretMap := make(map[string]string, len(raw))
for k, v := range raw {
if strings.TrimSpace(k) == "" {
return nil, fmt.Errorf("secret_set step %q: secrets key must not be empty", name)
}
valStr, ok := v.(string)
if !ok {
return nil, fmt.Errorf("secret_set step %q: secrets[%q] must be a string (value or template)", name, k)
}
secretMap[k] = valStr
}

return &SecretSetStep{
name: name,
moduleName: moduleName,
secrets: secretMap,
app: app,
tmpl: NewTemplateEngine(),
}, nil
}
}

// Name returns the step name.
func (s *SecretSetStep) Name() string { return s.name }

// Execute resolves the value templates using the pipeline context, writes each
// secret to the named secrets module via provider.Set, and returns the list of
// written keys as step output for observability.
//
// Empty resolved values are permitted (useful for clearing a secret).
// On partial failure (e.g., the 3rd of 5 keys fails), earlier writes are
// already committed — secrets backends have no transaction primitive.
// The returned error identifies which key failed.
func (s *SecretSetStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) {
if s.app == nil {
return nil, fmt.Errorf("secret_set step %q: no application context", s.name)
}

provider, err := s.resolveProvider()
if err != nil {
return nil, err
}

// Sort keys for deterministic write order. This ensures partial failures
// (where provider.Set fails mid-way) are reproducible rather than
// dependent on Go's random map iteration order.
sortedKeys := make([]string, 0, len(s.secrets))
for k := range s.secrets {
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)

setKeys := make([]string, 0, len(s.secrets))

for _, keyName := range sortedKeys {
valueTemplate := s.secrets[keyName]
// Resolve the value template against the current pipeline context.
// This enables dynamic values such as form fields from prior steps:
// "{{.steps.form.client_id}}"
resolvedValue, resolveErr := s.tmpl.Resolve(valueTemplate, pc)
if resolveErr != nil {
return nil, fmt.Errorf("secret_set step %q: failed to resolve value for %q: %w", s.name, keyName, resolveErr)
}

// Guard against writing Go template sentinel "<no value>" into the
// secrets backend. In non-strict mode the template engine resolves
// missing keys to this sentinel and logs a warning — acceptable for
// display but dangerous when persisting secrets.
if strings.Contains(resolvedValue, "<no value>") {
return nil, fmt.Errorf("secret_set step %q: resolved value for %q contains '<no value>' (template key may be missing or misspelled)", s.name, keyName)
}

if setErr := provider.Set(ctx, keyName, resolvedValue); setErr != nil {
return nil, fmt.Errorf("secret_set step %q: failed to set secret %q: %w", s.name, keyName, setErr)
}
Comment on lines +109 to +128

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In non-strict template mode, a missing map key resolves to the literal string "" (see pipeline.TemplateEngine behavior). Because this step permits empty values and does not validate the resolved string, a typo in a template can silently write "" (or a string containing it) into the secrets backend. Consider failing fast when the resolved value is "" (or contains it) for templated inputs, or resolving with a strict/"missingkey=error" pass before writing to prevent accidental secret corruption.

Copilot uses AI. Check for mistakes.

setKeys = append(setKeys, keyName)
}

// setKeys is already in sorted order (built from sortedKeys iteration).
return &StepResult{Output: map[string]any{
"set_keys": setKeys,
}}, nil
}

// resolveProvider looks up the SecretSetProvider from the application service
// registry using the configured module name. It first checks if the service
// directly implements SecretSetProvider; if not, it checks for a Provider()
// accessor (used by SecretsAWSModule, SecretsVaultModule) and asserts the
// underlying provider implements Set.
func (s *SecretSetStep) resolveProvider() (SecretSetProvider, error) {
svc, ok := s.app.SvcRegistry()[s.moduleName]
if !ok {
return nil, fmt.Errorf("secret_set step %q: secrets module %q not found in service registry", s.name, s.moduleName)
}

// Direct: service itself implements Set.
if provider, ok := svc.(SecretSetProvider); ok {
return provider, nil
}

// Indirect: service exposes a Provider() accessor (e.g. SecretsAWSModule,
// SecretsVaultModule) whose underlying secrets.Provider implements Set.
type providerAccessor interface {
Provider() secrets.Provider
}
Comment on lines +144 to +159

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveProvider requires the service itself to implement SecretSetProvider (Set). The built-in secrets modules (e.g., module/SecretsAWSModule and module/SecretsVaultModule) register themselves as the service instance and currently don't implement Set, so step.secret_set will error with “does not implement SecretSetProvider” when used with those modules. Fix by either adding Set methods on those modules that delegate to their underlying provider, or by updating resolveProvider to detect modules exposing Provider() and use that provider's Set implementation.

Copilot uses AI. Check for mistakes.
if accessor, ok := svc.(providerAccessor); ok {
underlying := accessor.Provider()
if underlying == nil {
return nil, fmt.Errorf("secret_set step %q: service %q exposes Provider() accessor but returned nil provider; secrets module may not be started or initialized", s.name, s.moduleName)
}
if provider, ok := underlying.(SecretSetProvider); ok {
return provider, nil
}
}

return nil, fmt.Errorf("secret_set step %q: service %q does not implement SecretSetProvider (Set method) directly or via Provider() accessor", s.name, s.moduleName)
}
Loading
Loading