Field encryption at rest
Waldur encrypts sensitive credential columns at rest with application-level
Fernet (waldur_core.core.encryption).
The goal is defense in depth: a database dump, a stray SELECT, a SQL-injection
read or a leaked backup yields opaque tokens rather than plaintext secrets. It is
not a secrets manager — an attacker who compromises the application process can
still decrypt, because the key lives in process memory.
This page covers what is encrypted, how the key is configured and rotated, and the accepted limitations. For the resource API key lifecycle specifically, see Resource API Keys.
What is encrypted
| Model / field | Scope | Notes |
|---|---|---|
marketplace.Offering.secret_options |
selective — only sensitive values in the JSON | keys and non-sensitive values stay plaintext |
structure.ServiceSettings.password |
whole value | |
structure.ServiceSettings.token |
whole value | |
structure.ServiceSettings.options |
selective — only credential-named values in the JSON | client_secret, keycloak_password, vault_token, … |
marketplace.ResourceApiKey.key_ciphertext |
whole value | see Resource API Keys |
Encryption is transparent: values are encrypted at the database-serialization
boundary (pre_save) and decrypted on read (from_db_value). The in-memory model
attribute always holds plaintext, so FieldTracker compares plaintext and does not
report a phantom change (handlers gated on a change do not fire on an unchanged
save). Because a Fernet token uses a random IV, the same plaintext encrypts to a
different token every time; encrypted values therefore cannot be queried by
value (filter(token=...) will not match) — this is fine, as none of the
encrypted fields is queried by value.
Which JSON values are encrypted
Offering.secret_options and ServiceSettings.options are both per-plugin JSON blobs
that mix credentials with ordinary configuration — endpoints, tenant ids, usernames,
tuning flags — and secret_options.customer_uuid is even queried by value. Encrypting
the whole blob would break those lookups and make the column unreadable for support, so
only the credential values are encrypted, key by key.
A value is encrypted when its key is:
- the exact keys
password,tokenorsecret, or any key suffixed_password,_tokenor_secret—vault_token,keycloak_password,heappe_password,shared_user_password,client_secret. A new key with one of those suffixes, from any plugin, is covered automatically the day it is added. This shared rule lives inwaldur_core.core.encryption(is_credential_key). - for
secret_optionsonly, a small set of named exceptions carrying no such suffix — currently onlyargocd_k8s_kubeconfig(a Rancher kubeconfig that embeds cluster credentials), listed inwaldur_mastermind.marketplace.secret_options.
Sensitivity is decided by key name alone, not the offering type: encryption and
decryption must agree on the key set, and the read path (from_db_value) cannot see
the offering type. Encrypting argocd_k8s_kubeconfig on a non-Rancher offering, where
it does not occur, is harmless.
Everything else stays plaintext, including endpoint URLs (api_url, backend_url,
vault_host, …), usernames, identifiers (client_id, tenant_id,
subscription_id), the public OpenStack TLS certificate
(openstack_api_tls_certificate), and Script hooks.
Adding a credential option. Name it *_password, *_token or *_secret and it is
encrypted with no further work. If it must carry another name, add it to
_EXTRA_SENSITIVE_KEYS. SecretOptionsClassificationDriftTest fails the build on any
key declared by a secret-options serializer that is neither classified sensitive nor
listed there as knowingly plaintext, so a new credential cannot reach the column in
cleartext unnoticed.
Configuration
FIELD_ENCRYPTION_KEY is a Fernet key, deliberately a separate setting from
SECRET_KEY: leaking Django settings must not, by itself, unlock encrypted DB
fields. Generate one with:
1 | |
and set it in the environment:
1 | |
When no dedicated key is configured, the key is derived from SECRET_KEY (with a
startup warning). That derived key always remains an implicit last-resort decrypt
fallback, so a deployment that starts without a dedicated key can introduce
FIELD_ENCRYPTION_KEY later without losing rows written before the switch. Running
in production without a dedicated FIELD_ENCRYPTION_KEY is discouraged: it removes
the separation from SECRET_KEY.
See also the settings reference.
Key rotation
FIELD_ENCRYPTION_KEY_FALLBACKS (a comma-separated list) holds previous keys so the
app can be re-keyed without downtime, using MultiFernet: encryption always uses the
primary FIELD_ENCRYPTION_KEY, decryption is attempted against the primary and
every fallback. To rotate:
- Generate a new Fernet key, set it as
FIELD_ENCRYPTION_KEY, and move the previous key intoFIELD_ENCRYPTION_KEY_FALLBACKS. Existing rows still decrypt (via the fallback); new writes use the new primary. - Run
waldur reencrypt_fields, which rewrites every stored token under the new primary. Waiting for rows to be re-saved on their own does not work: a value is only rewritten when it happens to be rotated, so there is no point at which you could tell the old key had become unnecessary. - Drop the old key from
FIELD_ENCRYPTION_KEY_FALLBACKS.
waldur reencrypt_fields --dry-run reports the same counts without writing, and in
particular how many rows no configured key can decrypt — worth checking on its
own, since such rows are invisible until something tries to read them. The command
covers every encrypted field: the scalar columns and the selectively-encrypted
values inside secret_options.
Backups
The encryption key is not part of a database dump. Back up FIELD_ENCRYPTION_KEY
(and any FIELD_ENCRYPTION_KEY_FALLBACKS) separately and at least as carefully as the
database:
- a database backup without the key is unreadable for the encrypted columns;
- losing the key loses the data — there is no recovery path for values only that
key could decrypt (
reencrypt_fieldsreports them as undecryptable).
Effect on offering revision history
Offering.secret_options is excluded from django-reversion tracking, and migration
0266 removed it from the version history that already existed (it was serialised in
plaintext there, which defeated the point of encrypting the column). Two consequences
worth knowing before you use the admin history view:
secret_optionsis not versioned. Offering versions no longer record it, so the history view cannot show what the credentials used to be, and there is no way to see when they changed. Use the event log for that.- Reverting an offering leaves the current credentials in place. Because the field
is absent from the stored version, a plain revert would deserialise it as the empty
default and wipe the live credentials on save. A
pre_savehandler (encrypt_secret_options_on_raw_save) restores the current value on a raw save instead, so a revert changes every other field and leavessecret_optionsalone. A revert is therefore not a way to roll credentials back.
The same handler covers loaddata, which is also a raw save: a fixture's
secret_options is encrypted on the way in rather than stored verbatim.
Known limitations and accepted risks
- Application-server compromise is not protected. The process holds the key and can decrypt. This is field encryption, not an HSM/KMS.
- Fernet embeds a creation timestamp. Someone with database access can tell when each secret was last written. Accepted — metadata only, no plaintext.
- No per-row binding (requires database write). Encryption and decryption use the same key set and encryption is unconditional, so the field cannot be turned into a decryption oracle through the API: a token planted under a non-sensitive key is returned untouched (never decrypted), and one placed under a sensitive key is re-wrapped rather than passed through. A Fernet token still carries no row identity, so someone with direct database write access could copy a ciphertext between the same field on two rows — but that already requires a full database compromise.
- The offering export endpoint decrypts by design. Exporting an offering with
include_secret_options=truewrites decryptedsecret_optionsinto the export payload, so it can be imported into another instance. This intentionally produces an unencrypted copy; treat such exports as secret material.