PhoenixKit.Integrations.KeyRotation (phoenix_kit v2.13.7)

Copy Markdown View Source

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/0 reports :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 (see PhoenixKit.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.Integrations work 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

failure_reason()

@type failure_reason() :: :decrypt_failed_under_current_key

Why a row was left untouched — always paired with its uuid.

Functions

rotate(new_secret, opts \\ [])

@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 (default false) — 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 require new_secret to be a real value the caller intends to use.

Returns:

  • {:ok, %{rotated: n, dry_run: boolean()}} — success. In dry-run mode rotated is how many rows WOULD rotate.
  • {:error, {:decrypt_failed, uuid, reason}} — row uuid could 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/0 is not one of the recognized "a real key is active" statuses, so there is no active key for "rotate" to mean anything relative to.