Skip to content
Merged
127 changes: 115 additions & 12 deletions cmd/wfctl/secrets_detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package main

import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"regexp"
"strings"
"time"

"github.com/GoCodeAlone/workflow/config"
"github.com/GoCodeAlone/workflow/secrets"
Expand Down Expand Up @@ -328,12 +330,22 @@ func stdinFileDescriptor() (int, error) {
return int(fd), nil //nolint:gosec // fd is range-checked before conversion.
}

// secretListJSONEntry is the JSON output shape for a single secret in --json mode.
type secretListJSONEntry struct {
Name string `json:"name"`
Store string `json:"store,omitempty"`
State string `json:"state"`
Exists bool `json:"exists"`
UpdatedAt string `json:"updatedAt,omitempty"`
}

func runSecretsList(args []string) error {
fs := flag.NewFlagSet("secrets list", flag.ContinueOnError)
configFile := fs.String("config", "app.yaml", "Workflow config file")
envName := fs.String("env", "", "Environment name for store resolution (optional)")
providerName := fs.String("provider", "", "Ad-hoc provider override (keychain|env|aws); bypasses app.yaml")
service := fs.String("service", "", "Service name for keychain provider")
asJSON := fs.Bool("json", false, "Output as JSON array")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: wfctl secrets list [options]\n\nList declared secrets and their status.\n\nOptions:\n")
fs.PrintDefaults()
Expand Down Expand Up @@ -379,10 +391,14 @@ func runSecretsList(args []string) error {
if err != nil {
return err
}
fmt.Printf("%-40s %-12s %-10s\n", "NAME", "STORE", "STATUS")
fmt.Printf("%-40s %-12s %-10s\n", strings.Repeat("-", 40), strings.Repeat("-", 12), strings.Repeat("-", 10))
if *asJSON {
return printSecretsJSON(statuses)
}
fmt.Printf("%-40s %-12s %-10s %-20s\n", "NAME", "STORE", "STATUS", "UPDATED")
fmt.Printf("%-40s %-12s %-10s %-20s\n", strings.Repeat("-", 40), strings.Repeat("-", 12), strings.Repeat("-", 10), strings.Repeat("-", 20))
for _, s := range statuses {
fmt.Printf("%-40s %-12s %-10s\n", s.Name, s.Store, secretStateLabel(s.State))
updatedAt := formatUpdatedAt(s.LastRotated)
fmt.Printf("%-40s %-12s %-10s %-20s\n", s.Name, s.Store, secretStateLabel(s.State), updatedAt)
}
return nil
}
Expand All @@ -397,25 +413,99 @@ func runSecretsList(args []string) error {
return err
}

fmt.Printf("Provider: %s\n\n", cmp(secretsCfg.Provider, "env"))
fmt.Printf("%-40s %-6s\n", "NAME", "STATUS")
fmt.Printf("%-40s %-6s\n", strings.Repeat("-", 40), "------")
// Check access if the provider supports it (only print in text mode).
if !*asJSON {
if adapter, ok := provider.(secretsProviderAdapter); ok {
if accessErr := adapter.checkAccess(ctx); accessErr != nil {
fmt.Printf("Store access: ✗ %s\n", accessErr.Error())
} else {
fmt.Printf("Store access: ✓\n")
}
}
}

// Build statuses for all declared entries so we can use them for --json or UPDATED column.
// Use Check (not Get) so the adapter's StatAll→Get→List precedence applies — this is
// essential for write-only stores like github where Get returns ErrUnsupported.
var statuses []SecretStatus
for _, entry := range secretsCfg.Entries {
val, _ := provider.Get(ctx, entry.Name)
status := "unset"
if val != "" {
status = "set"
state, _ := provider.Check(ctx, entry.Name)
statuses = append(statuses, SecretStatus{
Name: entry.Name,
Store: cmp(secretsCfg.Provider, "env"),
State: state,
IsSet: state == SecretSet,
})
}

// Enrich with metadata if supported.
if adapter, ok := provider.(secretsProviderAdapter); ok {
if mp, ok2 := adapter.p.(secrets.MetadataProvider); ok2 {
if metas, metaErr := mp.StatAll(ctx); metaErr == nil {
metaByName := make(map[string]secrets.SecretMeta, len(metas))
for _, m := range metas {
metaByName[m.Name] = m
}
for i, s := range statuses {
if m, found := metaByName[s.Name]; found {
statuses[i].LastRotated = m.UpdatedAt
}
}
}
}
}

if *asJSON {
return printSecretsJSON(statuses)
}

fmt.Printf("Provider: %s\n\n", cmp(secretsCfg.Provider, "env"))
fmt.Printf("%-40s %-6s %-20s\n", "NAME", "STATUS", "UPDATED")
fmt.Printf("%-40s %-6s %-20s\n", strings.Repeat("-", 40), "------", strings.Repeat("-", 20))

for i, entry := range secretsCfg.Entries {
desc := ""
if entry.Description != "" {
desc = " # " + entry.Description
}
fmt.Printf("%-40s %-6s%s\n", entry.Name, status, desc)
updatedAt := "—"
if i < len(statuses) {
updatedAt = formatUpdatedAt(statuses[i].LastRotated)
}
fmt.Printf("%-40s %-6s %-20s%s\n", entry.Name, secretStateLabel(statuses[i].State), updatedAt, desc)
}
return nil
}

// printSecretsJSON marshals statuses to a JSON array and writes to stdout.
func printSecretsJSON(statuses []SecretStatus) error {
entries := make([]secretListJSONEntry, len(statuses))
for i, s := range statuses {
entry := secretListJSONEntry{
Name: s.Name,
Store: s.Store,
State: secretStateLabel(s.State),
Exists: s.IsSet,
}
if !s.LastRotated.IsZero() {
entry.UpdatedAt = s.LastRotated.UTC().Format(time.RFC3339)
}
entries[i] = entry
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(entries)
}

// formatUpdatedAt returns a human-readable string for a LastRotated timestamp.
// Returns "—" when the timestamp is zero.
func formatUpdatedAt(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.UTC().Format("2006-01-02 15:04")
}

// secretStateLabel returns a human-readable label for a SecretState.
func secretStateLabel(state SecretState) string {
switch state {
Expand Down Expand Up @@ -510,7 +600,20 @@ func runSecretsInit(args []string) error {
envSuffix = " for environment " + *envName
}
fmt.Printf("Initialized secrets provider %q%s\n", *providerName, envSuffix)
fmt.Printf("Provider %q uses OS environment variables — no additional setup required.\n", *providerName)
switch *providerName {
case "env", "":
fmt.Printf("Provider %q uses OS environment variables — no additional setup required.\n", *providerName)
case "github":
fmt.Printf("Provider %q reads from GitHub Actions secrets — ensure GITHUB_TOKEN is set.\n", *providerName)
case "vault":
fmt.Printf("Provider %q reads from HashiCorp Vault — configure address and token in secrets.config.\n", *providerName)
case "aws":
fmt.Printf("Provider %q reads from AWS Secrets Manager — ensure AWS credentials are available.\n", *providerName)
case "keychain":
fmt.Printf("Provider %q reads from the OS keychain — no additional setup required.\n", *providerName)
default:
fmt.Printf("Provider %q initialized — check provider documentation for setup.\n", *providerName)
}
return nil
}

Expand Down
153 changes: 147 additions & 6 deletions cmd/wfctl/secrets_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package main

import (
"context"
"fmt"
"errors"
"os"
"time"

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

// SecretState describes the accessibility of a secret in its backing store.
Expand Down Expand Up @@ -45,14 +47,153 @@ type SecretStatus struct {
IsSet bool
}

// secretsProviderAdapter wraps a secrets.Provider and implements SecretsProvider.
// It upgrades to MetadataProvider/AccessChecker when the underlying provider
// supports them.
type secretsProviderAdapter struct {
p secrets.Provider
}

func (a secretsProviderAdapter) Get(ctx context.Context, name string) (string, error) {
return a.p.Get(ctx, name)
}

func (a secretsProviderAdapter) Set(ctx context.Context, name, value string) error {
return a.p.Set(ctx, name, value)
}

func (a secretsProviderAdapter) Delete(ctx context.Context, name string) error {
return a.p.Delete(ctx, name)
}

// Check returns the SecretState for the named secret.
//
// Resolution order, most-precise first:
// 1. MetadataProvider.StatAll — when it succeeds, membership + presence is authoritative.
// 2. Get(name)-presence — when StatAll is unavailable/errored but Get works
// (env, file, vault, aws). A non-empty value is Set; an empty value or
// ErrNotFound is NotSet.
// 3. List()-membership — only when Get itself reports ErrUnsupported
// (write-only stores like github, where reading a value is impossible).
func (a secretsProviderAdapter) Check(ctx context.Context, name string) (SecretState, error) {
if mp, ok := a.p.(secrets.MetadataProvider); ok {
metas, err := mp.StatAll(ctx)
if err == nil {
for _, m := range metas {
if m.Name == name {
if m.Exists {
return SecretSet, nil
}
return SecretNotSet, nil
}
}
return SecretNotSet, nil
}
// StatAll failed (including ErrUnsupported) — fall through to Get/List.
}

// Get-presence check.
v, err := a.p.Get(ctx, name)
if err == nil {
if v != "" {
return SecretSet, nil
}
return SecretNotSet, nil
}
if errors.Is(err, secrets.ErrNotFound) {
return SecretNotSet, nil
}
if !errors.Is(err, secrets.ErrUnsupported) {
// Unexpected Get error (e.g. permission denied) — surface as fetch error.
return SecretFetchError, err
}

// Get is unsupported (write-only store) — fall back to List() membership.
names, listErr := a.p.List(ctx)
if listErr != nil {
if errors.Is(listErr, secrets.ErrUnsupported) {
return SecretFetchError, nil
}
return SecretFetchError, listErr
}
for _, n := range names {
if n == name {
return SecretSet, nil
}
}
return SecretNotSet, nil
}

// List returns SecretStatus entries from the provider.
//
// It prefers MetadataProvider.StatAll (which carries LastRotated from
// SecretMeta.UpdatedAt). When StatAll is unavailable or errors, it falls back to
// the plain List() names with presence-only statuses. A store that supports
// neither (e.g. env with no prefix) yields an empty list rather than an error,
// so callers that only need per-entry Check semantics are unaffected.
func (a secretsProviderAdapter) List(ctx context.Context) ([]SecretStatus, error) {
if mp, ok := a.p.(secrets.MetadataProvider); ok {
metas, err := mp.StatAll(ctx)
if err == nil {
statuses := make([]SecretStatus, len(metas))
for i, m := range metas {
state := SecretNotSet
if m.Exists {
state = SecretSet
}
statuses[i] = SecretStatus{
Name: m.Name,
State: state,
IsSet: m.Exists,
LastRotated: m.UpdatedAt,
}
}
return statuses, nil
}
// StatAll failed (including ErrUnsupported) — fall through to List() below.
}
// Fall back to plain List() with presence-only statuses.
names, err := a.p.List(ctx)
if err != nil {
if errors.Is(err, secrets.ErrUnsupported) {
// Store cannot enumerate — return empty rather than erroring.
return nil, nil
}
return nil, err
}
statuses := make([]SecretStatus, len(names))
for i, n := range names {
statuses[i] = SecretStatus{
Name: n,
State: SecretSet,
IsSet: true,
}
}
return statuses, nil
}

// checkAccess calls CheckAccess on the underlying provider if it implements
// AccessChecker. Returns nil when the provider does not implement the interface.
func (a secretsProviderAdapter) checkAccess(ctx context.Context) error {
if ac, ok := a.p.(secrets.AccessChecker); ok {
return ac.CheckAccess(ctx)
}
return nil
}

// newSecretsProvider constructs the provider matching the given name.
// It now supports all 5 backends (env, github, vault, aws, keychain) by
// delegating to resolveSecretsProvider, then wrapping the result in the adapter.
func newSecretsProvider(providerName string) (SecretsProvider, error) {
switch providerName {
case "env", "":
return &envProvider{}, nil
default:
return nil, fmt.Errorf("unknown secrets provider %q (supported: env)", providerName)
name := providerName
if name == "" {
name = "env"
}
p, err := resolveSecretsProvider(&SecretsConfig{Provider: name})
if err != nil {
return nil, err
}
return secretsProviderAdapter{p}, nil
}

// envProvider reads/writes secrets as OS environment variables.
Expand Down
Loading
Loading