AshSandbox.SandboxCredentialTemplate stores the credential a sandbox uses to reach its own data
store. The secret is encrypted before it reaches the data layer and decrypted on the way back, by
AshSandbox.EncryptedSecret.
1. Generate and configure a key
Base.encode64(:crypto.strong_rand_bytes(32))config :ash_sandbox, AshSandbox.EncryptedSecret,
key: System.fetch_env!("SANDBOX_CREDENTIAL_KEY")32 raw bytes, Base64-encoded. Anything else raises with the reason — wrong length, or not Base64 — rather than silently producing unreadable rows.
⚠️ Do not generate the key at runtime. A generated key changes on restart and strands every
credential already written. It comes from configuration, and this is why key rotation is out of
scope for the type: rotating the encryption key means re-encrypting every row, which is a
migration. That is distinct from rotating a credential, which is the :rotate action below.
A host that holds no credentials configures no key, and that is a supported state. The library derives the key at application start when one is configured and does nothing when none is; the raise lands on the first use of a credential, where it names what the caller was trying to do.
2. Declare the resource
defmodule MyApp.SandboxCredential do
use AshSandbox.SandboxCredentialTemplate,
data_layer: AshPostgres.DataLayer,
domain: MyApp.Sandboxes,
repo: MyApp.Repo,
table: "sandbox_credentials",
sandbox_resource: MyApp.Sandbox
end3. Issue one
{:ok, credential} =
MyApp.SandboxCredential
|> Ash.Changeset.for_create(:issue, %{
sandbox_id: sandbox.id,
role_name: "sbx_01_app",
secret: generated_password,
data_store_ref: "sbx_01_db"
})
|> Ash.create(authorize?: false)role_name is unique across the table. Two sandboxes sharing a role share its grants, which is
the containment failure in one line.
data_store_ref records which logical database the credential is granted on, so a later rotation
or revoke knows where to act without re-deriving it.
4. Rotate it
{:ok, credential} =
credential
|> Ash.Changeset.for_update(:rotate, %{secret: new_password})
|> Ash.update(authorize?: false)
credential.version
# => 2Rotation replaces the secret in place. The row survives, the role survives, and version
increments atomically — a second row would leave two live credentials for one sandbox and no
statement of which is current.
What this protects against, and what it does not
Two mechanisms are in play and they defend against different readers. Neither implies the other, which is why the template uses both:
- The type governs what someone reading the table sees — a backup, a replica, an operator
with
SELECT, a support engineer running an ad-hoc query. AES-256-GCM, a fresh 12-byte IV per write so two sandboxes with the same secret do not share ciphertext, stored asiv <> tag <> ciphertextand Base64-encoded so the column staystext. sensitive? truegoverns what Ash prints — inspect output, error messages, changeset dumps — and leaves the stored bytes untouched. That is where credentials actually leak: a crashed changeset in a log, an{:error, changeset}inspected by a failing test, a struct dumped by an observer.
GCM is authenticated, so tampered ciphertext produces a decrypt failure rather than a silently different plaintext. A row that fails to authenticate is reported as invalid data against the attribute; it does not crash whatever loaded the record.
⚠️ The tenant can read its own credential, and that is not a leak. The sandbox has to connect
to its own database, so the value is necessarily readable by tenant code. Containment does not come
from concealment — it comes from the credential granting nothing anywhere else: one LOGIN role
per sandbox, with privileges only on that sandbox's own logical database. A test asserting that
reading the credential fails would be testing the wrong guarantee and would fail a correct
implementation.
The secret attribute is deliberately not public?, so it cannot be read through an
owner-facing action; the provisioning path reads it with authorize?: false when injecting it at
sandbox start.
Why not cloak
cloak/cloak_ecto is the conventional Elixir answer and was considered and rejected. This is a
published library, so every dependency it declares is inherited by every consumer, and imposing a
vault configuration on consumers who may hold no credentials at all is not worth 136 lines. Revisit
if key rotation without downtime becomes a requirement, or if a second encrypted attribute appears
in these packages.