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
202 changes: 184 additions & 18 deletions cmd/wfctl/infra_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

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

Expand All @@ -33,14 +34,13 @@ func (f *multiStringFlag) Set(v string) error {
}

// isRotatableType returns true for secret types that wfctl can regenerate
// locally. provider_credential types must be rotated upstream (the cloud
// provider issues new keys); rotating them here would produce an unusable
// credential that diverges from what the upstream provider tracks.
// locally. provider_credential rotation requires the upstream provider plugin
// to revoke the old credential (mint-new-then-revoke-old; see ADR 0012).
// infra_output types are derived from apply-time state and are never
// generated by bootstrap at all.
func isRotatableType(t string) bool {
switch t {
case "random_hex", "random_base64", "random_alphanumeric":
case "random_hex", "random_base64", "random_alphanumeric", "provider_credential":
return true
default:
return false
Expand Down Expand Up @@ -107,7 +107,21 @@ func runInfraBootstrap(args []string) error {
if err != nil {
return fmt.Errorf("resolve secrets provider: %w", err)
}
generated, err := bootstrapSecrets(ctx, provider, secretsCfg, forceRotate)

// Load the IaC provider for ProviderCredentialRevoker if any force-rotate
// target is a provider_credential type. We do this lazily (only when needed)
// so that runs without --force-rotate on provider_credential don't require
// the plugin binary to be installed.
revoker, iacCloser := resolveCredentialRevoker(ctx, cfgFile, secretsCfg, forceRotate)
Comment on lines +111 to +115
if iacCloser != nil {
defer func() {
if cerr := iacCloser.Close(); cerr != nil {
fmt.Fprintf(os.Stderr, "warning: credential revoker provider shutdown: %v\n", cerr)
}
}()
}

generated, err := bootstrapSecrets(ctx, provider, secretsCfg, forceRotate, revoker)
if err != nil {
return err
}
Expand All @@ -133,7 +147,7 @@ func runInfraBootstrap(args []string) error {
// secretsCfg.Generate and returns a map[name]bool for O(1) lookup.
// It returns an error if:
// - a name has no matching secrets.generate[] entry (typo guard)
// - a name refers to a provider_credential type (must rotate upstream)
// - a name refers to an infra_output type (never generated by bootstrap)
func buildForceRotateSet(names []string, cfg *SecretsConfig) (map[string]bool, error) {
if len(names) == 0 {
return nil, nil
Expand All @@ -154,7 +168,7 @@ func buildForceRotateSet(names []string, cfg *SecretsConfig) (map[string]bool, e
return nil, fmt.Errorf("no secrets.generate entry named %q in infra.yaml", name)
}
if !isRotatableType(gen.Type) {
return nil, fmt.Errorf("--force-rotate %q: %s secrets must be rotated via the upstream provider; cannot regenerate locally", name, gen.Type)
return nil, fmt.Errorf("--force-rotate %q: %s is not rotatable by bootstrap (rotatable types: random_hex, random_base64, random_alphanumeric, provider_credential)", name, gen.Type)
}
Comment on lines 170 to 172
set[name] = true
}
Expand Down Expand Up @@ -270,6 +284,91 @@ func bootstrapStateBackend(ctx context.Context, cfgFile string) error {
return nil
}

// resolveCredentialRevoker checks whether any force-rotate target is a
// provider_credential type and, if so, loads the IaC provider plugin and
// returns a ProviderCredentialRevoker (if the plugin supports it) and an
// io.Closer for the plugin process.
//
// Returns (nil, nil) when:
// - no provider_credential rotation is requested, OR
// - the config has no iac.provider module, OR
// - loading the IaC provider fails (warning logged).
//
// Returns (nil, iacCloser) when the provider was loaded but does not implement
// ProviderCredentialRevoker (warning logged; caller must close iacCloser).
//
// The returned Closer (if non-nil) must be closed by the caller — it MUST NOT
// be deferred inside a loop; callers should defer it at the function scope.
func resolveCredentialRevoker(ctx context.Context, cfgFile string, secretsCfg *SecretsConfig, forceRotate map[string]bool) (interfaces.ProviderCredentialRevoker, interface{ Close() error }) {
if len(forceRotate) == 0 || secretsCfg == nil {
return nil, nil
}
// Check if any force-rotate target is provider_credential.
needsRevoker := false
for _, gen := range secretsCfg.Generate {
if forceRotate[gen.Key] && gen.Type == "provider_credential" {
needsRevoker = true
break
}
}
if !needsRevoker {
return nil, nil
}

iacProv, iacCloser, loadErr := loadIaCProviderFromConfig(ctx, cfgFile)
if loadErr != nil {
// Non-fatal: log warning; credential will still be minted but old one
// won't be automatically revoked at the upstream provider.
fmt.Fprintf(os.Stderr, "warn: could not load IaC provider for credential revocation: %v\n", loadErr)
fmt.Fprintf(os.Stderr, "warn: old provider credential will NOT be revoked automatically — revoke manually\n")
return nil, nil
}
if iacProv == nil {
// No iac.provider module in config.
fmt.Fprintf(os.Stderr, "warn: no iac.provider module in config — old provider credential will NOT be revoked automatically\n")
return nil, nil
}
r, ok := iacProv.(interfaces.ProviderCredentialRevoker)
if !ok {
fmt.Fprintf(os.Stderr, "warn: IaC provider does not implement ProviderCredentialRevoker — old credential will NOT be revoked automatically\n")
return nil, iacCloser
}
return r, iacCloser
}

// loadIaCProviderFromConfig finds the first iac.provider module in cfgFile,
// loads the provider plugin, and returns it. Returns (nil, nil, nil) when no
// iac.provider module is declared (caller treats as "provider not available").
// The returned io.Closer (if non-nil) must be closed by the caller.
func loadIaCProviderFromConfig(ctx context.Context, cfgFile string) (interfaces.IaCProvider, interface{ Close() error }, error) {
rawCfg, err := config.LoadFromFile(cfgFile)
if err != nil {
return nil, nil, fmt.Errorf("load config: %w", err)
}
var provType string
var provCfg map[string]any
for i := range rawCfg.Modules {
mod := &rawCfg.Modules[i]
if mod.Type != "iac.provider" {
continue
}
modCfg := config.ExpandEnvInMap(mod.Config)
if pt, ok := modCfg["provider"].(string); ok && pt != "" {
provType = pt
provCfg = modCfg
break
}
}
if provType == "" {
return nil, nil, nil // no iac.provider module in config
}
prov, closer, err := resolveIaCProvider(ctx, provType, provCfg)
if err != nil {
return nil, nil, fmt.Errorf("load provider %q: %w", provType, err)
}
return prov, closer, nil
}

// writeBucketBackToConfig rewrites the iac.state module's `bucket:` field in
// cfgFile with the resolved bucket name. It is backend-neutral — the same
// field name is used by all remote backends (spaces, s3, gcs, azure). It uses
Expand Down Expand Up @@ -368,7 +467,17 @@ func expectedStoredKeys(gen SecretGen) []string {
// re-creating it (best-effort delete: a missing secret is logged as a warning
// and treated as the "no pre-existing value" case). After rotation the audit
// line is written to stderr.
func bootstrapSecrets(ctx context.Context, provider secrets.Provider, cfg *SecretsConfig, forceRotate map[string]bool) (map[string]string, error) {
//
// revoker is an optional ProviderCredentialRevoker. When non-nil and a
// provider_credential is being force-rotated, the OLD credential is revoked at
// the upstream provider AFTER the new credential has been minted and stored
// (mint-new-then-revoke-old; see ADR 0012). If revoker is nil, the old
// credential is not explicitly revoked (operator must revoke manually).
func bootstrapSecrets(ctx context.Context, provider secrets.Provider, cfg *SecretsConfig, forceRotate map[string]bool, revoker ...interfaces.ProviderCredentialRevoker) (map[string]string, error) {
var credRevoker interfaces.ProviderCredentialRevoker
if len(revoker) > 0 {
credRevoker = revoker[0]
}
generated := map[string]string{}

// Cache List() as a set so repeated probes in a bootstrap run only hit
Expand Down Expand Up @@ -424,14 +533,52 @@ func bootstrapSecrets(ctx context.Context, provider secrets.Provider, cfg *Secre
genConfig["source"] = gen.Source
}

// --force-rotate path: delete existing value (best-effort) so that the
// --force-rotate path: for provider_credential, read the OLD access_key
// BEFORE deleting so we can revoke it at the upstream provider after minting
// the new one (mint-new-then-revoke-old; see ADR 0012).
// For other types, delete the existing value (best-effort) so that the
// normal "doesn't exist → generate + create" code path runs below.
var oldCredentialID string // non-empty only for provider_credential + force-rotate
if forceRotate[gen.Key] {
deleteKey := gen.Key
if delErr := provider.Delete(ctx, deleteKey); delErr != nil {
// Log and continue regardless of the error kind — both absent/unsupported
// secrets and unexpected errors should not block the rotation flow.
fmt.Fprintf(os.Stderr, "warn: rotate-pre-delete %s: %v (continuing)\n", deleteKey, delErr)
if gen.Type == "provider_credential" {
// Always attempt to read the old access_key, even when credRevoker
// is nil: this allows the "no revoker available" warning to include the
// old credential ID so operators can revoke it manually.
accessKeyName := gen.Key + "_access_key"
if oldVal, getErr := provider.Get(ctx, accessKeyName); getErr == nil {
oldCredentialID = oldVal
} else if errors.Is(getErr, secrets.ErrUnsupported) {
// Write-only provider (e.g. GitHub Actions) — Get is not supported.
// Revocation will be skipped; warn the operator.
fmt.Fprintf(os.Stderr, "warn: secrets provider does not support Get — cannot read old %s for revocation; revoke manually after rotation\n", accessKeyName)
} else if !errors.Is(getErr, secrets.ErrNotFound) {
// Unexpected error reading old credential (not "doesn't exist" and not "unsupported").
// Log a warning so operators know revocation may not occur, but continue — we still
// mint and store the new credential.
fmt.Fprintf(os.Stderr, "warn: could not read old %s for revocation (%v) — revoke manually after rotation\n", accessKeyName, getErr)
}
// Delete all sub-keys for this provider_credential.
subKeys, hasSubKeys := subKeysForSource(gen.Source)
if hasSubKeys {
for _, sub := range subKeys {
fullKey := gen.Key + "_" + sub
if delErr := provider.Delete(ctx, fullKey); delErr != nil {
fmt.Fprintf(os.Stderr, "warn: rotate-pre-delete %s: %v (continuing)\n", fullKey, delErr)
}
}
} else {
// Unknown source — best-effort single delete.
if delErr := provider.Delete(ctx, accessKeyName); delErr != nil {
fmt.Fprintf(os.Stderr, "warn: rotate-pre-delete %s: %v (continuing)\n", accessKeyName, delErr)
}
}
} else {
deleteKey := gen.Key
if delErr := provider.Delete(ctx, deleteKey); delErr != nil {
// Log and continue regardless of the error kind — both absent/unsupported
// secrets and unexpected errors should not block the rotation flow.
fmt.Fprintf(os.Stderr, "warn: rotate-pre-delete %s: %v (continuing)\n", deleteKey, delErr)
}
}
// Invalidate the List cache so the existence check below reflects the
// deletion. A fresh List call after Delete is the safe path for
Expand Down Expand Up @@ -471,15 +618,34 @@ func bootstrapSecrets(ctx context.Context, provider secrets.Provider, cfg *Secre

// For provider_credential results (JSON map), store each sub-key.
if gen.Type == "provider_credential" {
var subKeys map[string]string
if jsonErr := json.Unmarshal([]byte(value), &subKeys); jsonErr == nil {
for subKey, subVal := range subKeys {
var subKeyMap map[string]string
if jsonErr := json.Unmarshal([]byte(value), &subKeyMap); jsonErr == nil {
for subKey, subVal := range subKeyMap {
fullKey := gen.Key + "_" + subKey
if setErr := provider.Set(ctx, fullKey, subVal); setErr != nil {
return generated, fmt.Errorf("store secret %q: %w", fullKey, setErr)
}
generated[fullKey] = subVal
fmt.Printf(" secret %q: created\n", fullKey)
if forceRotate[gen.Key] {
fmt.Printf(" secret %q: rotated\n", fullKey)
} else {
fmt.Printf(" secret %q: created\n", fullKey)
}
}
if forceRotate[gen.Key] {
fmt.Fprintf(os.Stderr, "wfctl: rotated provider_credential %s (replaced existing value at %s)\n", gen.Key, time.Now().UTC().Format(time.RFC3339))
// Revoke the OLD credential at the upstream provider AFTER storing
// the new one. Failure is non-fatal: the new credential is valid and
// must not be rolled back. Log warning + continue (see ADR 0012).
if credRevoker != nil && oldCredentialID != "" {
if revokeErr := credRevoker.RevokeProviderCredential(ctx, gen.Source, oldCredentialID); revokeErr != nil {
fmt.Fprintf(os.Stderr, "warn: revoke old credential %s (id=%s): %v — revoke manually\n", gen.Key, oldCredentialID, revokeErr)
} else {
fmt.Fprintf(os.Stderr, "wfctl: revoked old credential %s (id=%s)\n", gen.Key, oldCredentialID)
}
} else if credRevoker == nil && oldCredentialID != "" {
fmt.Fprintf(os.Stderr, "warn: no revoker available — old credential %s (id=%s) must be revoked manually\n", gen.Key, oldCredentialID)
Comment on lines +646 to +647
}
}
continue
}
Expand Down
Loading
Loading