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
1 change: 1 addition & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ flowchart TD
| `step.iac_provider_apply` | Applies a plan after recomputing + validating `desired_hash` (stateless two-phase TOCTOU guard) | platform |
| `step.iac_provider_destroy` | Destroys resources via an `iac.provider` plugin | platform |
| `step.iac_provider_drift` | Detects drift via an `iac.provider` (optional `IaCProviderDriftDetector`; `supported:false` fallback) | platform |
| `step.iac_secret_reachability` | Pre-flight gate: checks whether `secret://` refs in plan specs are reachable from the chosen exec-env. The verdict is provider-level (one `CheckAccess` probe; the same result is reported per distinct ref); returns `all_reachable` bool. Fail-safe for remote exec-envs (host-local backends unverifiable per ADR 0017, unknown backends, and probe failure → unreachable) | platform |
| `step.tofu_init` | Initializes an OpenTofu working directory | platform |
| `step.tofu_plan` | Creates an OpenTofu execution plan | platform |
| `step.tofu_apply` | Applies OpenTofu changes to infrastructure | platform |
Expand Down
218 changes: 218 additions & 0 deletions module/pipeline_step_iac_secret_reachability.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package module

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

"github.com/GoCodeAlone/modular"
"github.com/GoCodeAlone/workflow/iac/specparse"
"github.com/GoCodeAlone/workflow/interfaces"
"github.com/GoCodeAlone/workflow/secrets"
)

// ─── step.iac_secret_reachability ────────────────────────────────────────────

// IaCSecretReachabilityStep pre-flights whether the secrets referenced in a set
// of IaC resource specs can be read from the chosen execution environment.
//
// It resolves the secrets provider from the app service registry (mirrors the
// resolveProvider pattern in SecretSetStep), gathers all distinct secret://
// references from the spec configs (including nested maps and slices), calls
// secrets.Reachability once for the provider, and reports per-ref results.
//
// Output shape:
//
// {
// "secrets": [ {ref, reachable, reason}, ... ],
// "all_reachable": bool,
// }
//
// When there are zero secret:// refs, all_reachable is true with an empty list.
type IaCSecretReachabilityStep struct {
name string
provider string // secrets service name in the app registry
execEnv string
specs []interfaces.ResourceSpec
specsFrom string // dotted context path; mutually exclusive with specs
app modular.Application
}

// NewIaCSecretReachabilityStepFactory returns a StepFactory for
// step.iac_secret_reachability.
func NewIaCSecretReachabilityStepFactory() StepFactory {
return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) {
providerName, _ := cfg["provider"].(string)
if providerName == "" {
return nil, fmt.Errorf("iac_secret_reachability step %q: 'provider' is required", name)
}

execEnv, _ := cfg["exec_env"].(string)

specsFrom, _ := cfg["specs_from"].(string)
_, hasStaticSpecs := cfg["specs"]
if specsFrom != "" && hasStaticSpecs {
return nil, fmt.Errorf("iac_secret_reachability step %q: 'specs' and 'specs_from' are mutually exclusive", name)
}

var specs []interfaces.ResourceSpec
if hasStaticSpecs {
var err error
specs, err = specparse.ParseResourceSpecs(cfg["specs"])
if err != nil {
return nil, fmt.Errorf("iac_secret_reachability step %q: parse specs: %w", name, err)
}
}

return &IaCSecretReachabilityStep{
name: name,
provider: providerName,
execEnv: execEnv,
specs: specs,
specsFrom: specsFrom,
app: app,
}, nil
}
}

func (s *IaCSecretReachabilityStep) Name() string { return s.name }

func (s *IaCSecretReachabilityStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) {
// Resolve specs: dynamic path takes precedence when specsFrom is configured.
specs := s.specs
if s.specsFrom != "" {
raw := resolveBodyFrom(s.specsFrom, pc)
var err error
specs, err = specparse.ParseResourceSpecs(raw)
if err != nil {
return nil, fmt.Errorf("iac_secret_reachability step %q: resolve specs_from %q: %w", s.name, s.specsFrom, err)
}
// Fail-safe: a specs_from that resolves to nil/empty (missing/misspelled
// context path, or a body lacking specs) must NOT silently bypass the
// gate with all_reachable=true. Error, matching iac_provider_plan/apply.
if len(specs) == 0 {
return nil, fmt.Errorf("iac_secret_reachability step %q: specs_from %q resolved to empty/zero specs", s.name, s.specsFrom)
}
}
Comment thread
intel352 marked this conversation as resolved.

// Resolve the secrets provider from the service registry.
p, err := resolveSecretsProvider(s.app, s.provider, s.name)
if err != nil {
return nil, err
}

// Gather distinct secret:// refs across all spec configs.
refs := collectSecretRefs(specs)

// Short-path: no secret refs → trivially reachable.
if len(refs) == 0 {
return &StepResult{Output: map[string]any{
"secrets": []map[string]any{},
"all_reachable": true,
}}, nil
}

// Call Reachability once — the verdict is provider-level, not per-ref.
// Propagate the Execute ctx so a slow/unreachable backend probe is bounded
// by the pipeline/route deadline rather than hanging the pre-flight.
verdict := secrets.Reachability(ctx, p, s.execEnv)

secretEntries := make([]map[string]any, 0, len(refs))
for _, ref := range refs {
entry := map[string]any{
"ref": ref,
"reachable": verdict.Reachable,
}
if !verdict.Reachable {
entry["reason"] = verdict.Reason
}
secretEntries = append(secretEntries, entry)
}

allReachable := verdict.Reachable

return &StepResult{Output: map[string]any{
"secrets": secretEntries,
"all_reachable": allReachable,
}}, nil
}

