Re-encrypts every stored integration connection under a new encryption secret.
Needed any time the secret backing PhoenixKit.Integrations.Encryption
changes:
- First adoption of a dedicated key — every connection is currently
encrypted under the legacy
secret_key_base-derived key (status/0reports:legacy_secret_key_base); this migrates them to a dedicated:integrations_encryption_key. - Rotating an existing dedicated key — e.g. after a suspected compromise.
Without this, changing the active key makes every previously-encrypted
enc:v1: value undecryptable — see PhoenixKit.Integrations.Encryption's
moduledoc on key rotation. mix phoenix_kit.integrations.rotate_key wraps
this module for interactive use.
Refuses to run at all unless Encryption.status/0 is :dedicated or
:legacy_secret_key_base (an allowlist of the statuses that mean "a real
key is active" — NOT a blocklist of the disabled ones, so a status this
module doesn't recognize refuses by default instead of silently being
treated as safe to rotate). With encryption off, "decrypt under the
current key" is a no-op (nothing is actually encrypted), so a rotation
would either silently leave already-plaintext values alone while
encrypting only some fields, or report success while having changed
nothing meaningful. Turn encryption on first.
How it decides what "the current key" is
rotate/2 decrypts every row using whatever
PhoenixKit.Integrations.Encryption currently resolves as active (the
dedicated key if one is set, else the legacy fallback) — it does not take
an "old secret" argument. This is deliberate: the running app is already
using that key for every read, so it is unambiguous. Re-encryption happens
under new_secret, passed explicitly, since that secret is NOT yet the
active configured key at the moment rotation runs (setting it first would
make the CURRENT rows unreadable before rotation even starts).
Atomicity and concurrent writers — what the row lock does and does not buy
A REAL (non-dry-run) rotation fetches every row with a row-level lock
(SELECT ... FOR UPDATE) inside the SAME transaction that performs the
write — not a plain read followed by a separate write transaction. mix phoenix_kit.integrations.rotate_key --dry-run does NOT take this lock
(see below) — it is a pure read, safe to run against live traffic.
This lock defends exactly ONE direction of the race against a concurrent
writer (an OAuth token auto-refresh calling
PhoenixKit.Integrations.save_setup/4, a "Test Connection" click, ...):
if that writer is ALREADY mid-transaction against a row when rotation
tries to lock it, rotation's SELECT ... FOR UPDATE blocks until the
writer commits or rolls back — so rotation always re-encrypts the LATEST
committed value, never a torn or stale one.
It does NOT defend the other direction. If a writer already read a
row (has old field values in memory) BEFORE rotation acquired the lock,
that writer's own UPDATE — issued later, and possibly left queued
behind rotation's lock in the meantime — still applies AFTER rotation
commits, carrying content built from what it read and re-encrypted under
whichever key was active when it computed that content: the OLD key,
since the app's config never changes mid-rotation. That write silently
overwrites rotation's freshly re-encrypted row with a value the NEW key
cannot read. rotate/2 already reported success by the time this
happens — its transaction committed before the writer's queued UPDATE
landed — and the affected field's later decrypt failure at restart is
indistinguishable, from the log line alone, from unrelated corruption.
Closing this properly needs either optimistic concurrency (a version
column compared-and-swapped on every write — PhoenixKit.Settings.Setting
does not carry one today, and adding one is a schema change touching
every settings row in every install, not scoped to integrations) or
making every OTHER write path in PhoenixKit.Integrations also
read-lock inside a transaction (save_setup/4, disconnect/3,
record_validation/3, rename_connection/4, refresh_access_token/1 —
a change to hot, already-shipped write paths well beyond what this
rotation feature should carry). Neither is attempted here.
Practical consequence: nothing should write to phoenix_kit_settings
rows where module = "integrations" from before you start a real
rotation until the app has been restarted onto the new key — not just
for the few seconds rotate/2 itself is running. A write landing
anywhere in that whole span, including well after rotate/2 has already
returned {:ok, _}, can still land on the old key: silently reverting a
row rotation just finished (this section), or getting silently stranded
once the restart happens (see "The gap between rotating and restarting"
below) — the same failure mode from either end of the same window. Pause
anything that could write (most concretely: an OAuth token-refresh
worker) before starting, and do not resume it until the restart is done
— resuming as soon as rotate/2 returns, before restarting, walks
straight into this window. Treat the ENTIRE span — start to restart — as
one maintenance window, not just the rotation command itself. The row
lock narrows the race, it does not close it.
A genuine write failure (a DB error while saving a re-encrypted row, not a
decrypt failure) is NOT converted into an {:error, _} return — it raises,
rolling back the transaction via the normal exception path. rotate/2's
own two-shape error type only covers the failure modes it can meaningfully
name; an infrastructure failure should crash loudly, not get flattened
into a tuple a caller might pattern-match past.
If any row fails to decrypt under the current key, the whole transaction
rolls back and NOTHING is written — a rotation that silently skipped a
broken row would be exactly the kind of failure this module exists to
prevent (see PhoenixKit.Integrations.Encryption.decrypt_fields/1's own
handling of a decrypt failure: it logs and drops the field rather than
returning the raw ciphertext as if it were the value).
The gap between rotating and restarting
Rotation only changes what's IN THE DATABASE; the running app's configured key does not change until the operator edits config and restarts. In the window between those two events, on top of the concurrent-write race above:
- Reads of a rotated connection do not raise or crash — a field
that can't be decrypted under the still-active old key is logged
(
Logger.error) and silently dropped from whatever asked for it (seePhoenixKit.Integrations.Encryption's decrypt-failure handling), same as any other decrypt failure. Not an exception a caller has to handle — the field is simply absent. - A write that reads-and-merges the existing row and does NOT touch
the stuck field is no longer destructive. Most writes in
PhoenixKit.Integrationswork this way, including fully automatic ones (a validation-status update after every token-refresh attempt, with no operator involved) — and used to permanently erase a field it couldn't decrypt, because the map it saved back never had that field in it. That's fixed: such a write restores the field's untouched ciphertext from storage before saving, as long as the write itself doesn't supply a fresh value for that exact key. The field stays exactly as this rotation left it — encrypted under the NEW secret — and decrypts fine again the moment the app restarts. - A write that DOES supply a fresh value for that exact field is still at risk. Whatever gets encrypted at write time uses whichever key is ACTIVE, which is still the OLD one until the app restarts — so that one field lands back under the OLD key, in a row this rotation already moved to the NEW secret, and will fail to decrypt once the app restarts onto the new key, same as an un-rotated row would. This is the write half of the same race "Atomicity and concurrent writers" describes above, not a separate one.
There is deliberately no dual-key read fallback to paper over this gap (it
would silently mask exactly the class of failure this module exists to
prevent). Restart promptly after rotating, and keep writers paused until
you do — this gap is the SAME maintenance window "Atomicity and
concurrent writers" above requires, not a separate, shorter one. It does
not end when rotate/2 returns; it ends when the app is running under
the new key.
Summary
Types
Why a row was left untouched — always paired with its uuid.
Functions
Re-encrypts every stored integration connection under new_secret.
Types
Functions
@spec rotate( String.t(), keyword() ) :: {:ok, %{rotated: non_neg_integer(), dry_run: boolean()}} | {:error, {:decrypt_failed, String.t(), failure_reason()}} | {:error, {:encryption_disabled, PhoenixKit.Integrations.Encryption.key_status()}}
Re-encrypts every stored integration connection under new_secret.
opts:
:dry_run(defaultfalse) — runs the decrypt-and-verify pass against every row and writes nothing. Unlike a real rotation, this does NOT take row locks — it's a plain read, safe to run against live traffic — and does not requirenew_secretto be a real value the caller intends to use.
Returns:
{:ok, %{rotated: n, dry_run: boolean()}}— success. In dry-run moderotatedis how many rows WOULD rotate.{:error, {:decrypt_failed, uuid, reason}}— rowuuidcould not be decrypted under the currently active key. Nothing was written, not even for rows that decrypted fine — investigate the named row (it may already be encrypted under a different key from a prior partial change, or genuinely corrupted) before retrying.{:error, {:encryption_disabled, status}}— refused to run;Encryption.status/0is not one of the recognized "a real key is active" statuses, so there is no active key for "rotate" to mean anything relative to.