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

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

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.