Repository navigation
feat(deploy): deploy secrets --rotate rotates a generated key and every stored value derived from it - #1144
Merged
Conversation
…very stored value derived from it A plan kept every stored value, so a leaked generated credential could not be rotated through codefly, and a connection URL an operator stored for its consumer (reported `require`, no producer template) had nothing to keep it in step with its password. --rotate <module>/<service>/<configuration>:<KEY> (or <configuration>:<KEY>, or the CODEFLY__ key) mints a new value for a key service-secrets.generate declares, writes it to every remote key holding it, and in the same apply rewrites every stored URL whose userinfo password is the old value. Any other stored value embedding the old one, a supplied/configured/signing key, a holder or derived value outside --module, and a configuration spanning stores are refused by name. Writes add versions and report key, properties and version; a rotation failing part-way writes the previous documents back as new versions. Values are never printed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ons before writing, restores a landed failed write Security review of #1144: - the derived-URL search compared the RAW value with the old password, so a base64 password percent-encoded in userinfo was silently skipped; every stored property is now parsed and its DECODED password compared, the userinfo spliced (no re-serialisation), and any raw or percent-encoded embedding refused; - each write compares the key's newest version with the one the plan read and refuses a key that moved; Describe reports it; - a failing write that landed anyway is restored when the store holds this rotation's document, on a fresh bounded context, and the result message says what the store holds; - an identifier-format key is refused; - docs: the store is not the server's credential (ClickHouse re-applies on start, Postgres-style images do not), undo by re-adding previous versions, and copies outside the planned store are not seen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
On the obin-blue cell, the tracing evidence ClickHouse password reached Cloud Logging: an old migrate CLI printed its DSN. That was fixed upstream in service-clickhouse v0.0.25. The password still has to be rotated, and codefly had no way to do it. The plan kept every stored value (
pkg/deploysecrets/plan.go: "a value is never rotated here"). The consumer's…__CLICKHOUSE__CONNECTIONURL has no producer template, so the plan only reported it asrequire. That left it typed by hand, with nothing to keep it in step with the password.Design
codefly deploy secrets --rotate <module>/<service>/<configuration>:<KEY>(for blue:tracing/evidence/clickhouse:CLICKHOUSE_PASSWORD) (also<configuration>:<KEY>for a workspace configuration, or theCODEFLY__…_SECRET_CONFIGURATION__…key itself; the flag can be repeated). The selector is spelled like theservice-secrets.generateentry that declares the key.service-secrets.generatedeclares can be rotated. Each of these is refused with its reason:require) keyed25519-pkcs8signing key (its public half is registered elsewhere)identifierkey (a new user name in the store renames nobody on the server)--moduleDescribe,--sort-by=~createTime) is compared with the one the plan read, and a key that moved is refused by name. Secret Manager has no conditionalversions add, so this narrows the race; it does not close it.gcloud secrets versions add --format=value(name)), so nothing is destroyed.Store.Writenow returns the version. The result printsWrote <key> as version <n> (<properties>), with no values.--dry-runshowsrotaterows with property names and sources only.--metadata-onlyshows the rotated key's rows. Derived rows need the store to be read, and a metadata-only plan can never be applied.deploy secretsre-run writes nothing. Running--rotateagain rotates again; that is documented.What is derived, and how
I reused the template seam: a producer that declares its connection as a
ConfigurationValue.templategets it assembled by External Secrets in the cluster. Nothing is stored in that case, so a rotated primitive is picked up on the next sync and there is nothing to rewrite.For a connection stored by hand (blue's case), the derivation is read from the store, not guessed from a key name:
net/urlencodes userinfo. Every other byte is kept as stored; the URL is not re-serialised.QueryEscape,PathEscape, userinfo escaping), is refused, namingremote-key#property (key). Rotating would otherwise leave it stale.Only rotation does this scan, and only on stored values in memory. A refusal never quotes a value.
Mid-apply failure: GCP Secret Manager has no transaction across secrets. A rotation that fails part-way writes the previous document of every key it already wrote back as a new version, in reverse order; a key it created gets
{}.WithoutCancelplus a 2-minute timeout).The store is not the server's credential. Whether a restart makes the server accept the new value depends on the agent:
users.dfromCLICKHOUSE_USER/CLICKHOUSE_PASSWORDon every start. I checked this in service-clickhousemain.goatfe09e97.No agent declaration says which behaviour an agent has; I checked core's protos. So this is documented, not enforced.
Operator follow-up (documented; the CLI does none of it): force-sync the affected ExternalSecrets, make the server accept the new credential, then restart its consumers. Undo is to re-add each key's previous version as current (a
gcloud … access | … add --data-file=-pipe, printing nothing).Classification
Fix, at the place that owns the behaviour.
pkg/deploysecretsis the only writer of the environment's secret store. Rotation is a new resolution in the same plan; it reuses the generator index, alias groups, scope test and theStoreseam rather than adding a second mechanism. I deliberately did not teach the CLI any producer-specific connection format (ClickHouse, Postgres). The one generic rule is "a URL whose password equals the old value", and everything else is refused.Tests (fake store,
pkg/deploysecrets/rotate_test.go)TestRotateReplacesAGeneratedKeyAndTheConnectionDerivedFromIt(blue's shape):TestRotateRefusesAKeyCodeflyDoesNotProduce: supplied, configured, signing and identifier keys are each refused with their reason, and nothing is written.TestRotateRefusesADerivedValueItCannotKeepConsistent: a value that embeds the password other than as a URL password is refused by name, and the refusal carries no value. A derived value outside--moduleis refused.TestRotateFindsAPercentEncodedPassword: a base64 password with/and+, stored with userinfo escaping and with query escaping, is found and rewritten consistently, and found again by a second rotation. This test fails if the old raw pre-filter is put back.TestRotateRestoresAFailedWriteThatLandedandTestRotateRefusesAKeyThatMovedSinceThePlan.TestRotateMetadataOnlyShowsRowsWithoutReading: showsrotaterows with no reads, no values, and no way to apply.TestRotateFailingPartWayLeavesNothingHalfRotated: the consumer write fails, the evidence document is restored as version 2, the pair is consistent, and a retry converges.TestRotationKeyResolvesTheGeneratorSpelling,TestCheckRotationStoresRefusesAConfigurationSpanningStores, andcmd/deployTestRotationKeysResolveAgainstTheRender.TestGoogleSecretManagerWritesThroughStdinnow also checks the version is parsed fromversions add.go test ./pkg/deploysecrets/... ./cmd/...passes andmake lintreports 0 issues.Docs
docs/commands.md: arotaterow in the action table, a new "Rotating a generated key" section with the operator follow-up, and the "never rotated" rule now notes the--rotateexception.docs/deployment-secret-safety.mddocs/development-loop.mddocs/cli-reference.md, regenerated--helpNot verified
versions add --format=value(name)printing the version resource name, andversions list --sort-by=~createTime --limit=1returning the newest enabled version.CLICKHOUSE_PASSWORDis declared in blue'sservice-secrets.generate. If it is not, the rotation is refused, and the declaration has to be added first.🤖 Generated with Claude Code