Skip to content

feat(iac): provider_credential rotation — mint-new-then-revoke-old via ProviderCredentialRevoker#573

Merged
intel352 merged 4 commits into
mainfrom
feat/provider-credential-rotation
May 7, 2026
Merged

feat(iac): provider_credential rotation — mint-new-then-revoke-old via ProviderCredentialRevoker#573
intel352 merged 4 commits into
mainfrom
feat/provider-credential-rotation

Conversation

@intel352

@intel352 intel352 commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Removes the --force-rotate hard-rejection for provider_credential type. Previously wfctl blocked this with "must be rotated via the upstream provider" — now enabled with mint-new-then-revoke-old ordering (ADR 0012).
  • Adds optional ProviderCredentialRevoker interface to interfaces/iac_provider.go following the existing optional-interface pattern (Enumerator, DriftConfigDetector). Provider plugins that can revoke credentials implement it; caller type-asserts and falls through to a warning log if not implemented.
  • bootstrapSecrets accepts an optional variadic ProviderCredentialRevoker. For provider_credential + --force-rotate: reads OLD access_key_id before deletion, mints + stores new sub-keys, then revokes old credential via the interface. Revoke failure is non-fatal — new key is never rolled back (see ADR 0012).
  • ADR 0012 documents ordering rationale, revoke-failure contract, and alternatives.

Changes

File Change
interfaces/iac_provider.go Add ProviderCredentialRevoker optional interface
cmd/wfctl/infra_bootstrap.go Enable force-rotate for provider_credential; load IaC provider for revocation; implement mint-new-then-revoke-old in bootstrapSecrets
cmd/wfctl/infra_bootstrap_force_rotate_test.go Update ProviderCredentialRefusedProviderCredentialAllowed; add MintsAndRevokes + RevokeFailNonFatal tests
decisions/0012-provider-credential-rotation.md ADR 0012

ADR 0012 summary

Ordering: Read OLD key → Delete sub-keys → Mint new → Store new → Revoke old.
Revoke failure: non-fatal, log warning, operator revokes manually. New key is retained.
Alternatives rejected: revoke-then-mint (service disruption window), operator-manual-only (no audit trail).

Test plan

  • go test ./cmd/wfctl/ -run ForceRotate — all 8 tests pass (3 new, updated 1)
  • go test ./... — full suite green
  • go build ./cmd/wfctl/ — clean

Follow-up

Phase 3: workflow-plugin-digitalocean implements RevokeProviderCredential for digitalocean.spaces source (DELETE /v2/spaces/keys/{access_key_id}).

🤖 Generated with Claude Code

…a ProviderCredentialRevoker

