docs/lightdash-secret-rotation.md
Rama main · réplica de lectura del repositorio original.
# Rotating `LIGHTDASH_SECRET`
`LIGHTDASH_SECRET` protects everything a Lightdash instance derives from its
root secret: AES-256-GCM ciphertext at rest (warehouse credentials, dbt
connections, SSH keys, embed secrets, OAuth tokens, AI provider keys, and
more), session cookies, app-preview and persistent-download JWTs,
headless-browser HMAC authentication, and the deterministic bcrypt hashes
used to look up personal access tokens and service accounts.
This document describes how to change the active secret without downtime
using the fallback keyring, and every condition that must hold before the old
secret can be removed.
## The fallback keyring
`LIGHTDASH_SECRET_FALLBACKS` holds up to three previous secrets as a JSON
array of strings:
```env
LIGHTDASH_SECRET='the new active secret'
LIGHTDASH_SECRET_FALLBACKS='["the previous secret"]'
```
Behavior:
- **Writes always use the active secret.** New ciphertext, session cookies,
JWTs, HMACs, and token hashes are produced only from `LIGHTDASH_SECRET`.
- **Reads try the active secret first, then each fallback in order.**
Persisted ciphertext, session cookies, Lightdash-issued JWTs,
headless-browser HMACs, and PAT/service-account lookups all accept values
produced under any configured secret.
- Values are parsed as exact JSON strings: no trimming, no delimiter
splitting. Secrets containing commas or leading/trailing whitespace
round-trip unchanged.
- An empty array is valid. Empty-string entries, duplicates (including a
fallback equal to the active secret), and more than three fallbacks are
configuration errors that prevent startup.
- If Slack is configured (`SLACK_CLIENT_ID`) and fallbacks are set,
`SLACK_STATE_SECRET` must be set explicitly. Slack OAuth state is
deliberately excluded from rotation: pin it to the pre-rotation
`LIGHTDASH_SECRET` so in-flight OAuth installs keep working, and rotate it
separately if desired.
Every runtime service consumes the keyring: the API, the scheduler worker,
NATS workers, and any environment that runs migrations, seeds, or maintenance
commands. All of them must be configured identically in each phase below.
## The `rotate-lightdash-secret` command
```bash
# Source checkout (development)
pnpm -F backend rotate-lightdash-secret \
[--execute] \
[--batch-size 500] \
[--table <registry-table>]
# Production container image (no src/ or tsx in the image — run the
# compiled entry with the same env as the app, including
# LIGHTDASH_SECRET, LIGHTDASH_SECRET_FALLBACKS, and the database config)
node packages/backend/dist/scripts/rotate-lightdash-secret/index.js \
[--execute] [--batch-size 500] [--table <registry-table>]
```
Without `--execute` the command is a report-only dry run. With `--execute` it
re-encrypts fallback-encrypted values with the active secret:
- Every registered ciphertext field is scanned in bounded primary-key
batches. Rows already encrypted with the active secret are untouched.
Updates are compare-and-swap (they match both the row identity and the
original ciphertext); a concurrent modification is recorded as a skip, not
an error.
- Queued `createProjectWithCompile` jobs (which carry encrypted project
credentials in the job queue) are re-encrypted only while unlocked, guarded
against concurrent workers.
- PAT and service-account hashes are classified as active, per-fallback,
legacy sha256, or unknown. The command never rewrites token hashes: the
plaintext token is not stored and cannot be re-hashed offline. Hashes
produced under a fallback keep verifying only while that fallback stays
configured; the affected credentials must be reissued or revoked before
the fallback is removed.
- Signed download links are not scanned or reported: a link stops working
once the secret that signed it is no longer in the configured secrets.
Links are time-bounded (default expiry 3 days via
`PERSISTENT_DOWNLOAD_URL_EXPIRATION_SECONDS`, with per-channel
`_EMAIL`/`_SLACK`/`_MSTEAMS` overrides that may be configured longer),
so the time-based removal gate below covers them.
- Unreadable values (decryptable by no configured secret) are reported with
their primary keys, never modified or deleted, and cause a non-zero exit
after the full report completes.
The command prints an explicit blocker list. **A successful exit does not
mean the old secret can be removed** — only an empty blocker list plus the
time-based gates below mean that.
## Normal rotation runbook
### Phase 0 — preparation
1. Deploy a keyring-capable release to every service while keeping the
current secret active and configuring no fallbacks.
2. If Slack is configured, set `SLACK_STATE_SECRET` explicitly to the current
`LIGHTDASH_SECRET` and complete that rollout before configuring any
fallback.
3. Configure the **proposed new secret as the first fallback** of the current
active secret, and roll every service:
```env
LIGHTDASH_SECRET='old secret'
LIGHTDASH_SECRET_FALLBACKS='["new secret"]'
```
This phase changes no writes; it proves every replica consumes keyring
configuration and can read material produced under either secret.
### Phase 1 — active-key rollout
4. Atomically swap the ordering so the new secret is active and the old
secret is the fallback:
```env
LIGHTDASH_SECRET='new secret'
LIGHTDASH_SECRET_FALLBACKS='["old secret"]'
```
5. Roll every API replica, scheduler worker, NATS worker, and
migration/maintenance environment. During the mixed-replica window both
orderings read each other's output.
6. Record the timestamp at which the **final old-active replica stopped**.
Do not run the migration command in execute mode before this point.
### Phase 2 — convergence
7. Run the maintenance command: dry run, then execute, then dry run again.
Repeat after any run that reports concurrent skips or when new
fallback-encrypted work appears (for example a queued project creation
from just before the rollout).
### Phase 3 — old-secret removal gates
Remove the old fallback only when **all** of the following hold:
- The final dry run reports **zero fallback-encrypted ciphertext**, **zero
unreadable ciphertext**, **zero concurrent skips**, and **zero eligible
fallback-encrypted queued jobs**.
- **Zero PAT or service-account hashes** are classified under the old
fallback. Token hashes never converge on their own: every credential the
dry run reports under the old fallback must be reissued (rotated by its
owner) or revoked, and that decision recorded. A token whose hash still
depends on the old secret stops working the moment the old secret is
removed.
- At least the configured session-cookie lifetime (`COOKIES_MAX_AGE_HOURS`,
default 24h) has elapsed since the rollout-completion timestamp, so no
valid session cookie signed with the old secret remains.
- At least one hour has elapsed since the rollout-completion timestamp, so
every app-preview JWT signed with the old secret has expired.
- Signed download links minted before the rollout have either expired or
their breakage is explicitly accepted: a link stops working the moment
the secret that signed it leaves the configured secrets. The waiting
period is the **largest configured expiration** across
`PERSISTENT_DOWNLOAD_URL_EXPIRATION_SECONDS` and its
`_EMAIL`/`_SLACK`/`_MSTEAMS` overrides (default 3 days) — check the
instance configuration rather than assuming the default.
Then remove the fallback, roll every replica, and run a final steady-state
dry run to confirm nothing regressed.
### Rollback
While both secrets remain configured, rollback is a pure ordering swap: make
the old secret active and the new secret the fallback. Both orderings read
each other's output, so this is safe at any point before the fallback is
removed. If new-active ciphertext was already written or migrated, keep the
new secret configured as the fallback and rerun the maintenance command to
converge back toward the restored old active secret before removing the new
one.
## Compromised-secret rotation
When the old secret must be retired as fast as possible:
1. Complete the active-key rollout immediately (phases 0–1 compressed;
phase 0 step 3 can be skipped if the fleet is known to run a
keyring-capable release).
2. Run the maintenance command in execute mode as soon as the last
old-active replica stops — do not wait for natural convergence.
3. Rotate or revoke every PAT and service account still classified under the
compromised fallback.
4. Decide explicitly which time-based artifacts to preserve. Waiting the full
session lifetime keeps users logged in but extends the window in which a
stolen old secret can forge session cookies; removing the fallback early
logs those sessions out. The same tradeoff applies to signed download
links.
5. Remove the compromised fallback and roll every replica.
The command remains report-only toward credentials in this scenario:
revocation is an operator action through the normal credential-management
surfaces.
## Notes for operators
- The PAT session cache keys entries by the active secret, so cached entries
from before the rollout simply miss after it; no cache flush is needed.
- A credential whose hash still derives from the old secret pays an extra
hashing round on every authentication for as long as the fallback is
configured. Reissue high-traffic tokens early in the overlap window
rather than leaving them on the blocker list.
- Legacy sha256 token hashes (from very old Lightdash versions) do not
depend on `LIGHTDASH_SECRET`. They are reported separately and do not
block removal; they keep verifying regardless of rotation.
- Embed JWTs are signed with per-project embed secrets and are unaffected,
except that the stored embed secret itself is registered ciphertext and is
migrated by the command like any other field.
- Dev tooling that mirrors the backend encryption (`sdk-test-app`,
`scripts/dev-github-reconcile.cjs`, `scripts/k8s-dev/reconcile-github.cjs`)
reads a single secret and should be pointed at the active secret after a
rotation.