// resolveSecretsProvider looks up a secrets.Provider from the application
// service registry by name. It mirrors the resolveProvider pattern in
// SecretSetStep: first checks if the service directly implements secrets.Provider;
// if not, checks for a Provider() accessor (used by SecretsAWSModule,
// SecretsVaultModule, etc.).
func resolveSecretsProvider(app modular.Application, providerName, stepName string) (secrets.Provider, error) {
if app == nil {
return nil, fmt.Errorf("iac_secret_reachability step %q: no application context", stepName)
}
svc, ok := app.SvcRegistry()[providerName]
if !ok {
return nil, fmt.Errorf("iac_secret_reachability step %q: secrets service %q not found in registry", stepName, providerName)
}

// Direct: service itself implements secrets.Provider.
if p, ok := svc.(secrets.Provider); ok {
return p, nil
}

// Indirect: service exposes a Provider() accessor (e.g. SecretsAWSModule,
// SecretsVaultModule).
type providerAccessor interface {
Provider() secrets.Provider
}
if acc, ok := svc.(providerAccessor); ok {
p := acc.Provider()
if p == nil {
return nil, fmt.Errorf("iac_secret_reachability step %q: service %q exposes Provider() accessor but returned nil; module may not be started", stepName, providerName)
}
return p, nil
}

return nil, fmt.Errorf("iac_secret_reachability step %q: service %q does not implement secrets.Provider directly or via Provider() accessor", stepName, providerName)
}

// collectSecretRefs walks the Config map of each ResourceSpec and returns a
// sorted, deduplicated slice of all string values that start with secrets.SecretPrefix.
// It recurses into nested map[string]any, []any, and typed []string values so
// both YAML-decoded and programmatically-built spec configs are fully scanned.
func collectSecretRefs(specs []interfaces.ResourceSpec) []string {
seen := make(map[string]struct{})
for _, spec := range specs {
collectFromValue(spec.Config, seen)
}
refs := make([]string, 0, len(seen))
for ref := range seen {
refs = append(refs, ref)
}
sort.Strings(refs)
return refs
}

// collectFromValue recursively extracts secret:// refs from an arbitrary value.
// It handles both YAML-decoded shapes (map[string]any, []any) and typed Go
// shapes built programmatically (notably []string), since ResourceSpec.Config
// may carry either when specs are constructed in code rather than parsed.
func collectFromValue(v any, seen map[string]struct{}) {
switch val := v.(type) {
case string:
if strings.HasPrefix(val, secrets.SecretPrefix) {
seen[val] = struct{}{}
}
case []string:
for _, item := range val {
if strings.HasPrefix(item, secrets.SecretPrefix) {
seen[item] = struct{}{}
}
}
case map[string]any:
for _, child := range val {
collectFromValue(child, seen)
}
case []any:
for _, item := range val {
collectFromValue(item, seen)
}
}
}
93 changes: 93 additions & 0 deletions module/pipeline_step_iac_secret_reachability_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package module

import (
"reflect"
"testing"

"github.com/GoCodeAlone/workflow/interfaces"
)

// TestCollectSecretRefs exercises collectSecretRefs/collectFromValue across every
// container shape a ResourceSpec.Config can carry: a flat string ref, a ref
// nested inside a map[string]any, a ref inside an []any of strings, a ref inside
// an []any of maps, a ref inside a typed []string (programmatically-built specs),
// and double-nesting. It also asserts dedup + sorted order.
func TestCollectSecretRefs(t *testing.T) {
specs := []interfaces.ResourceSpec{
{
Name: "r1",
Type: "infra.database",
Config: map[string]any{
// flat string ref
"password": "secret://vault/db-pass",
// non-ref string (ignored)
"region": "us-east-1",
// ref nested in a map[string]any
"nested": map[string]any{
"token": "secret://vault/nested-token",
},
// ref in an []any of strings
"list_any_strings": []any{
"plain",
"secret://vault/from-anylist",
},
// ref in an []any of maps
"list_any_maps": []any{
map[string]any{"key": "secret://vault/from-anymap"},
},
// ref in a typed []string (programmatically-built)
"list_typed_strings": []string{
"not-a-ref",
"secret://vault/from-typedlist",
},
// double-nesting: map → slice → map → string
"deep": map[string]any{
"items": []any{
map[string]any{
"deeper": map[string]any{
"v": "secret://vault/deep-ref",
},
},
},
},
},
},
{
Name: "r2",
Type: "infra.database",
Config: map[string]any{
// duplicate of r1's password — must be deduped
"password": "secret://vault/db-pass",
// a unique ref in r2
"api": "secret://vault/r2-api",
},
},
}

got := collectSecretRefs(specs)

want := []string{
"secret://vault/db-pass",
"secret://vault/deep-ref",
"secret://vault/from-anylist",
"secret://vault/from-anymap",
"secret://vault/from-typedlist",
"secret://vault/nested-token",
"secret://vault/r2-api",
}

if !reflect.DeepEqual(got, want) {
t.Errorf("collectSecretRefs mismatch:\n got: %#v\n want: %#v", got, want)
}
}

// TestCollectSecretRefs_NoRefs asserts an empty (non-nil) slice when no refs exist.
func TestCollectSecretRefs_NoRefs(t *testing.T) {
specs := []interfaces.ResourceSpec{
{Name: "r1", Type: "infra.database", Config: map[string]any{"size": "small"}},
}
got := collectSecretRefs(specs)
if len(got) != 0 {
t.Errorf("expected no refs, got %#v", got)
}
}
Loading
Loading