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
74 changes: 74 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ flowchart TD
| `step.http_proxy` | Proxies an HTTP request to an upstream service | pipelinesteps |
| `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.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 @@ -1103,6 +1104,79 @@ steps:

---

### `step.secret_fetch`

Fetches one or more secrets from a named secrets module (`secrets.aws`, `secrets.vault`, etc.) and exposes the resolved values as step outputs. Secret IDs / ARNs are Go template expressions evaluated against the live pipeline context, enabling **per-tenant dynamic secret resolution**.

**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 output key → secret ID/ARN. Values support Go template expressions for dynamic resolution. |

**Output fields:** One field per key in `secrets`, each containing the resolved secret value. `fetched: true` when all secrets were successfully fetched.

**Examples:**

Static secret IDs:

```yaml
steps:
- name: fetch_creds
type: step.secret_fetch
config:
module: aws-secrets
secrets:
token_url: "arn:aws:secretsmanager:us-east-1:123:secret:token-url"
client_id: "arn:aws:secretsmanager:us-east-1:123:secret:client-id"
client_secret: "arn:aws:secretsmanager:us-east-1:123:secret:client-secret"
```

Tenant-aware dynamic resolution (ARNs from a previous step's output):

```yaml
steps:
- name: lookup_integration
type: step.db_query
config:
query: "SELECT * FROM integrations WHERE tenant_id = $1"
params: ["{{.tenant_id}}"]

- name: fetch_creds
type: step.secret_fetch
config:
module: aws-secrets
secrets:
token_url: "{{.steps.lookup_integration.row.token_url_secret_arn}}"
client_id: "{{.steps.lookup_integration.row.client_id_secret_arn}}"
client_secret: "{{.steps.lookup_integration.row.client_secret_secret_arn}}"

