Packages
phoenix_kit
2.13.0
2.13.1
2.13.0
2.12.1
2.12.0
2.11.0
2.10.0
2.9.0
2.8.1
2.8.0
2.7.0
2.6.0
2.5.0
2.4.0
2.3.0
2.2.0
2.1.0
2.0.1
2.0.0
1.7.236
1.7.235
1.7.234
1.7.233
1.7.232
1.7.231
1.7.230
1.7.229
1.7.228
1.7.227
1.7.226
1.7.225
1.7.224
1.7.223
1.7.222
1.7.221
1.7.220
1.7.219
1.7.218
1.7.217
1.7.216
1.7.215
1.7.214
1.7.213
1.7.212
1.7.211
1.7.210
1.7.209
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/phoenix_kit.integrations.rotate_key.ex
defmodule Mix.Tasks.PhoenixKit.Integrations.RotateKey do
@moduledoc """
Rotates the encryption key protecting stored integration credentials.
## Usage
$ mix phoenix_kit.integrations.rotate_key --dry-run
$ mix phoenix_kit.integrations.rotate_key
$ mix phoenix_kit.integrations.rotate_key --new-key="<already-generated-secret>"
## What it does
1. Reads every stored integration connection.
2. Decrypts each one under whichever key is CURRENTLY active (a
dedicated `:integrations_encryption_key` if configured, else the
legacy `secret_key_base`-derived key — see
`PhoenixKit.Integrations.Encryption`).
3. If every row decrypts cleanly, re-encrypts all of them under the new
secret in a single database transaction — either every connection
rotates, or (on any failure) none do.
4. Prints the new secret (unless you supplied one with `--new-key`) and
the config to set.
This task does NOT write any config file or environment variable — you
must configure `integrations_encryption_key` yourself (typically from an
env var in `runtime.exs`) and restart the app.
## When to run this
* **First adoption** — no dedicated key is configured yet, so every
connection is protected only by the legacy `secret_key_base`-derived
key. Run this once, set the printed secret, restart.
* **Suspected key compromise** — a dedicated key is already configured.
Run this, replace the env var with the newly printed secret, restart.
Treat the old key as permanently compromised; do not reuse it.
## Options
* `--dry-run` — runs the decrypt-and-verify pass over every row and
reports how many WOULD rotate, without generating a key or writing
anything. Unlike a real rotation, this does NOT take row locks —
it's a plain read, safe to run against live traffic at any time, not
just before committing to a real rotation.
* `--new-key` — supply your own secret instead of generating one (e.g.
one already stored in a secrets manager). Skipped in `--dry-run`. Must
not be empty — `--new-key=""` is refused outright rather than
silently falling back to a generated secret, since that's very likely
a shell variable that resolved empty (`--new-key="$MAYBE_UNSET"`) and
not something you meant to ask for.
## Run this with nothing else writing to integration connections
A real rotation's row lock only defends against ONE direction of a race
with a concurrent writer (an OAuth token auto-refresh, a "Test
Connection" click, ...) — see `PhoenixKit.Integrations.KeyRotation`'s
moduledoc, "Atomicity and concurrent writers", for the exact mechanism
and what it does NOT cover. In short: a writer that already read a row
before rotation locked it can still silently overwrite the freshly
rotated row with old-key content after rotation commits, and `rotate/2`
will have already reported success by then. **Pause anything that could
write to integration connections (most concretely: an OAuth token-refresh
worker) before running this for real, and do not resume it until you have
restarted the app under the new key** — not just until this command
returns. Treat the whole span, start to restart, as one maintenance
window.
## The gap between rotating and restarting
Rotation only changes what's in the database; the running app keeps using
the OLD key until you set the new one and restart. This is the SAME
maintenance window the section above requires — it doesn't end when this
command returns, it ends when the app is running under the new key. In
that window:
* A READ of a rotated connection does not raise or crash — a field
that can't be decrypted under the still-active old key is logged
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 to catch — the
field is simply absent.
* A WRITE that must first read-and-merge the existing row (most writes
in `PhoenixKit.Integrations` work this way, including fully
automatic ones like a validation-status update after every
token-refresh attempt) is NOT destructive just because it hits a
row this task already rotated: it restores the untouched field's
ciphertext before saving, as long as the write itself doesn't
supply a fresh value for that exact field. The field stays exactly
as rotation left it and decrypts fine again once the app restarts.
* A WRITE that DOES supply a fresh value for that exact field is
still at risk, whether it's this simple gap or the race the section
above describes: whatever gets encrypted uses whichever key is
ACTIVE, which is still the OLD one until the app restarts, so that
value lands under the OLD key in a row this task already moved to
the new secret. It then fails to decrypt once the app restarts onto
the new key, indistinguishable from unrelated corruption.
There is no dual-key fallback to paper over any of this (it would
silently mask exactly the failure class this task exists to prevent).
Restart promptly, and keep writers paused until you do.
"""
use Mix.Task
alias PhoenixKit.Integrations.KeyRotation
@shortdoc "Rotates the encryption key protecting stored integration credentials"
@switches [dry_run: :boolean, new_key: :string]
@impl Mix.Task
def run(argv) do
case parse_args(argv) do
{:ok, opts} ->
Mix.Task.run("app.start")
if Keyword.get(opts, :dry_run, false), do: run_dry(), else: run_real(opts)
{:error, message} ->
Mix.raise(message)
end
end
# Parses this task's CLI args, `{:ok, opts} | {:error, message}`. Public
# (but undocumented, `@doc false`) and pure — `run/1` isn't itself a
# unit-test seam (it starts the app), but this decision is — same
# reasoning as `Mix.Tasks.PhoenixKit.Repair.exit_code/1`. Deliberately
# `strict:` rather than `switches:`: with `switches:`, an unrecognized or
# misspelled flag — `--dryrun` instead of `--dry-run` — is silently
# accepted as an extra boolean under its OWN (wrong) key rather than
# erroring, so `Keyword.get(opts, :dry_run, false)` would quietly default
# to `false` and run a REAL rotation instead of the dry run the caller
# typed the flag to get. `strict:` turns that same typo into a parse
# error here instead.
@doc false
@spec parse_args([String.t()]) :: {:ok, keyword()} | {:error, String.t()}
def parse_args(argv) do
case OptionParser.parse(argv, strict: @switches) do
{opts, _argv, []} -> validate_new_key(opts)
{_opts, _argv, errors} -> {:error, "Invalid option(s): #{format_option_errors(errors)}"}
end
end
# An explicitly empty `--new-key=""` is refused rather than silently
# treated as "flag not passed, generate one instead". Generating and
# using a random secret when the caller passed an EMPTY value (most
# plausibly `--new-key="$SOME_VAR"` where the variable resolved empty) is
# a real rotation under a key the caller never chose and that is printed
# to stdout exactly once — if that output isn't captured, the credential
# is unrecoverable even though the command "succeeded". Silently
# substituting behavior for a value the caller DID supply, just wrong, is
# the same class of surprise `strict:` above exists to prevent.
defp validate_new_key(opts) do
case Keyword.get(opts, :new_key) do
"" ->
{:error,
"--new-key was passed but empty. Omit the flag entirely to generate a secret, or pass a real one."}
_ ->
{:ok, opts}
end
end
defp format_option_errors(errors) do
Enum.map_join(errors, ", ", fn
{flag, nil} -> flag
{flag, value} -> "#{flag}=#{inspect(value)}"
end)
end
defp run_dry do
# A dry run only needs the decrypt-and-verify pass — the "new secret"
# never reaches an encrypt call, so a placeholder is fine here.
case KeyRotation.rotate("dry-run-placeholder-unused", dry_run: true) do
{:ok, %{rotated: n}} ->
Mix.shell().info("OK — #{n} connection(s) would rotate cleanly. Nothing was written.")
{:error, {:decrypt_failed, uuid, reason}} ->
Mix.raise(decrypt_failed_message(uuid, reason))
{:error, {:encryption_disabled, status}} ->
Mix.raise(encryption_disabled_message(status))
end
end
defp run_real(opts) do
{new_secret, supplied?} = resolve_new_secret(opts)
case KeyRotation.rotate(new_secret) do
{:ok, %{rotated: n}} ->
print_success(n, new_secret, supplied?)
{:error, {:decrypt_failed, uuid, reason}} ->
Mix.raise(decrypt_failed_message(uuid, reason))
{:error, {:encryption_disabled, status}} ->
Mix.raise(encryption_disabled_message(status))
end
end
# Resolves the secret a real (non-dry-run) rotation writes under:
# `{secret, supplied?}`. Trusts `opts` came through `parse_args/1`, which
# already rejects an explicitly-empty `--new-key=""` (see
# `validate_new_key/1`) — so `nil` here means the flag was genuinely never
# passed, not "passed but empty", and generating a secret is unambiguously
# correct. Public for the same testability reason as `parse_args/1`.
@doc false
@spec resolve_new_secret(keyword()) :: {String.t(), boolean()}
def resolve_new_secret(opts) do
case Keyword.get(opts, :new_key) do
value when is_binary(value) -> {value, true}
nil -> {generate_secret(), false}
end
end
defp decrypt_failed_message(uuid, reason) do
"Row #{uuid} failed to decrypt under the CURRENTLY active key (#{inspect(reason)}). " <>
"Rotation aborted — NOTHING was written, not even for rows that decrypted fine. " <>
"Investigate this row (it may already be encrypted under a different key from an " <>
"earlier partial change, or genuinely corrupted) before retrying."
end
defp encryption_disabled_message(status) do
"Refusing to rotate — encryption is not active (status: #{inspect(status)}). " <>
"Rotation re-encrypts under a new secret; with no key active there is nothing " <>
"meaningful to rotate. Set integration_encryption_enabled: true and configure a key " <>
"first."
end
defp generate_secret, do: 32 |> :crypto.strong_rand_bytes() |> Base.encode64()
defp print_success(count, secret, supplied?) do
Mix.shell().info("\nRotated #{count} connection(s).\n")
unless supplied? do
Mix.shell().info("""
New secret (shown ONCE — copy it now, this task does not save it anywhere):
#{secret}
""")
end
Mix.shell().info("""
Set it as the active key and restart the app, e.g. in runtime.exs:
config :phoenix_kit, integrations_encryption_key: System.get_env("PHOENIX_KIT_INTEGRATIONS_ENCRYPTION_KEY")
with the value above wired to that environment variable. Stored connections are now
encrypted under the NEW secret — reads will fail until you configure it and restart,
so don't delay between running this and restarting.
""")
end
end