A string that is encrypted before it reaches the data layer and decrypted on the way back (003 T036, data-model.md §SandboxCredential).
Why this exists alongside sensitive? true
The two are routinely conflated and protect against different readers.
sensitive? true governs what Ash prints — inspect output, error
messages, changeset dumps — and leaves the stored bytes untouched. This type
governs what someone reading the table sees: a database backup, a replica,
an operator with SELECT, or a support engineer running an ad-hoc query.
A credential needs both, because neither implies the other. The resource
therefore keeps sensitive? true on the attribute and uses this as its
type.
AES-256-GCM, from OTP's :crypto
GCM is authenticated: tampering with stored ciphertext produces a decrypt
failure rather than a silently different plaintext. That matters here because
the value is fed into CREATE ROLE ... PASSWORD and a corrupted secret that
decrypts to something would produce a sandbox nobody can connect to, with
no indication why.
A fresh 12-byte IV per write is generated for every encryption, so two sandboxes that happen to share a secret do not share ciphertext. Without it the scheme is deterministic and leaks equality across rows — an operator could see which sandboxes share a credential without decrypting anything.
Stored as iv <> tag <> ciphertext, Base64-encoded so the column stays
text and needs no migration.
Key management
The key comes from configuration and is not generated at runtime: a generated key would change on restart and strand every credential already written. Configure it as 32 raw bytes, Base64-encoded:
config :ash_sandbox, AshSandbox.EncryptedSecret,
key: System.fetch_env!("SANDBOX_CREDENTIAL_KEY")Why not cloak
cloak/cloak_ecto is the conventional Elixir answer and was considered and
rejected -- recorded in 003 data-model.md §Decision. The short form: this is
a published library (012-FR-015), so every dependency it declares is
inherited by every consumer, and imposing Cloak's vault configuration on
consumers who may hold no credentials at all is not worth 136 lines. The
accepted cost is the next paragraph.
Revisit if a requirement appears for key rotation without downtime, or if a second encrypted attribute appears in these packages.
This is deliberately not a key rotation mechanism. Rotating the encryption
key means re-encrypting every row, which is a migration rather than a type
concern — and distinct from FR-020's credential rotation, which changes the
secret itself and is handled by the resource's :rotate action.
Summary
Functions
@spec warm() :: :ok
Derives the key now, so no later read has to.
Called from AshSandbox.Application.start/2. A consumer that holds no
credentials configures no key, so an unconfigured application is a no-op here
rather than a boot failure — the raise belongs on the first use of a
credential, where it names what the caller was trying to do.