AshVault
Per-tenant encrypted attributes for Ash resources, with cryptographic erasure.
AshVault replaces an attribute with an encrypted column plus a decrypt calculation, so writes and reads look exactly like ordinary Ash while the database only ever holds ciphertext. Keys live in an external provider — filesystem or OpenBao — addressed by a scope, which is the Ash tenant by default.
The motivating property
A customer asks to be deleted. You delete the rows. Six weeks later you restore last
month's backup for an unrelated reason, and they are back — not through carelessness, but
because a backup is supposed to contain the state of the database at that time, and that
state included their data. DELETE cannot reach backwards into a tarball. Encryption can,
if the keys live somewhere the restore does not: destroy that customer's key and every
copy of their ciphertext — in the live database, in every snapshot, WAL archive, logical
dump and stolen replica that already exists — becomes permanently undecryptable, while
every other customer's rows in the very same tables keep decrypting normally. That is the
one property AshVault is built around, and the reason the key provider records a
tombstone rather than merely deleting: a destroyed scope must never be silently
re-created by the next write.
Example
defmodule MyApp.Vault do
use AshVault.Vault, key_provider: AshVault.KeyProviders.Local
end
defmodule MyApp.Accounts.User do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer,
extensions: [AshVault]
ash_vault do
vault MyApp.Vault
encrypt :email
encrypt :ssn, encrypt_nil?: false
decrypt_by_default [:email]
end
multitenancy do
strategy :attribute
attribute :org_id
end
attributes do
uuid_primary_key :id
attribute :org_id, :uuid, allow_nil?: false, public?: true
attribute :email, :string, public?: true
attribute :ssn, :string, public?: true
end
actions do
default_accept :*
defaults [:read, :destroy, create: :*, update: :*]
end
end
Write and read as usual:
user =
MyApp.Accounts.User
|> Ash.Changeset.for_create(:create, %{org_id: org.id, email: "ada@example.com"},
tenant: org.id
)
|> Ash.create!()
user.email
#=> "ada@example.com"
What is actually in the column:
%{rows: [[blob]]} = MyApp.Repo.query!("SELECT encrypted_email FROM users LIMIT 1")
<<"AV", 1::8, _rest::binary>> = blob
String.contains?(blob, "ada@example.com")
#=> false
And erasure:
:ok = AshVault.destroy_keys!(MyApp.Vault, org.id)
Ash.read(MyApp.Accounts.User, tenant: org.id)
#=> {:error, %Ash.Error.Invalid{errors: [%AshVault.Errors.KeyDestroyed{}]}}
The row is still there. It is unreadable, in the live database and in every backup, for good.
What it gives you
- AES-256-GCM with a fresh nonce per value, and a versioned self-describing envelope carrying the cipher id and the key version.
- Ciphertext bound to
scope | resource | fieldthrough AEAD associated data, so a blob cannot be relocated between tenants, resources or columns — it fails authentication instead of silently decrypting into the wrong place. - Rotation without re-encryption. The envelope names the key version that opens it, so rotating a scope's key is one API call and touches no rows.
- Per-scope cryptographic erasure, with tombstones that fail closed.
- A distinguishable error taxonomy.
KeyDestroyed,KeyNotFound,ProviderUnavailable,CiphertextIntegrityFailed,KeySizeMismatchare different errors because the correct human response to each is different. An outage never looks like erasure; erasure never looks like tampering. - Operator tooling:
mix ash_vault.backfill,.verify,.key_info,.rotate,.destroy_keys,.local.init. - Pluggable everything — key provider, cipher, envelope, scope, rotation policy — each a small behaviour with a documented contract and a shared test suite.
Providers
| Provider | When to use it | Key store | Survives restart | Multi-node | Setup |
|---|---|---|---|---|---|
AshVault.KeyProviders.Memory |
tests, and only tests | process memory | no | no | add to your supervision tree |
AshVault.KeyProviders.Local |
single-node deployments, homelab, dev with persistence | files on disk, mode 0600 | yes | no | mix ash_vault.local.init <root> |
AshVault.KeyProviders.OpenBao |
production, multi-node | OpenBao/Vault transit | yes | yes | AshVault.KeyProviders.OpenBao.setup/0 |
The requirement that decides this: the key store must be a different system from the
database, so a database restore cannot restore keys. Local satisfies it with a
directory on another volume, excluded from the database backup job. Memory cannot —
it forgets everything on restart, so in production it is not a key store, it is a
countdown to total data loss. OpenBao is the answer for anything with more than one
node, or anything where losing a disk should not lose every customer's data.
Installation
def deps do
[
{:ash_vault, "~> 0.1.0"}
]
end
# .formatter.exs
[import_deps: [:ash, :ash_postgres, :ash_vault]]
Then follow Getting started — pick a
provider, define a vault, add ash_vault to a resource, and confirm the column holds
ciphertext.
Documentation
Tutorial
Topics
- Architecture — the five layers, the envelope, the AAD,
AshVault.Context - Tenant-scoped encryption — how a tenant becomes a key
- Rotation — rotation vs re-encryption vs erasure
- Crypto-erasure — the guarantee, the tombstone, the checklist
- Migrating from plaintext — expand / backfill / cut over / contract
- Searchable fields — not in v1; the design and its disclosure tradeoff
- Threat model — including the non-goals
- Operations — providers, backups, mix tasks, every error
How-to
Design decisions
What it does not do
Read the threat model before promising anyone
anything. In short, AshVault does not defend against a compromised application process
(plaintext is in BEAM memory whenever it is encrypted or decrypted), does not erase
plaintext that already escaped through logs, warehouses, CDC streams or integrations, does
not hide metadata or ciphertext length, and performs no authorization of its own —
Ash.Policy.Authorizer and field policies do that.
It also cannot filter or sort on an encrypted field: randomized AEAD ciphertext supports
neither, and the decrypt calculation is filterable?: false, sortable?: false. Searchable
fields (searchable?, unique?) are specified but rejected at compile time in v1.
Development
mix deps.get
mix test # no Docker required
mix test.all # adds the :postgres and :openbao suites
mix docs
The :postgres and :openbao suites need a PostgreSQL on localhost:5432 and an OpenBao
on http://127.0.0.1:8200; both are excluded by default so mix test is green without
them.
License
See LICENSE.