- Remove --force-rotate rejection for provider_credential type (was blocked with "must be rotated
  via upstream provider"); now allowed with mint-new-then-revoke-old ordering (ADR 0012).
- Add ProviderCredentialRevoker optional interface to interfaces/iac_provider.go: plugins that
  issue credentials implement RevokeProviderCredential; callers type-assert and fall through to
  a warning log if not implemented.
- bootstrapSecrets accepts optional variadic ProviderCredentialRevoker; for provider_credential
  force-rotate: reads OLD access_key_id before deletion, mints+stores new sub-keys, then revokes
  old credential. Revoke failure is non-fatal — new key is never rolled back (ADR 0012).
- ADR 0012: documents ordering guarantee, revoke-failure contract, and alternatives considered.
- Tests: ProviderCredentialAllowed, MintsAndRevokes, RevokeFailNonFatal; updated
  ProviderCredentialRefused → ProviderCredentialAllowed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 7, 2026 13:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Enables wfctl infra bootstrap --force-rotate for provider_credential secrets by introducing an optional IaC provider interface to revoke the old upstream credential after minting and storing the new one (per ADR 0012).

Changes:

  • Added optional interfaces.ProviderCredentialRevoker for provider plugins to support upstream revocation.
  • Updated wfctl infra bootstrap to allow force-rotation of provider_credential with mint-new-then-revoke-old ordering (revocation best-effort / non-fatal).
  • Added/updated force-rotate tests and introduced ADR 0012 documenting the contract and rationale.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

File Description
interfaces/iac_provider.go Adds optional ProviderCredentialRevoker interface for upstream credential revocation.
cmd/wfctl/infra_bootstrap.go Allows force-rotate for provider_credential, loads IaC provider for revocation, and implements mint-new-then-revoke-old flow in bootstrapSecrets.
cmd/wfctl/infra_bootstrap_force_rotate_test.go Updates tests to allow provider_credential rotation and adds revocation + revoke-failure tests.
decisions/0012-provider-credential-rotation.md ADR documenting ordering guarantees and revoke-failure behavior.

Comment thread cmd/wfctl/infra_bootstrap.go Outdated
// The new credential will still be minted; the old one will
// not be explicitly revoked (operator must do so manually).
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")
Comment thread cmd/wfctl/infra_bootstrap.go Outdated
fmt.Fprintf(os.Stderr, "warn: old provider credential will NOT be revoked automatically — revoke manually\n")
} else {
if iacProvCloser != nil {
defer iacProvCloser.Close() //nolint:errcheck
// Read old access_key sub-key before deletion.
accessKeyName := gen.Key + "_access_key"
if oldVal, getErr := provider.Get(ctx, accessKeyName); getErr == nil {
oldCredentialID = oldVal
Comment on lines +600 to +601
} 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 187 to 189
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 secrets are derived from apply-time state and cannot be regenerated by bootstrap", name, gen.Type)
}
Comment on lines +150 to +204
// TestInfraBootstrap_ForceRotate_ProviderCredential_MintsAndRevokes verifies
// the full mint-new-then-revoke-old flow for provider_credential force-rotate.
// The revoker captures the old credentialID; the new sub-keys are stored.
func TestInfraBootstrap_ForceRotate_ProviderCredential_MintsAndRevokes(t *testing.T) {
// Stub generator returns a new DO Spaces credential JSON.
withStubGenerator(t, func(_ context.Context, _ string, _ map[string]any) (string, error) {
return `{"access_key":"NEW_AK","secret_key":"NEW_SK"}`, nil
})

// Store has old sub-keys pre-populated.
p := newTrackingProvider(map[string]string{
"SPACES_access_key": "OLD_AK",
"SPACES_secret_key": "OLD_SK",
})
cfg := &SecretsConfig{
Generate: []SecretGen{
{Key: "SPACES", Type: "provider_credential", Source: "digitalocean.spaces"},
},
}
forceRotate := map[string]bool{"SPACES": true}

// Capture revoke calls.
var revokedSource, revokedID string
revoker := &stubProviderRevoker{
fn: func(_ context.Context, source, credentialID string) error {
revokedSource = source
revokedID = credentialID
return nil
},
}

result, err := bootstrapSecrets(context.Background(), p, cfg, forceRotate, revoker)
if err != nil {
t.Fatalf("bootstrapSecrets: %v", err)
}

// New sub-keys should be stored.
if p.inner["SPACES_access_key"] != "NEW_AK" {
t.Errorf("SPACES_access_key = %q, want NEW_AK", p.inner["SPACES_access_key"])
}
if p.inner["SPACES_secret_key"] != "NEW_SK" {
t.Errorf("SPACES_secret_key = %q, want NEW_SK", p.inner["SPACES_secret_key"])
}
if !strings.Contains(err.Error(), "must be rotated via the upstream provider") {
t.Errorf("error message should mention upstream provider rotation; got: %v", err)
// Result map should contain new values.
if result["SPACES_access_key"] != "NEW_AK" {
t.Errorf("generated[SPACES_access_key] = %q, want NEW_AK", result["SPACES_access_key"])
}
// Revocation should have been called with old access_key_id.
if revokedSource != "digitalocean.spaces" {
t.Errorf("revokedSource = %q, want digitalocean.spaces", revokedSource)
}
if revokedID != "OLD_AK" {
t.Errorf("revokedID = %q, want OLD_AK", revokedID)
}
}
…itic violation

Moves the IaC provider load + ProviderCredentialRevoker type-assertion out of
the for loop (where defer was flagged by gocritic deferInLoop) into a standalone
helper resolveCredentialRevoker that returns the revoker + closer. Defer is now
at runInfraBootstrap function scope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown

⏱ Benchmark Results

No significant performance regressions detected.

benchstat comparison (baseline → PR)
## benchstat: baseline → PR
baseline-bench.txt:260: parsing iteration count: invalid syntax
baseline-bench.txt:313305: parsing iteration count: invalid syntax
baseline-bench.txt:631711: parsing iteration count: invalid syntax
baseline-bench.txt:925004: parsing iteration count: invalid syntax
baseline-bench.txt:1186197: parsing iteration count: invalid syntax
baseline-bench.txt:1516591: parsing iteration count: invalid syntax
benchmark-results.txt:260: parsing iteration count: invalid syntax
benchmark-results.txt:313110: parsing iteration count: invalid syntax
benchmark-results.txt:635146: parsing iteration count: invalid syntax
benchmark-results.txt:1114700: parsing iteration count: invalid syntax
benchmark-results.txt:1382712: parsing iteration count: invalid syntax
benchmark-results.txt:1654444: parsing iteration count: invalid syntax
goos: linux
goarch: amd64
pkg: github.com/GoCodeAlone/workflow/dynamic
cpu: AMD EPYC 7763 64-Core Processor                
                            │ baseline-bench.txt │        benchmark-results.txt         │
                            │       sec/op       │    sec/op      vs base               │
InterpreterCreation-4              3.387m ± 168%   4.355m ± 144%        ~ (p=0.240 n=6)
ComponentLoad-4                    3.573m ±   1%   4.279m ±   4%  +19.74% (p=0.002 n=6)
ComponentExecute-4                 1.959µ ±   1%   2.157µ ±   3%  +10.11% (p=0.002 n=6)
PoolContention/workers-1-4         1.098µ ±   1%   1.218µ ±   3%  +10.93% (p=0.002 n=6)
PoolContention/workers-2-4         1.089µ ±   3%   1.214µ ±   6%  +11.53% (p=0.002 n=6)
PoolContention/workers-4-4         1.100µ ±   1%   1.217µ ±   6%  +10.59% (p=0.002 n=6)
PoolContention/workers-8-4         1.100µ ±   1%   1.202µ ±   2%   +9.23% (p=0.002 n=6)
PoolContention/workers-16-4        1.101µ ±   2%   1.223µ ±   5%  +11.03% (p=0.002 n=6)
ComponentLifecycle-4               3.600m ±   2%   4.390m ±   6%  +21.93% (p=0.002 n=6)
SourceValidation-4                 2.270µ ±   1%   2.689µ ±   2%  +18.46% (p=0.002 n=6)
RegistryConcurrent-4               818.8n ±   4%   858.3n ±   5%   +4.82% (p=0.015 n=6)
LoaderLoadFromString-4             3.608m ±   0%   4.571m ±   3%  +26.69% (p=0.002 n=6)
geomean                            17.65µ          20.31µ         +15.08%

                            │ baseline-bench.txt │        benchmark-results.txt         │
                            │        B/op        │     B/op      vs base                │
InterpreterCreation-4               2.027Mi ± 0%   2.027Mi ± 0%       ~ (p=0.699 n=6)
ComponentLoad-4                     2.180Mi ± 0%   2.180Mi ± 0%       ~ (p=0.589 n=6)
ComponentExecute-4                  1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-1-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-2-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-4-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-8-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-16-4         1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
ComponentLifecycle-4                2.183Mi ± 0%   2.183Mi ± 0%       ~ (p=0.076 n=6)
SourceValidation-4                  1.984Ki ± 0%   1.984Ki ± 0%       ~ (p=1.000 n=6) ¹
RegistryConcurrent-4                1.133Ki ± 0%   1.133Ki ± 0%       ~ (p=1.000 n=6) ¹
LoaderLoadFromString-4              2.182Mi ± 0%   2.182Mi ± 0%  +0.00% (p=0.024 n=6)
geomean                             15.25Ki        15.25Ki       +0.00%
¹ all samples are equal

                            │ baseline-bench.txt │        benchmark-results.txt        │
                            │     allocs/op      │  allocs/op   vs base                │
InterpreterCreation-4                15.68k ± 0%   15.68k ± 0%       ~ (p=1.000 n=6)
ComponentLoad-4                      18.02k ± 0%   18.02k ± 0%       ~ (p=1.000 n=6)
ComponentExecute-4                    25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-1-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-2-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-4-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-8-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-16-4           25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
ComponentLifecycle-4                 18.07k ± 0%   18.07k ± 0%       ~ (p=1.000 n=6) ¹
SourceValidation-4                    32.00 ± 0%    32.00 ± 0%       ~ (p=1.000 n=6) ¹
RegistryConcurrent-4                  2.000 ± 0%    2.000 ± 0%       ~ (p=1.000 n=6) ¹
LoaderLoadFromString-4               18.06k ± 0%   18.06k ± 0%       ~ (p=1.000 n=6) ¹
geomean                               183.3         183.3       +0.00%
¹ all samples are equal

pkg: github.com/GoCodeAlone/workflow/middleware
                                  │ baseline-bench.txt │       benchmark-results.txt       │
                                  │       sec/op       │   sec/op     vs base              │
CircuitBreakerDetection-4                  284.4n ± 0%   310.6n ± 5%  +9.19% (p=0.002 n=6)
CircuitBreakerExecution_Success-4          21.60n ± 0%   21.60n ± 0%       ~ (p=0.712 n=6)
CircuitBreakerExecution_Failure-4          65.94n ± 1%   66.80n ± 0%  +1.30% (p=0.002 n=6)
geomean                                    74.00n        76.52n       +3.41%

                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │        B/op        │    B/op     vs base                │
CircuitBreakerDetection-4                 144.0 ± 0%     144.0 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Success-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Failure-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                              ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │     allocs/op      │ allocs/op   vs base                │
CircuitBreakerDetection-4                 1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Success-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Failure-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                              ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/module
                                 │ baseline-bench.txt │         benchmark-results.txt         │
                                 │       sec/op       │     sec/op      vs base               │
JQTransform_Simple-4                     899.1n ± 28%   1078.0n ±  41%        ~ (p=0.132 n=6)
JQTransform_ObjectConstruction-4         1.491µ ±  1%    1.751µ ± 133%  +17.44% (p=0.002 n=6)
JQTransform_ArraySelect-4                3.342µ ±  0%    4.340µ ±   5%  +29.85% (p=0.002 n=6)
JQTransform_Complex-4                    38.26µ ±  0%    43.63µ ±   2%  +14.03% (p=0.002 n=6)
JQTransform_Throughput-4                 1.811µ ±  1%    2.117µ ±   3%  +16.90% (p=0.002 n=6)
SSEPublishDelivery-4                     70.92n ±  4%    72.77n ±   0%   +2.61% (p=0.002 n=6)
geomean                                  1.674µ          1.950µ         +16.50%

                                 │ baseline-bench.txt │        benchmark-results.txt         │
                                 │        B/op        │     B/op      vs base                │
JQTransform_Simple-4                   1.273Ki ± 0%     1.273Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ObjectConstruction-4       1.773Ki ± 0%     1.773Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ArraySelect-4              2.625Ki ± 0%     2.625Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Complex-4                  16.22Ki ± 0%     16.22Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Throughput-4               1.984Ki ± 0%     1.984Ki ± 0%       ~ (p=1.000 n=6) ¹
SSEPublishDelivery-4                     0.000 ± 0%       0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                             ²                 +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                 │ baseline-bench.txt │       benchmark-results.txt        │
                                 │     allocs/op      │ allocs/op   vs base                │
JQTransform_Simple-4                     10.00 ± 0%     10.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ObjectConstruction-4         15.00 ± 0%     15.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ArraySelect-4                30.00 ± 0%     30.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Complex-4                    324.0 ± 0%     324.0 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Throughput-4                 17.00 ± 0%     17.00 ± 0%       ~ (p=1.000 n=6) ¹
SSEPublishDelivery-4                     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                             ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/schema
                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │       sec/op       │    sec/op     vs base              │
SchemaValidation_Simple-4                    1.096µ ± 4%   1.123µ ± 24%  +2.51% (p=0.048 n=6)
SchemaValidation_AllFields-4                 1.664µ ± 3%   1.679µ ±  2%       ~ (p=0.310 n=6)
SchemaValidation_FormatValidation-4          1.579µ ± 1%   1.604µ ±  1%  +1.62% (p=0.030 n=6)
SchemaValidation_ManySchemas-4               1.793µ ± 5%   1.879µ ±  2%  +4.83% (p=0.015 n=6)
geomean                                      1.507µ        1.544µ        +2.44%

                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │        B/op        │    B/op     vs base                │
SchemaValidation_Simple-4                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_AllFields-4                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_FormatValidation-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_ManySchemas-4              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                                ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │     allocs/op      │ allocs/op   vs base                │
SchemaValidation_Simple-4                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_AllFields-4                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_FormatValidation-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_ManySchemas-4              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                                ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/store
                                   │ baseline-bench.txt │        benchmark-results.txt        │
                                   │       sec/op       │    sec/op     vs base               │
EventStoreAppend_InMemory-4                1.281µ ± 18%   1.224µ ± 28%        ~ (p=0.589 n=6)
EventStoreAppend_SQLite-4                  1.380m ±  3%   1.543m ±  5%  +11.85% (p=0.002 n=6)
GetTimeline_InMemory/events-10-4           13.63µ ±  5%   15.11µ ±  5%  +10.81% (p=0.002 n=6)
GetTimeline_InMemory/events-50-4           75.43µ ± 19%   81.83µ ±  2%   +8.49% (p=0.002 n=6)
GetTimeline_InMemory/events-100-4          121.7µ ±  1%   165.8µ ± 18%  +36.15% (p=0.002 n=6)
GetTimeline_InMemory/events-500-4          626.4µ ±  1%   707.8µ ±  4%  +13.00% (p=0.002 n=6)
GetTimeline_InMemory/events-1000-4         1.286m ±  2%   1.436m ±  9%  +11.67% (p=0.002 n=6)
GetTimeline_SQLite/events-10-4             108.5µ ±  1%   114.6µ ±  1%   +5.63% (p=0.002 n=6)
GetTimeline_SQLite/events-50-4             250.2µ ±  0%   269.8µ ±  5%   +7.85% (p=0.002 n=6)
GetTimeline_SQLite/events-100-4            423.7µ ±  2%   466.6µ ±  2%  +10.14% (p=0.002 n=6)
GetTimeline_SQLite/events-500-4            1.802m ±  0%   2.006m ±  7%  +11.32% (p=0.002 n=6)
GetTimeline_SQLite/events-1000-4           3.510m ±  0%   4.048m ±  3%  +15.32% (p=0.002 n=6)
geomean                                    220.1µ         244.7µ        +11.15%

                                   │ baseline-bench.txt │         benchmark-results.txt         │
                                   │        B/op        │     B/op       vs base                │
EventStoreAppend_InMemory-4                 840.0 ± 10%     786.5 ± 10%       ~ (p=0.310 n=6)
EventStoreAppend_SQLite-4                 1.986Ki ±  2%   1.988Ki ±  1%       ~ (p=0.738 n=6)
GetTimeline_InMemory/events-10-4          7.953Ki ±  0%   7.953Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-50-4          46.62Ki ±  0%   46.62Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-100-4         94.48Ki ±  0%   94.48Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-500-4         472.8Ki ±  0%   472.8Ki ±  0%       ~ (p=1.000 n=6)
GetTimeline_InMemory/events-1000-4        944.3Ki ±  0%   944.3Ki ±  0%       ~ (p=0.455 n=6)
GetTimeline_SQLite/events-10-4            16.74Ki ±  0%   16.74Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-50-4            87.14Ki ±  0%   87.14Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-100-4           175.4Ki ±  0%   175.4Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-500-4           846.1Ki ±  0%   846.1Ki ±  0%       ~ (p=0.545 n=6)
GetTimeline_SQLite/events-1000-4          1.639Mi ±  0%   1.639Mi ±  0%  -0.00% (p=0.041 n=6)
geomean                                   67.69Ki         67.33Ki        -0.54%
¹ all samples are equal

                                   │ baseline-bench.txt │        benchmark-results.txt        │
                                   │     allocs/op      │  allocs/op   vs base                │
EventStoreAppend_InMemory-4                  7.000 ± 0%    7.000 ± 0%       ~ (p=1.000 n=6) ¹
EventStoreAppend_SQLite-4                    53.00 ± 0%    53.00 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-10-4             125.0 ± 0%    125.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-50-4             653.0 ± 0%    653.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-100-4           1.306k ± 0%   1.306k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-500-4           6.514k ± 0%   6.514k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-1000-4          13.02k ± 0%   13.02k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-10-4               382.0 ± 0%    382.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-50-4              1.852k ± 0%   1.852k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-100-4             3.681k ± 0%   3.681k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-500-4             18.54k ± 0%   18.54k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-1000-4            37.29k ± 0%   37.29k ± 0%       ~ (p=1.000 n=6) ¹
geomean                                     1.162k        1.162k       +0.00%
¹ all samples are equal

Benchmarks run with go test -bench=. -benchmem -count=6.
Regressions ≥ 20% are flagged. Results compared via benchstat.

…otation

- buildForceRotateSet error message: list supported rotatable types instead of
  claiming the type is infra_output-specific
- resolveCredentialRevoker: explicitly check iacProv == nil and emit clear warning
  (loadIaCProviderFromConfig returns nil when no iac.provider module in config)
- runInfraBootstrap: defer iacCloser.Close() with error logging (consistent with
  bootstrapStateBackend's provider shutdown logging)
- bootstrapSecrets: always capture oldCredentialID for provider_credential
  force-rotate regardless of credRevoker nil-ness — enables the "no revoker
  available" warning to include the credential ID for manual revocation
- bootstrapSecrets: handle ErrUnsupported from Get with explicit warning (write-only
  stores like GitHub Actions can't expose the old credential ID; operator warned)
- Test: TestInfraBootstrap_ForceRotate_ProviderCredential_WriteOnlyStore — verifies
  that on ErrUnsupported the revoker is NOT called (old ID unknown) and new
  credential is still stored

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 7, 2026 14:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

Comment thread cmd/wfctl/infra_bootstrap.go Outdated
Comment on lines +291 to +292
// provider_credential rotation is requested or the provider doesn't implement
// ProviderCredentialRevoker (a warning is logged in that case).
} 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)
Comment thread cmd/wfctl/infra_bootstrap.go Outdated
Comment on lines +530 to +533
// --force-rotate path: for provider_credential, read the OLD access_key_id
// 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
Comment on lines +111 to +115
// 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 thread interfaces/iac_provider.go Outdated
//
// source is the provider_credential source string (e.g. "digitalocean.spaces").
// credentialID is the provider-specific identifier of the OLD credential
// (e.g. the access_key_id for DO Spaces).
Comment on lines +25 to +29
Enable `--force-rotate` for `provider_credential` secrets with the following ordering guarantee:

1. **Read** the OLD `access_key_id` from the secrets store before any deletion.
2. **Delete** all existing sub-keys from the secrets store.
3. **Mint** new credentials via `secrets.GenerateSecret` (calls the provider API).
Comment on lines +73 to +78
- Requires explicit `--force-rotate <name>` flag — never triggered automatically.
- Validates against `secrets.generate[]` first (fast-fail on typos).
- `infra_output` types are still rejected (they are derived from apply state, not generated).
- The OLD `access_key_id` is captured from the secrets store before deletion. If the store
doesn't expose Get (write-only provider like GitHub Actions), revocation is skipped with a
warning.
…ported Get error handling

- resolveCredentialRevoker docstring: clarify that (nil, iacCloser) is returned when
  provider doesn't implement ProviderCredentialRevoker (not (nil, nil) as previously stated)
- infra_bootstrap.go: log explicit warning for non-ErrUnsupported/ErrNotFound errors from
  provider.Get when reading old access_key before rotation (operators previously got no
  indication that revocation would be skipped due to unexpected Get failures)
- Terminology: update all references from "access_key_id" to "access_key" to match the
  actual sub-key name (<name>_access_key) produced by the provider_credential generator —
  affects infra_bootstrap.go comment, interfaces/iac_provider.go docstring, and ADR 0012

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@intel352 intel352 merged commit eee3525 into main May 7, 2026
23 checks passed
@intel352 intel352 deleted the feat/provider-credential-rotation branch May 7, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants