From 9bd7958d256fc24e437b25cf51e5408ca3bf21ef Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 18 May 2026 14:50:28 -0400 Subject: [PATCH] feat(wfctl): route infra output secrets --- CHANGELOG.md | 7 +++ cmd/wfctl/dsl-reference-embedded.md | 22 ++++++- cmd/wfctl/infra.go | 13 ++-- cmd/wfctl/infra_output_secrets.go | 85 ++++++++++++++++++-------- cmd/wfctl/infra_output_secrets_test.go | 32 ++++++++++ cmd/wfctl/infra_secrets.go | 74 ++++++++++++++++++++-- cmd/wfctl/infra_secrets_test.go | 23 +++++++ config/secrets_config.go | 1 + config/secrets_multistore_test.go | 11 ++++ docs/dsl-reference.md | 22 ++++++- secrets/github_provider.go | 40 +++++++++--- secrets/github_provider_test.go | 53 ++++++++++++++++ 12 files changed, 332 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e20fa61..3002e1d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,13 @@ Configs that still reference the legacy types now fail to load with an actionabl ### Added +- **GitHub environment secret destinations for IaC output sync**: `secretStores` can now use + `provider: github` with `config.environment`, including `${WORKFLOW_ENV}`, so + `wfctl infra apply --env staging` writes to GitHub Actions environment secrets instead of + repository secrets. +- **`secrets.generate[].store` routing**: `infra_output` generators can name a store from + `secretStores`, allowing provisioning output such as database URLs to be piped directly into + the intended secret manager location after apply. - **Engine-side sensitive-output routing** (v0.27.0): `ResourceDriver` outputs flagged with `Sensitive: {key: true}` on Create/Update are routed through the configured `secrets.Provider` and replaced in state with `secret_ref://_` placeholders. Plugins remain diff --git a/cmd/wfctl/dsl-reference-embedded.md b/cmd/wfctl/dsl-reference-embedded.md index c4ae694d..2f120349 100644 --- a/cmd/wfctl/dsl-reference-embedded.md +++ b/cmd/wfctl/dsl-reference-embedded.md @@ -944,8 +944,8 @@ The optional `secretStores:` section declares named secret storage backends. Thi ### Fields - `secretStores.` (object) — a named store. Fields: - - `provider` (string, required) — backend provider: `env`, `vault`, `aws-secrets-manager`, `gcp-secret-manager` - - `config` (map) — provider-specific configuration (e.g., Vault address, AWS region) + - `provider` (string, required) — backend provider: `env`, `vault`, `aws-secrets-manager`, `gcp-secret-manager`, `github` + - `config` (map) — provider-specific configuration (e.g., Vault address, AWS region). GitHub supports `repo`, `token_env`, and optional `environment` for environment-scoped Actions secrets. ### Example @@ -957,6 +957,12 @@ secretStores: provider: aws-secrets-manager config: region: us-east-1 + github-env: + provider: github + config: + repo: GoCodeAlone/example-app + token_env: GH_MANAGEMENT_TOKEN + environment: ${WORKFLOW_ENV} ``` ### Relationship to Other Sections @@ -975,7 +981,7 @@ The optional `secrets:` section declares the application's secret management con ### Fields - `secrets.defaultStore` (string) — name of the default store from `secretStores`. When set, all secrets without an explicit `store` field use this store. -- `secrets.provider` (string) — legacy single-provider name (use `defaultStore` + `secretStores` for new configs). Supported: `env`, `vault`, `aws-secrets-manager`, `gcp-secret-manager` +- `secrets.provider` (string) — legacy single-provider name (use `defaultStore` + `secretStores` for new configs). Supported: `env`, `vault`, `aws-secrets-manager`, `gcp-secret-manager`, `github` - `secrets.config` (map) — provider-specific configuration (used with legacy `provider` field) - `secrets.rotation` (object) — default rotation policy: - `enabled` (bool) — enable automatic rotation @@ -986,6 +992,11 @@ The optional `secrets:` section declares the application's secret management con - `description` (string) — human-readable description - `store` (string) — name of a specific store from `secretStores`; overrides `defaultStore` and environment override - `rotation` (object) — per-secret rotation override (same fields as `secrets.rotation`) +- `secrets.generate` (array) — secrets generated by `wfctl infra bootstrap` or synced by `wfctl infra apply`. Each entry: + - `key` (string, required) — target secret name + - `type` (string, required) — generator type, including `random_hex`, `random_base64`, `random_alphanumeric`, `provider_credential`, or `infra_output` + - `source` (string) — for `infra_output`, `.` copied from applied IaC state + - `store` (string) — optional named store from `secretStores` for `infra_output` entries; lets provisioning output pipe directly into a specific secret manager location ### Example (multi-store) @@ -1012,6 +1023,11 @@ secrets: - name: STRIPE_SECRET_KEY description: Stripe payment API key store: payment-vault + generate: + - key: DATABASE_URL + type: infra_output + source: main-db.uri + store: github-env ``` ### Example (single provider, legacy) diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 77f1d146..4b703570 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -1518,14 +1518,13 @@ func runInfraApply(args []string) error { if err != nil { return fmt.Errorf("load current state for infra_output sync: %w", err) } - // Only reload the workflow config when env resolution is actually needed: - // it is needed only when --env is set AND at least one infra_output secret - // generator is configured (otherwise syncInfraOutputSecrets is a no-op for - // env resolution regardless). + // Only reload the workflow config when routing or env resolution is needed: + // store-scoped generators need secretStores, and --env needs module + // ResolveForEnv so bmw-database.uri can find bmw-staging-db in state. var wfCfg *config.WorkflowConfig - if envName != "" { - for _, g := range secretsCfg.Generate { - if g.Type == "infra_output" { + for _, g := range secretsCfg.Generate { + if g.Type == "infra_output" { + if envName != "" || g.Store != "" { var loadErr error wfCfg, loadErr = config.LoadFromFile(cfgFile) if loadErr != nil { diff --git a/cmd/wfctl/infra_output_secrets.go b/cmd/wfctl/infra_output_secrets.go index fc1c26b1..73b9af53 100644 --- a/cmd/wfctl/infra_output_secrets.go +++ b/cmd/wfctl/infra_output_secrets.go @@ -146,35 +146,28 @@ func syncInfraOutputSecrets(ctx context.Context, secretsCfg *SecretsConfig, prov // Lazy List() cache — same pattern as bootstrapSecrets for write-only // providers (GitHub Actions) that return ErrUnsupported on Get. - var listSet map[string]struct{} - var listErr error - var listDone bool - lookupViaList := func(key string) (bool, error) { - if !listDone { - names, err := provider.List(ctx) - listErr = err - if err == nil { - listSet = make(map[string]struct{}, len(names)) - for _, n := range names { - listSet[n] = struct{}{} - } - } - listDone = true - } - if listErr != nil && !errors.Is(listErr, secrets.ErrUnsupported) { - return false, fmt.Errorf("list secrets to check %q: %w", key, listErr) - } - _, ok := listSet[key] - return ok, nil - } + listLookups := map[secrets.Provider]*providerListLookup{} stateOutputs := buildStateOutputsMap(states) for _, gen := range gens { + genProvider, err := providerForSecretGen(wfCfg, provider, gen, envName) + if err != nil { + return err + } + lookupViaList := func(key string) (bool, error) { + lookup, ok := listLookups[genProvider] + if !ok { + lookup = &providerListLookup{provider: genProvider} + listLookups[genProvider] = lookup + } + return lookup.exists(ctx, key) + } + // Attempt to read the current value. This serves two purposes: // 1. Existence check for readable providers. // 2. Value comparison in refresh mode to avoid spurious updates. - currentVal, getErr := provider.Get(ctx, gen.Key) + currentVal, getErr := genProvider.Get(ctx, gen.Key) var exists bool var isReadable bool @@ -212,12 +205,12 @@ func syncInfraOutputSecrets(ctx context.Context, secretsCfg *SecretsConfig, prov fmt.Printf(" secret %q: unchanged\n", gen.Key) continue } - if err := provider.Set(ctx, gen.Key, newValue); err != nil { + if err := genProvider.Set(ctx, gen.Key, newValue); err != nil { return fmt.Errorf("store secret %q: %w", gen.Key, err) } fmt.Printf(" secret %q: updated from infra output\n", gen.Key) } else { - if err := provider.Set(ctx, gen.Key, newValue); err != nil { + if err := genProvider.Set(ctx, gen.Key, newValue); err != nil { return fmt.Errorf("store secret %q: %w", gen.Key, err) } fmt.Printf(" secret %q: created from infra output\n", gen.Key) @@ -225,3 +218,47 @@ func syncInfraOutputSecrets(ctx context.Context, secretsCfg *SecretsConfig, prov } return nil } + +type providerListLookup struct { + provider secrets.Provider + listSet map[string]struct{} + listErr error + listDone bool +} + +func (l *providerListLookup) exists(ctx context.Context, key string) (bool, error) { + if !l.listDone { + names, err := l.provider.List(ctx) + l.listErr = err + if err == nil { + l.listSet = make(map[string]struct{}, len(names)) + for _, n := range names { + l.listSet[n] = struct{}{} + } + } + l.listDone = true + } + if l.listErr != nil && !errors.Is(l.listErr, secrets.ErrUnsupported) { + return false, fmt.Errorf("list secrets to check %q: %w", key, l.listErr) + } + _, ok := l.listSet[key] + return ok, nil +} + +func providerForSecretGen(wfCfg *config.WorkflowConfig, fallback secrets.Provider, gen SecretGen, envName string) (secrets.Provider, error) { + if gen.Store == "" { + return fallback, nil + } + if wfCfg == nil { + return nil, fmt.Errorf("secret %q references store %q, but workflow config is unavailable", gen.Key, gen.Store) + } + store, ok := wfCfg.SecretStores[gen.Store] + if !ok || store == nil { + return nil, fmt.Errorf("secret %q references unknown store %q", gen.Key, gen.Store) + } + provider, err := resolveSecretsProviderForEnv(secretsConfigFromStore(store), envName) + if err != nil { + return nil, fmt.Errorf("resolve store %q for secret %q: %w", gen.Store, gen.Key, err) + } + return provider, nil +} diff --git a/cmd/wfctl/infra_output_secrets_test.go b/cmd/wfctl/infra_output_secrets_test.go index e255f85f..739d3937 100644 --- a/cmd/wfctl/infra_output_secrets_test.go +++ b/cmd/wfctl/infra_output_secrets_test.go @@ -3,8 +3,10 @@ package main import ( "context" "fmt" + "os" "testing" + "github.com/GoCodeAlone/workflow/config" "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/secrets" ) @@ -163,6 +165,36 @@ func TestSyncInfraOutputSecrets_WritesSecret(t *testing.T) { } } +func TestSyncInfraOutputSecrets_RoutesGeneratorToNamedStore(t *testing.T) { + envProvider := secrets.NewEnvProvider("ROUTED_") + _ = envProvider.Delete(context.Background(), "DATABASE_URL") + t.Cleanup(func() { _ = envProvider.Delete(context.Background(), "DATABASE_URL") }) + p := newSimpleProvider() + cfg := &SecretsConfig{ + Generate: []SecretGen{ + {Key: "DATABASE_URL", Type: "infra_output", Source: "bmw-database.uri", Store: "github-env"}, + }, + } + wfCfg := &config.WorkflowConfig{ + SecretStores: map[string]*config.SecretStoreConfig{ + "github-env": { + Provider: "env", + Config: map[string]any{"prefix": "ROUTED_"}, + }, + }, + } + err := syncInfraOutputSecrets(context.Background(), cfg, p, sampleStates(), wfCfg, "staging", nil, false) + if err != nil { + t.Fatalf("syncInfraOutputSecrets: %v", err) + } + if _, ok := p.data["DATABASE_URL"]; ok { + t.Fatalf("default provider received DATABASE_URL; wanted named store routing") + } + if got := os.Getenv("ROUTED_DATABASE_URL"); got != "postgres://user:pass@db.example.com:5432/app" { + t.Fatalf("ROUTED_DATABASE_URL = %q, want infra output", got) + } +} + func TestSyncInfraOutputSecrets_WritesMultiple(t *testing.T) { p := newSimpleProvider() cfg := &SecretsConfig{ diff --git a/cmd/wfctl/infra_secrets.go b/cmd/wfctl/infra_secrets.go index eb8d50ff..7cd480c6 100644 --- a/cmd/wfctl/infra_secrets.go +++ b/cmd/wfctl/infra_secrets.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "strings" "github.com/GoCodeAlone/workflow/config" "github.com/GoCodeAlone/workflow/secrets" @@ -48,15 +49,19 @@ func parseInfraConfig(cfgFile string) (*InfraConfig, error) { } // resolveSecretsProvider constructs the appropriate secrets.Provider from cfg. -// ${VAR} / $VAR references in cfg.Config are expanded via os.ExpandEnv before -// the provider is constructed, so credentials can be passed through the environment -// (e.g. VAULT_TOKEN=s.xxx in CI) rather than hard-coded in YAML. cfg.Config is -// never mutated — expansion produces a deep copy. +// ${VAR} / $VAR references in cfg.Config are expanded before the provider is +// constructed, so credentials can be passed through the environment (e.g. +// VAULT_TOKEN=s.xxx in CI) rather than hard-coded in YAML. cfg.Config is never +// mutated — expansion produces a deep copy. func resolveSecretsProvider(cfg *SecretsConfig) (secrets.Provider, error) { + return resolveSecretsProviderForEnv(cfg, "") +} + +func resolveSecretsProviderForEnv(cfg *SecretsConfig, envName string) (secrets.Provider, error) { if cfg == nil { return nil, fmt.Errorf("no secrets config provided") } - c := config.ExpandEnvInMap(cfg.Config) + c := expandSecretsConfigForEnv(cfg.Config, envName) if c == nil { c = map[string]any{} } @@ -67,7 +72,14 @@ func resolveSecretsProvider(cfg *SecretsConfig) (secrets.Provider, error) { if tokenVar == "" { tokenVar = "GITHUB_TOKEN" //nolint:gosec // G101: this is an env var name, not a credential } - return secrets.NewGitHubSecretsProvider(repo, tokenVar) + provider, err := secrets.NewGitHubSecretsProvider(repo, tokenVar) + if err != nil { + return nil, err + } + if environment, _ := c["environment"].(string); environment != "" { + provider.SetEnvironment(environment) + } + return provider, nil case "vault": addr, _ := c["address"].(string) @@ -101,6 +113,56 @@ func resolveSecretsProvider(cfg *SecretsConfig) (secrets.Provider, error) { } } +func expandSecretsConfigForEnv(m map[string]any, envName string) map[string]any { + if m == nil { + return nil + } + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = expandSecretConfigValueForEnv(v, envName) + } + return out +} + +func expandSecretConfigValueForEnv(v any, envName string) any { + switch val := v.(type) { + case string: + return os.Expand(val, func(key string) string { + if key == "WORKFLOW_ENV" { + return envName + } + return os.Getenv(key) + }) + case map[string]any: + return expandSecretsConfigForEnv(val, envName) + case []any: + out := make([]any, len(val)) + for i, item := range val { + out[i] = expandSecretConfigValueForEnv(item, envName) + } + return out + default: + return v + } +} + +func secretsConfigFromStore(store *config.SecretStoreConfig) *SecretsConfig { + if store == nil { + return nil + } + provider := strings.TrimSpace(store.Provider) + switch provider { + case "aws-secrets-manager": + provider = "aws" + case "github-actions": + provider = "github" + } + return &SecretsConfig{ + Provider: provider, + Config: store.Config, + } +} + // buildAdhocProvider constructs a secrets.Provider for ad-hoc operations without // requiring an app.yaml secrets block. Supports keychain, env, and aws. // vault and github require explicit config via the app.yaml secrets block. diff --git a/cmd/wfctl/infra_secrets_test.go b/cmd/wfctl/infra_secrets_test.go index 1d8d2772..02707641 100644 --- a/cmd/wfctl/infra_secrets_test.go +++ b/cmd/wfctl/infra_secrets_test.go @@ -133,6 +133,29 @@ func TestResolveSecretsProvider_GitHubProvider(t *testing.T) { } } +func TestResolveSecretsProvider_GitHubProviderEnvironmentScope(t *testing.T) { + t.Setenv("GH_TOKEN_TEST", "fake-token") + cfg := &SecretsConfig{ + Provider: "github", + Config: map[string]any{ + "repo": "owner/repo", + "token_env": "GH_TOKEN_TEST", + "environment": "${WORKFLOW_ENV}", + }, + } + p, err := resolveSecretsProviderForEnv(cfg, "staging") + if err != nil { + t.Fatalf("resolveSecretsProviderForEnv github: %v", err) + } + gh, ok := p.(*secrets.GitHubSecretsProvider) + if !ok { + t.Fatalf("provider type = %T, want *secrets.GitHubSecretsProvider", p) + } + if gh.Environment() != "staging" { + t.Errorf("github environment = %q, want staging", gh.Environment()) + } +} + func TestResolveSecretsProvider_UnknownProvider(t *testing.T) { cfg := &SecretsConfig{Provider: "mystery"} _, err := resolveSecretsProvider(cfg) diff --git a/config/secrets_config.go b/config/secrets_config.go index f94af299..137f557a 100644 --- a/config/secrets_config.go +++ b/config/secrets_config.go @@ -15,6 +15,7 @@ type SecretGen struct { Length int `json:"length,omitempty" yaml:"length,omitempty"` // for random generators Source string `json:"source,omitempty" yaml:"source,omitempty"` // for provider_credential Name string `json:"name,omitempty" yaml:"name,omitempty"` // optional human-readable label + Store string `json:"store,omitempty" yaml:"store,omitempty"` // optional named store for infra_output sync } // SecretsConfig defines secret management for the application. diff --git a/config/secrets_multistore_test.go b/config/secrets_multistore_test.go index 143bed8b..a45629da 100644 --- a/config/secrets_multistore_test.go +++ b/config/secrets_multistore_test.go @@ -20,6 +20,11 @@ secretStores: secrets: defaultStore: github provider: env + generate: + - key: DATABASE_URL + type: infra_output + source: main-db.uri + store: github-env entries: - name: DATABASE_URL description: PostgreSQL connection string @@ -113,6 +118,12 @@ func TestMultiStoreSecretsConfig(t *testing.T) { if len(cfg.Secrets.Entries) != 3 { t.Fatalf("Entries len: got %d, want 3", len(cfg.Secrets.Entries)) } + if len(cfg.Secrets.Generate) != 1 { + t.Fatalf("Generate len: got %d, want 1", len(cfg.Secrets.Generate)) + } + if cfg.Secrets.Generate[0].Store != "github-env" { + t.Errorf("DATABASE_URL generator store: got %q, want github-env", cfg.Secrets.Generate[0].Store) + } // Per-secret store field dbEntry := cfg.Secrets.Entries[0] diff --git a/docs/dsl-reference.md b/docs/dsl-reference.md index 5108ddee..b72a4cb8 100644 --- a/docs/dsl-reference.md +++ b/docs/dsl-reference.md @@ -944,8 +944,8 @@ The optional `secretStores:` section declares named secret storage backends. Thi ### Fields - `secretStores.` (object) — a named store. Fields: - - `provider` (string, required) — backend provider: `env`, `vault`, `aws-secrets-manager`, `gcp-secret-manager` - - `config` (map) — provider-specific configuration (e.g., Vault address, AWS region) + - `provider` (string, required) — backend provider: `env`, `vault`, `aws-secrets-manager`, `gcp-secret-manager`, `github` + - `config` (map) — provider-specific configuration (e.g., Vault address, AWS region). GitHub supports `repo`, `token_env`, and optional `environment` for environment-scoped Actions secrets. ### Example @@ -957,6 +957,12 @@ secretStores: provider: aws-secrets-manager config: region: us-east-1 + github-env: + provider: github + config: + repo: GoCodeAlone/example-app + token_env: GH_MANAGEMENT_TOKEN + environment: ${WORKFLOW_ENV} ``` ### Relationship to Other Sections @@ -975,7 +981,7 @@ The optional `secrets:` section declares the application's secret management con ### Fields - `secrets.defaultStore` (string) — name of the default store from `secretStores`. When set, all secrets without an explicit `store` field use this store. -- `secrets.provider` (string) — legacy single-provider name (use `defaultStore` + `secretStores` for new configs). Supported: `env`, `vault`, `aws-secrets-manager`, `gcp-secret-manager` +- `secrets.provider` (string) — legacy single-provider name (use `defaultStore` + `secretStores` for new configs). Supported: `env`, `vault`, `aws-secrets-manager`, `gcp-secret-manager`, `github` - `secrets.config` (map) — provider-specific configuration (used with legacy `provider` field) - `secrets.rotation` (object) — default rotation policy: - `enabled` (bool) — enable automatic rotation @@ -986,6 +992,11 @@ The optional `secrets:` section declares the application's secret management con - `description` (string) — human-readable description - `store` (string) — name of a specific store from `secretStores`; overrides `defaultStore` and environment override - `rotation` (object) — per-secret rotation override (same fields as `secrets.rotation`) +- `secrets.generate` (array) — secrets generated by `wfctl infra bootstrap` or synced by `wfctl infra apply`. Each entry: + - `key` (string, required) — target secret name + - `type` (string, required) — generator type, including `random_hex`, `random_base64`, `random_alphanumeric`, `provider_credential`, or `infra_output` + - `source` (string) — for `infra_output`, `.` copied from applied IaC state + - `store` (string) — optional named store from `secretStores` for `infra_output` entries; lets provisioning output pipe directly into a specific secret manager location ### Example (multi-store) @@ -1012,6 +1023,11 @@ secrets: - name: STRIPE_SECRET_KEY description: Stripe payment API key store: payment-vault + generate: + - key: DATABASE_URL + type: infra_output + source: main-db.uri + store: github-env ``` ### Example (single provider, legacy) diff --git a/secrets/github_provider.go b/secrets/github_provider.go index b5d8d48a..a62ceb93 100644 --- a/secrets/github_provider.go +++ b/secrets/github_provider.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "strings" @@ -23,6 +24,7 @@ const githubAPIBase = "https://api.github.com" type GitHubSecretsProvider struct { owner string repo string + env string token string client *http.Client } @@ -48,6 +50,17 @@ func NewGitHubSecretsProvider(repo string, tokenEnvVar string) (*GitHubSecretsPr func (p *GitHubSecretsProvider) Name() string { return "github" } +// SetEnvironment scopes subsequent operations to a GitHub Actions environment. +// Empty scope means repository-level secrets. +func (p *GitHubSecretsProvider) SetEnvironment(environment string) { + p.env = strings.TrimSpace(environment) +} + +// Environment returns the configured GitHub Actions environment scope. +func (p *GitHubSecretsProvider) Environment() string { + return p.env +} + // Get always returns ErrUnsupported because GitHub secrets are write-only. func (p *GitHubSecretsProvider) Get(_ context.Context, _ string) (string, error) { return "", ErrUnsupported @@ -72,8 +85,7 @@ func (p *GitHubSecretsProvider) Set(ctx context.Context, key, value string) erro "key_id": pubKeyID, } body, _ := json.Marshal(payload) - url := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/%s", githubAPIBase, p.owner, p.repo, key) - req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.secretURL(key), bytes.NewReader(body)) if err != nil { return err } @@ -94,8 +106,7 @@ func (p *GitHubSecretsProvider) Delete(ctx context.Context, key string) error { if key == "" { return ErrInvalidKey } - url := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/%s", githubAPIBase, p.owner, p.repo, key) - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, p.secretURL(key), nil) if err != nil { return err } @@ -116,8 +127,7 @@ func (p *GitHubSecretsProvider) Delete(ctx context.Context, key string) error { // List returns the names of all GitHub Actions secrets for the repo. func (p *GitHubSecretsProvider) List(ctx context.Context) ([]string, error) { - url := fmt.Sprintf("%s/repos/%s/%s/actions/secrets", githubAPIBase, p.owner, p.repo) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.secretsURL(), nil) if err != nil { return nil, err } @@ -165,14 +175,28 @@ func (p *GitHubSecretsProvider) setHeaders(req *http.Request) { req.Header.Set("X-GitHub-Api-Version", "2022-11-28") } +func (p *GitHubSecretsProvider) secretsURL() string { + if p.env != "" { + return fmt.Sprintf("%s/repos/%s/%s/environments/%s/secrets", githubAPIBase, p.owner, p.repo, url.PathEscape(p.env)) + } + return fmt.Sprintf("%s/repos/%s/%s/actions/secrets", githubAPIBase, p.owner, p.repo) +} + +func (p *GitHubSecretsProvider) secretURL(key string) string { + return p.secretsURL() + "/" + url.PathEscape(key) +} + +func (p *GitHubSecretsProvider) publicKeyURL() string { + return p.secretsURL() + "/public-key" +} + type repoPublicKeyResponse struct { KeyID string `json:"key_id"` Key string `json:"key"` } func (p *GitHubSecretsProvider) repoPublicKey(ctx context.Context) (keyID, keyBase64 string, err error) { - url := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/public-key", githubAPIBase, p.owner, p.repo) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.publicKeyURL(), nil) if err != nil { return "", "", err } diff --git a/secrets/github_provider_test.go b/secrets/github_provider_test.go index 3821dda9..09a7bcc3 100644 --- a/secrets/github_provider_test.go +++ b/secrets/github_provider_test.go @@ -80,6 +80,59 @@ func TestGitHubProvider_List_ParsesResponse(t *testing.T) { } } +func TestGitHubProvider_EnvironmentScopeUsesEnvironmentEndpoints(t *testing.T) { + recipientPub, _, err := box.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.Method+" "+r.URL.Path) + switch r.URL.Path { + case "/repos/owner/repo/environments/staging/secrets/public-key": + json.NewEncoder(w).Encode(repoPublicKeyResponse{ + KeyID: "env-key", + Key: base64.StdEncoding.EncodeToString(recipientPub[:]), + }) + case "/repos/owner/repo/environments/staging/secrets/DATABASE_URL": + if r.Method != http.MethodPut { + http.Error(w, "bad method", http.StatusMethodNotAllowed) + return + } + w.WriteHeader(http.StatusNoContent) + case "/repos/owner/repo/environments/staging/secrets": + json.NewEncoder(w).Encode(map[string]any{ + "secrets": []map[string]any{{"name": "DATABASE_URL"}}, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + p := newTestGitHubProvider(t, srv) + p.SetEnvironment("staging") + if p.Environment() != "staging" { + t.Fatalf("Environment() = %q, want staging", p.Environment()) + } + if err := p.Set(context.Background(), "DATABASE_URL", "postgres://example"); err != nil { + t.Fatalf("Set: %v", err) + } + if _, err := p.List(context.Background()); err != nil { + t.Fatalf("List: %v", err) + } + + want := []string{ + "GET /repos/owner/repo/environments/staging/secrets/public-key", + "PUT /repos/owner/repo/environments/staging/secrets/DATABASE_URL", + "GET /repos/owner/repo/environments/staging/secrets", + } + if strings.Join(paths, "\n") != strings.Join(want, "\n") { + t.Fatalf("paths:\n%s\nwant:\n%s", strings.Join(paths, "\n"), strings.Join(want, "\n")) + } +} + func TestGitHubProvider_Set_SendsEncryptedPayload(t *testing.T) { // Generate a NaCl key pair to act as the repo's key pair. recipientPub, recipientPriv, err := box.GenerateKey(rand.Reader)