Skip to content

sec(#947): chart-manage the mysql-client root password (rotate off the baked literal) - #822

Merged
saqlainsyed007 merged 9 commits into
developfrom
sec/947-rotate-mysql-root
Aug 25, 2026
Merged

sec(#947): chart-manage the mysql-client root password (rotate off the baked literal)#822
saqlainsyed007 merged 9 commits into
developfrom
sec/947-rotate-mysql-root

Conversation

@saqlainsyed007

@saqlainsyed007 saqlainsyed007 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

What

backend#947 / backend#1528 Phase 0. Enable rotating the mysql-client root password off the image's baked literal. A new rotateMysqlRoot gate (default off, ByEnv-resolved exactly like serviceDbAccounts / bootstrapDbReparent): when on, the chart generates a random root password into the Secret (3-tier: pin → existing Secret → randAlphaNum, mirroring credmgr) and injects it as MYSQL_ROOT_PASSWORD on the mysql-client deployment.

This is the credential the #785 re-parent points the mint at, and — separately — a literal now disclosed in public git history, so rotating it is both a Phase-0 prerequisite and a standing-exposure fix.

Scope — read this, it's the important part

This rotates a FRESH datadir only. The mysql entrypoint reads MYSQL_ROOT_PASSWORD at first init and ignores it thereafter, so an existing edge keeps its current root password until the one-time rollout DDL runs:

ALTER USER 'root'@'%'        IDENTIFIED BY '<the generated Secret value>';
ALTER USER 'root'@'localhost' IDENTIFIED BY '<the generated Secret value>';

(read the value from the Secret, run via kubectl exec as root@localhost). The chart deliberately does not run that ALTER — it would have to authenticate as root with the current literal (re-introducing it) or hit a chicken/egg once rotated. Consistent with the rest of #1528, the live-fleet DDL stays an ops step; the chart only provides the Secret + the fresh-init override.

Turning the gate on rolls the mysql pod (an env change → brief restart), so flip it in a window. And any tooling/runbook that dials root with the old literal must move to the Secret first, or it breaks at rotation — that inventory is the substance of #947.

Changes

  • values.yamlrotateMysqlRoot (override) + rotateMysqlRootByEnv{dev,stg,prod: false} + mysqlRootPassword pin, with the fresh-vs-existing distinction and the ALTER step documented inline.
  • _helpers.tpltracebloc.rotateMysqlRoot, resolving like serviceDbAccounts.
  • secrets.yamlMYSQL_ROOT_PASSWORD 3-tier generate-and-persist, gated. No alphanumeric constraint (connection param, never DDL); placeholder-guarded.
  • mysql-deployment.yamlMYSQL_ROOT_PASSWORD env from the Secret, gated. Probes use mysqladmin ping (needs no auth), so they're undisturbed.
  • values.schema.json — three keys. Chart.yaml 1.9.68 → 1.9.69 (+ appVersion lockstep).
  • Tests — +7 helm-unittest cases.

Design notes

Verification

  • helm unittest full suite: 569 passed (34 suites).
  • helm template (ci/eks base): default render = 0 MYSQL_ROOT_PASSWORD refs (byte-identical); gate-on injects a 48-char random password (decoded, confirmed not the literal) into the Secret and the deployment env; rotateMysqlRootByEnv.dev=true resolves on.
  • helm lint clean; version-bump guard satisfied; no Edg9@Tr@ce in the diff.

Not in this PR (the rest of Phase 0 / #947)

  • The per-fleet ALTER USER 'root' rollout (ops, reads this Secret).
  • The root-consumer inventory (runbooks, DR/backup tooling) — must move off the literal before rotating.
  • The Dockerfile.mysql_client ENV source scrub (gated on the client#454 image freeze; the runtime override here neutralizes the baked default for new installs meanwhile).
  • The client-runtime Service Bus key (separate #947 scope).

Refs: backend#947 · backend#1528 (Phase 0) · client#785 (the re-parent this feeds).

🤖 Generated with Claude Code


Note

Medium Risk
When enabled, mis-rotation or flipping the gate without the runbook can lock out root consumers or briefly roll mysql; default-off limits blast radius but fleet rollout is credential-sensitive.

Overview
backend#947 / backend#1528 Phase 0 adds an opt-in path to stop relying on the mysql-client image’s baked-in root password. A new rotateMysqlRoot gate (default off, resolved like serviceDbAccounts via rotateMysqlRootByEnv) controls whether the chart owns root credentials.

When the gate is on, the release Secret gets MYSQL_ROOT_PASSWORD (pin → existing Secret → randAlphaNum), and the mysql-client Deployment reads it as MYSQL_ROOT_PASSWORD. That only applies on a fresh datadir; live edges still need a one-time ops ALTER USER 'root' using the Secret (documented in docs/migration-tools/rotate-mysql-root.md). Default installs stay byte-identical (no env block, no Secret key).

Also documents MYSQL_ROOT_PW for migrate-tenant.sh when root is chart-managed, extends values.yaml / values.schema.json, bumps the chart to 1.9.71, and adds helm-unittest coverage for the gate and password validation.

Reviewed by Cursor Bugbot for commit 3693c57. Bugbot is set up for automated code reviews on this repo. Configure here.

…e baked literal)

backend#947 / backend#1528 Phase 0: enable rotating the mysql-client root
password off the image's baked literal. New rotateMysqlRoot gate (default off,
ByEnv-resolved like serviceDbAccounts / bootstrapDbReparent): when on, the chart
generates a random root password into the Secret (3-tier: pin -> existing Secret
-> randAlphaNum, mirroring credmgr) and injects it as MYSQL_ROOT_PASSWORD on the
mysql-client deployment.

Scope — this rotates a FRESH datadir only. The mysql entrypoint reads
MYSQL_ROOT_PASSWORD at init and ignores it thereafter, so an existing edge keeps
its current root password until the one-time rollout DDL runs
(ALTER USER 'root'@'%'/'@localhost' IDENTIFIED BY '<the generated Secret value>',
via kubectl exec as root@localhost). The chart deliberately does not run that
ALTER: it would need to authenticate as root with the current literal
(re-introducing it) or hit a chicken/egg once rotated — so, like the rest of the
epic, the live-fleet DDL stays an ops step. Wiring this Secret into #785's
bootstrapDbPassword is a deliberate follow-up (one self-contained change per PR;
don't churn just-merged code).

- values.yaml: rotateMysqlRoot + rotateMysqlRootByEnv{dev,stg,prod: false} +
  mysqlRootPassword pin. Heavy comment on the fresh-vs-existing distinction and
  the ALTER rollout step.
- _helpers.tpl: tracebloc.rotateMysqlRoot (mirrors serviceDbAccounts resolution).
- secrets.yaml: MYSQL_ROOT_PASSWORD 3-tier generate-and-persist, gated.
- mysql-deployment.yaml: MYSQL_ROOT_PASSWORD env from the Secret, gated; probes
  use `mysqladmin ping` (no auth) so they are undisturbed.
- values.schema.json: three keys. Chart.yaml 1.9.68 -> 1.9.69 (+ appVersion).
- tests: +7 helm-unittest cases (secrets off/on/pin/byEnv/placeholder; deployment
  env off/on).

Additive and default-off: helm unittest 569 passed, lint clean, default render
byte-identical (0 MYSQL_ROOT_PASSWORD refs), gate-on injects a 48-char random
password (not the literal). No literal in the diff.

Refs: backend#947 (rotate + remove hardcoded creds), backend#1528 (Phase 0), client#785 (re-parent this rotation feeds).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@saqlainsyed007 saqlainsyed007 self-assigned this Aug 24, 2026
saadqbal
saadqbal previously approved these changes Aug 24, 2026

@saadqbal saadqbal 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.

Approving — and the scope statement is the best part of this. Saying plainly that the chart rotates a fresh datadir only, that it deliberately does not run the ALTER USER, and why (authenticating as root with the current literal would re-introduce it, or hit a chicken/egg once rotated) is worth more than a fix that quietly implied the exposure was closed. Same for flagging that the gate flip rolls the pod, and that any tooling dialling root with the old literal breaks at rotation.

Checked the things that would hurt on a public repo or a live edge:

Generate-once is real. $existingSecret at secrets.yaml:17 is a genuine lookup, so tier 2 preserves the value across upgrades — no randAlphaNum-on-every-upgrade trap locking MySQL out of its own datadir.

The Secret key and the secretKeyRef are gated by the same helper, so they appear and disappear together. A default install can't end up with a deployment referencing a key the Secret didn't emit.

Nothing shipped. values.yaml carries mysqlRootPassword: "", not a default, and the only literals in the diff are helm-unittest fixtures in tests/secrets_test.yaml. The placeholder guard on the pin is a nice touch — <ROOT_PASSWORD> failing loudly beats it reaching MySQL.

--reuse-values lands safe. rotateMysqlRoot is unset-vs-false aware via kindIs "invalid", and both it and rotateMysqlRootByEnv are default dict-guarded — so an upgrade that carries stored values without the new keys resolves to gate-off rather than crashing templating or half-rotating.

To be precise about what this does and doesn't close: it's the enabling half. The literal is still in public history and every existing edge still has it live until the rollout DDL runs, which is exactly how the body frames it.

…otates it

migrate-tenant.sh already requires MYSQL_ROOT_PW (no hardcode); this just tells
the operator that on a fleet with rotateMysqlRoot on, that value is the
mysql-client Secret MYSQL_ROOT_PASSWORD, not the image-baked legacy password.
Comment-only, lands with the rotation feature it describes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The operational half of rotateMysqlRoot: read the generated Secret value, run
the one-time ALTER USER root (both hosts) via kubectl exec, verify old-pw-dead /
new-pw-works / heartbeat-full-count, roll back by re-ALTERing. Documents the
consumer inventory that must move to the Secret first and the prod exec-access
gap. Lands with the feature it operationalizes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread docs/migration-tools/rotate-mysql-root.md Outdated
The runbook interpolated NEWPW into the sh -c string, so the ALTER USER text
showed in the node ps output — the exact leak the runbook warned against.
Rewritten so every secret travels only over the exec stdin stream: auth via a
mode-600 --defaults-extra-file written+deleted in the pod, the new password via
mysql stdin. Nothing in any process argv.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@saqlainsyed007

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread docs/migration-tools/rotate-mysql-root.md
The remote sh -s body had no set -e and ended in rm -f, so a failed ALTER USER
still exited 0 — kubectl exec reported success while root stayed on the old
password and the Secret diverged. Now: set -e + an EXIT trap that always wipes
the password file; the rotate and new-pw-verify blocks exit non-zero on failure,
and the old-pw-verify inverts explicitly (OK only when the old pw is rejected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@saqlainsyed007

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread docs/migration-tools/rotate-mysql-root.md
Comment thread docs/migration-tools/rotate-mysql-root.md
…(Bugbot)

Two related High findings on the runbook snippets:
- every mysql call used the default unix socket, but this image puts the socket
  at /var/lib/mysql/mysql.sock (the reason the probes are pinned to -h 127.0.0.1)
  so the ALTER/verifies could not reach mysqld. Added host=127.0.0.1 to every
  --defaults-extra-file.
- the old-password verify treated ANY non-zero mysql exit as \"rejected\", so a
  connection error (mysqld restarting, 2003) read as success. It now requires
  ERROR 1045 (access denied) to pass and flags anything else INCONCLUSIVE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@saqlainsyed007

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread docs/migration-tools/rotate-mysql-root.md
…gbot)

An empty NEWPW (wrong NS/REL, or rotateMysqlRoot not actually enabled so the
Secret lacks MYSQL_ROOT_PASSWORD) would ALTER root to an EMPTY password while the
runbook printed success. Guard both CURPW and NEWPW non-empty before building any
ALTER, using ${var:?} so it fails fast but is safe to paste interactively — same
fail-fast intent as migrate-tenant.sh MYSQL_ROOT_PW guard. Preserves the earlier
fixes (argv-safe stdin, set -e/trap, 1045-specific verify, TCP host).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@saqlainsyed007

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread client/values.yaml
The chart documented mysqlRootPassword as \"never interpolated into DDL\", but the
rotation runbook this PR adds embeds it in ALTER USER ... IDENTIFIED BY '...'. A pin
with a quote or metacharacter (which the chart allowed) would break the one-time
ALTER; generated randAlphaNum happened to be safe. Reconciled by enforcing
[A-Za-z0-9]+ on the pin, exactly like the credmgr/tb_meta/tb_ingest pins (which
are constrained for the same CREATE USER DDL reason). bootstrapDbPassword stays
unconstrained — it is only ever a connection parameter, never DDL. Updated
values.yaml + schema wording and added a reject test.
@saqlainsyed007

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 3693c57. Configure here.

@saadqbal saadqbal 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.

Re-approving. My earlier approval was dismissed by the pushes since; I've read all five.

The alphanumeric constraint is the one I looked at hardest, because narrowing a password charset is normally a step backwards. Here it isn't: the value lands inside ALTER USER … IDENTIFIED BY '…' in the rotation runbook, so a quote or shell metacharacter breaks the one-time rotation — and it breaks it mid-way, on the operation you least want half-applied. Rejecting it at template time beats discovering it in a DDL statement.

What makes it right rather than merely defensible is the schema note drawing the line explicitly: constrained here, not constrained for bootstrapDbPassword, because that one is only ever a connection parameter and this one also lands in DDL. Correcting the old "NOT constrained to alphanumeric" sentence in the same change matters too — that claim was about to become false, and it's precisely the sentence a future operator would have relied on. randAlphaNum already satisfying the constraint means the generated path is unaffected, so this only ever fires on an operator pin.

The three runbook commits are the same instinct applied to the procedure: failing loudly on a failed ALTER, asserting 1045 on the old-password check rather than assuming the negative, and refusing an empty root password. A rotation runbook that continues past a failed step is worse than one that stops.

20 checks green, no open threads.

@saqlainsyed007
saqlainsyed007 merged commit 09bb86c into develop Aug 25, 2026
23 checks passed
@saqlainsyed007
saqlainsyed007 deleted the sec/947-rotate-mysql-root branch August 25, 2026 08:52
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.

3 participants