- name: call_api
type: step.http_call
config:
url: "https://login.example.com/services/oauth2/token"
method: POST
oauth2:
token_url: "{{.steps.fetch_creds.token_url}}"
client_id: "{{.steps.fetch_creds.client_id}}"
client_secret: "{{.steps.fetch_creds.client_secret}}"
```

Per-tenant ARN construction using trigger data:

```yaml
steps:
- name: fetch_tenant_secret
type: step.secret_fetch
config:
module: aws-secrets
secrets:
api_key: "arn:aws:secretsmanager:us-east-1:123:secret:{{.tenant_id}}-api-key"
```

---

### `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 @@ -1428,6 +1428,11 @@ func KnownStepTypes() map[string]StepTypeInfo {
Plugin: "secrets",
ConfigKeys: []string{"provider", "key", "notify_module"},
},
"step.secret_fetch": {
Type: "step.secret_fetch",
Plugin: "pipelinesteps",
ConfigKeys: []string{"module", "secrets"},
},
}
// Include any step types registered dynamically (e.g. from external plugins).
for _, t := range schema.KnownModuleTypes() {
Expand Down
2 changes: 1 addition & 1 deletion mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ type Server struct {
mcpServer *server.MCPServer
pluginDir string
registryDir string
documentationFile string // optional explicit path to DOCUMENTATION.md
documentationFile string // optional explicit path to DOCUMENTATION.md
engine EngineProvider // optional; enables execution tools when set
}

Expand Down
8 changes: 4 additions & 4 deletions modernize/manifest_rule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,11 +453,11 @@ modules:
// the Fix function does not create duplicate keys.
func TestManifestRule_ModuleConfigKeyRename_Collision(t *testing.T) {
mr := ManifestRule{
ID: "test-collision-mod",
ID: "test-collision-mod",
Description: "Rename old_key to new_key in my.module",
ModuleType: "my.module",
OldKey: "old_key",
NewKey: "new_key",
ModuleType: "my.module",
OldKey: "old_key",
NewKey: "new_key",
}
rule, err := mr.ToRule()
if err != nil {
Expand Down
127 changes: 127 additions & 0 deletions module/pipeline_step_secret_fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package module

import (
"context"
"fmt"
"strings"

"github.com/GoCodeAlone/modular"
)

// SecretFetchProvider is the minimal interface required by SecretFetchStep.
// Both SecretsAWSModule and SecretsVaultModule satisfy this interface.
type SecretFetchProvider interface {
Get(ctx context.Context, key string) (string, error)
}

// SecretFetchStep fetches one or more secrets from a named secrets module
// (e.g. secrets.aws, secrets.vault) and exposes the resolved values as step
// outputs. Secret IDs / ARNs are Go template expressions evaluated against
// the live PipelineContext, enabling per-tenant dynamic resolution:
//
// config:
// module: aws-secrets
// secrets:
// token_url: "{{.steps.lookup.row.token_url_arn}}"
// client_id: "{{.steps.lookup.row.client_id_arn}}"
type SecretFetchStep struct {
name string
moduleName string // service name registered by the secrets module
secrets map[string]string // output key → secret ID/ARN (may contain templates)
app modular.Application
tmpl *TemplateEngine
}

// NewSecretFetchStepFactory returns a StepFactory that creates SecretFetchStep instances.
func NewSecretFetchStepFactory() 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_fetch step %q: 'module' is required", name)
}

raw, _ := config["secrets"].(map[string]any)
if len(raw) == 0 {
return nil, fmt.Errorf("secret_fetch 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 k == "fetched" {
return nil, fmt.Errorf("secret_fetch step %q: secrets key %q is reserved for step output status", name, k)
}
idStr, ok := v.(string)
if !ok {
return nil, fmt.Errorf("secret_fetch step %q: secrets[%q] must be a string (secret ID or ARN)", name, k)
}
if strings.TrimSpace(idStr) == "" {
return nil, fmt.Errorf("secret_fetch step %q: secrets[%q] must not be empty", name, k)
}
secretMap[k] = idStr
}

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

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

// Execute resolves the secret IDs/ARNs using the pipeline context (enabling
// per-tenant dynamic resolution), fetches each secret from the named secrets
// module, and returns the resolved values as step output.
func (s *SecretFetchStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) {
if s.app == nil {
return nil, fmt.Errorf("secret_fetch step %q: no application context", s.name)
}

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

output := make(map[string]any, len(s.secrets)+1)

for outputKey, idTemplate := range s.secrets {
// Resolve the secret ID/ARN template against the current pipeline context.
// This enables tenant-aware ARNs such as:
// "arn:aws:secretsmanager:us-east-1:123:secret:{{.tenant_id}}-creds"
resolvedID, resolveErr := s.tmpl.Resolve(idTemplate, pc)
if resolveErr != nil {
return nil, fmt.Errorf("secret_fetch step %q: failed to resolve secret ID for %q: %w", s.name, outputKey, resolveErr)
}
if strings.TrimSpace(resolvedID) == "" {
return nil, fmt.Errorf("secret_fetch step %q: resolved secret ID for %q is empty (check template expression %q)", s.name, outputKey, idTemplate)
}

value, fetchErr := provider.Get(ctx, resolvedID)
if fetchErr != nil {
return nil, fmt.Errorf("secret_fetch step %q: failed to fetch secret %q (id=%q): %w", s.name, outputKey, resolvedID, fetchErr)
}

output[outputKey] = value
}

output["fetched"] = true
return &StepResult{Output: output}, nil
}

// resolveProvider looks up the SecretFetchProvider from the application service
// registry using the configured module name.
func (s *SecretFetchStep) resolveProvider() (SecretFetchProvider, error) {
svc, ok := s.app.SvcRegistry()[s.moduleName]
if !ok {
return nil, fmt.Errorf("secret_fetch step %q: secrets module %q not found in service registry", s.name, s.moduleName)
}
provider, ok := svc.(SecretFetchProvider)
if !ok {
return nil, fmt.Errorf("secret_fetch step %q: service %q does not implement SecretFetchProvider (Get method)", s.name, s.moduleName)
}
return provider, nil
}
Loading
Loading