You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(secrets): prevent duplicate DO Spaces key creation (#732)
* fix(secrets): prevent duplicate DO Spaces key creation
DO does not enforce name uniqueness on Spaces keys. The
provider_credential bootstrap path POSTed blindly every run; if the
bootstrap layer failed to persist the access_key+secret_key pair to
the configured store (or skipped existsInProvider for any reason),
the next run created another key with the same name. Repeated runs
accreted orphaned keys until the account hit DO's 200-key quota.
Each orphan is unrecoverable — DO returns secret_key once at
creation time, and List returns access_key only.
Changes:
- generateDOSpacesKey() now does a list-then-create. Pre-create call
to https://api.digitalocean.com/v2/spaces/keys checks for a
matching name; if found, returns an error naming the existing
access_key with explicit recovery guidance (delete via console OR
--force-rotate). The POST is never attempted in the conflict case.
- New lookupExistingSpacesKey() helper paginates through Spaces keys
(10 × 100 cap) matching by exact name. Used by the pre-check AND
by the 403 error branch (account-quota vs name-conflict
disambiguation hint).
- Improved error wrapping: every "DO spaces key create" error now
includes name=%q so the operator can correlate logs with DO
console entries.
- stderr trace line on every create attempt: name + bucket + grant
permission, so the failure mode is visible without re-running
with extra flags.
Tests:
- TestGenerateDOSpacesKey_RejectsDuplicateNameBeforeCreate verifies
the pre-check short-circuits and POST is never attempted when a
duplicate exists. (postCalls == 0 asserted.)
- TestGenerateDOSpacesKey_403QuotaWithoutNameConflict covers the
fallback hint when no name match but DO still returns 403.
- TestLookupExistingSpacesKey_PaginatedHit + _Miss cover the pager
helper.
- Existing TestGenerateSecret_ProviderCredential_DOSpaces and
_NoBucket / _WithBucket tests pass without changes — their GET
handlers return 404 (no existing key) so the create path proceeds.
- TestGenerateDOSpacesKey_IncludesCreatedAt updated to stub both
the new GET and the existing POST.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(secrets): require explicit name for DO Spaces key + fullaccess warning
Two follow-ups to the duplicate-creation fix:
1. Refuse the default name fallback. The prior default
\`workflow-spaces-key\` was project-shared; multiple workflow-
managed projects in one DO account would all try to create or
adopt the same key. Force the caller to set a project-unique
slug (e.g. \`multisite-deploy-key\`, \`wfcompute-deploy-key\`).
2. Emit a stderr WARN when permission=fullaccess is granted
(i.e. no \`bucket:\` configured). Bootstrap necessarily uses
fullaccess because the IaC state bucket does not exist yet,
but once it does the operator should rotate to a bucket-
scoped key via \`wfctl secrets rotate --target SPACES --bucket
<state-bucket>\` to limit blast radius. The warning includes
the exact rotate command.
Test updates:
- WithBucket / IncludesCreatedAt / NoBucket tests now pass an
explicit name (was relying on the default).
- New TestGenerateDOSpacesKey_RequiresName covers the refuse-
default-name path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(wfctl): transactional rollback + list-orphans for provider_credential
Round 2 of the DO Spaces orphan fix (workflow#732 follow-up).
Round 1 (already in this PR) added a list-before-create pre-check so
the generator refuses to POST a duplicate name. That prevents NEW
orphans whose root cause is a save-failure-after-mint loop.
This round closes the other half: the orphan-creation event itself.
cmd/wfctl/infra_bootstrap.go — bootstrapSecrets provider_credential
path now:
1. Extracts access_key + created_at BEFORE the Set loop (was inside
the loop, so a first-iteration Set failure left the rollback path
blind to the just-minted credential).
2. On ANY Set failure inside the loop, invokes credRevoker.
RevokeProviderCredential with the just-minted access_key. If no
revoker is registered, emits a loud ORPHANED-CREDENTIAL warning
to stderr with the wfctl list-orphans recovery command pre-filled.
3. Rollback is best-effort: failure does not mask the original Set
error.
cmd/wfctl/secrets_orphans.go — new \`wfctl secrets list-orphans\`
subcommand:
wfctl secrets list-orphans --source digitalocean.spaces \
--name workflow-spaces-key # dry-run
wfctl secrets list-orphans --source digitalocean.spaces \
--name workflow-spaces-key --delete # delete all matches
Lists every upstream credential whose name field equals --name
(DO Spaces does not enforce name uniqueness, so a single name can
map to many access_keys after the orphan loop). Bounded pager
(100 × 100 = 10 000 keys). Per-orphan delete continues on failure
to surface every recoverable orphan.
Currently supports digitalocean.spaces; extending to other sources
adds one switch arm.
Tests:
- TestBootstrapSecrets_ProviderCredential_RollbackOnSetFailure —
Set(SPACES_secret_key) returns error; assert revoker invoked with
AK_ORPHAN; assert original error surfaces.
- TestBootstrapSecrets_ProviderCredential_RollbackOnFirstSetFailure —
Set(SPACES_access_key) (the very first call) returns error;
asserts the access_key extraction reorder is correct (revoker
must receive AK_FIRST even though we never even reached the
access_key iteration).
These two tests reproduce the pre-fix orphan-creation bug
(generator succeeds + Set fails → DO key remains, no rollback
attempted). Without the rollback wiring they fail with
"expected 1 rollback-revoke call; got 0".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(lint): annotate ghAPICmd #nosec G204
Pre-existing G204 false positive on a fixed-binary + url-escaped
endpoint subprocess. Documented inline; no behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(secrets): follow DO pagination + raise per_page to 200
list-orphans was using local page-counter increments. Earlier runs
truncated at 100 because per_page was 100 + the locally-bounded loop
returned after page 1. DO's Spaces Keys API allows per_page up to
200 and returns an absolute next-page URL in links.pages.next; we
now follow that URL rather than incrementing locally.
Also emits a stderr scan summary so debugging is observable from
the CI log without re-running with extra flags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
// Capture access_key + created_at independently of storage so
717
-
// RotationResult always reports them on a force-rotate, even
718
-
// though access_key IS in the allowed set and created_at is
719
-
// NOT (per review #2 C5: stderr emission alone would skip
720
-
// access_key).
721
-
varnewAccessKey, newCreatedAtstring
722
-
forsubKey, subVal:=rangesubKeyMap {
723
-
ifsubKey=="access_key" {
724
-
newAccessKey=subVal
716
+
// Capture access_key + created_at BEFORE any Set call so the
717
+
// transactional rollback path has the upstream credential ID
718
+
// available even when the very first Set fails. Bug fix:
719
+
// previously these were extracted DURING iteration, so a
720
+
// Set("foo_access_key") failure left newAccessKey unset and
721
+
// the orphaned DO key invisible to the rollback path.
722
+
newAccessKey:=subKeyMap["access_key"]
723
+
newCreatedAt:=subKeyMap["created_at"]
724
+
725
+
// Transactional rollback: if ANY Set() inside the loop fails,
726
+
// revoke the just-created upstream credential before
727
+
// returning. The DO Spaces Keys API does NOT enforce name
728
+
// uniqueness, so a partially-applied generation without
729
+
// rollback leaves an orphaned key whose secret_key is
730
+
// permanently irrecoverable (see workflow#732 / SPEC V?).
731
+
//
732
+
// Rollback is best-effort — failure is logged but does not
733
+
// mask the original Set error. Operator can still find the
734
+
// orphan via the wfctl secrets list-orphans helper.
735
+
revokeOrphan:=func(reasonerror) {
736
+
ifnewAccessKey=="" {
737
+
return
738
+
}
739
+
ifcredRevoker==nil {
740
+
fmt.Fprintf(os.Stderr, "warn: provider_credential %q minted access_key=%s but no revoker available; the upstream credential is now ORPHANED and unrecoverable. Run `wfctl secrets list-orphans --source %s --name %s` to clean up. Original error: %v\n",
0 commit comments