Encryptor.Ecto.Migration behaviour (Encryptor.Ecto v0.2.0)

Copy Markdown View Source

The compile-time DSL for a migration plan (ADR-0002 decision 2).

A plan module names one repo and, per schema, the fields to rewrite and how the tenant is resolved for its rows:

defmodule MyApp.Encryption.CloakMigration do
  use Encryptor.Ecto.Migration, repo: MyApp.Repo

  rewrite MyApp.Accounts.Customer do
    tenant_from :account_id

    field :tax_id, from: MyApp.Cloak.Encrypted.Binary, to: MyApp.Encrypted.Binary
    field :notes, from: MyApp.Cloak.Encrypted.String, to: MyApp.Encrypted.String
  end

  rewrite MyApp.Reference.Code do
    tenant :none
    field :value, from: MyApp.Cloak.Encrypted.Binary, to: MyApp.Encrypted.Binary
  end
end

The module compiles to a Encryptor.Ecto.Migrator.Plan.t/0, handed over by the __plan__/0 callback this DSL defines. Encryptor.Ecto.Migrator.run/2 and verify/2 take the plan module, so a host names a file it can read.

Why a plan is code

Everything in a plan is code already: the from and to sides are type modules, and which fields are encrypted is exactly the kind of fact that should be reviewed in a diff, versioned with the schema it describes, and deleted in a named commit when the migration is finished. Configuration would make the most consequential operation this package performs invisible to code review - a change to which columns get rewritten, against live production data, arriving without a diff. So there is no config path here, and adding one is not a small convenience: it is the property being refused.

The compile-time checks are the deliverable

ADR-0002 decision 2 promises that a plan which would fail on row one fails at mix compile. Row one is against live production data, from a shell, usually late in a change window, and a typo discovered there has already cost the rehearsal. So every check that can be made against the schema and the modules is made while the plan module compiles, and each failure is a CompileError naming the schema and field it came from:

  • the schema is a real Ecto.Schema, and every field and into: names a field on it;
  • tenant_from names a real column, and a tenant module resolves;
  • every from: module can read the bytes - resolve!/2 decides between Encryptor.Ecto.Migrator.Source, Ecto.ParameterizedType and Ecto.Type and refuses a module that is none of them;
  • every to: module can both load and dump at one consistent arity. The two sides are not symmetric even though the record states them in one clause: a from: module is the host's own legacy code and only has to load, while a to: module receives the rewritten bytes and must dump as well. A to: that cannot dump fails on row one just as loudly, and nothing else in this package would have caught it.

Code.ensure_compiled/1 precedes each of those, so a plan may name a module from its own application: the check waits for the parallel compiler rather than answering "not loaded" on a race it would lose intermittently.

from: and to: may be the same module

A field moving from tenant: :none to tenant: :scope, or gaining a :context pair, is a context change and therefore a full rewrite even though no type module changed. The migrator constructs both sides' params itself (ADR-0002 decision 3), so the plan expresses this by naming the same module on both sides; a migrator built on schema declarations could only ever express the type-module case. Nothing here rejects from: equal to to: for that reason.

Adopting encryption on a plaintext column

into: names a different target column, which is the backfill leg of the expand/backfill/contract dance a never-encrypted column needs (ADR-0002 decision 8): plaintext lives in a text column, ciphertext must live in a binary one, and the DDL either side of the backfill is the host's own migration. Both columns are checked against the schema; which one is wider is not this package's business.

What the legacy cipher does not prove

A legacy stream cipher has no authentication tag, so a failed decrypt is not a signal: the bytes decrypt to something, and the migrator would re-encrypt that something into authenticated storage, permanently, and report it as a success (ADR-0004 decision 3). Two field options exist for that:

  • source_authenticated: false acknowledges it. The report then counts that field's rows :migratable_unverified rather than :migratable, in a dry run and in a verification alike, so no evidence claims verification that never happened; and the pass refuses to run in --mode write for that field unless validate: is declared with it.
  • validate: is a host-supplied (term() -> boolean()) applied to the loaded plaintext before it is re-encrypted (decision 3b) - a tax identifier is nine digits, a serialized map parses, a kept legacy hash column recomputes (decision 3c). A row it rejects is :undecryptable, handled like any other failure. There is no built-in generic validator: this package cannot know what a valid value looks like, and a printable?/UTF-8? check would be reassurance rather than a control.

Silence is allowed only where authentication is provable

ADR-0004's proposed amendment of 2026-08-28 answers Q2. A field whose from: is one of this package's own vault-backed types needs no declaration, because the package that wrote those bytes authenticates them and Encryptor.Ecto.Migrator.Source.vault_backed?/2 can prove it - that is the context-change case above, where from: and to: name the same module with different params. Every other from: is the host's own legacy reader: a cloak cipher module, a legacy load/1, an unknown Source. This package's correctness obligation on that format is nil (decision 1) and it cannot tell an AEAD cipher from a stream cipher by looking, so it asks - at mix compile, where the question is cheap - and such a field must declare source_authenticated: explicitly, true or false. true is not a capability this package checks; it is the host asserting that someone looked at the legacy cipher, in a line a reviewer sees in the diff.

A host upgrading across this change sees its plan stop compiling, with a message naming the field and saying what to write.

Summary

Types

One field's rewrite.

Where a compile-time failure came from, for the CompileError.

Callbacks

The compiled plan. Defined by use Encryptor.Ecto.Migration.

Functions

Declares a plan module. Takes repo:, and nothing else yet.

Declares one field to rewrite: from:, to:, and optionally into:, source_authenticated: and validate:.

Opens a rewrite of one schema. Its body declares a tenant strategy and one or more fields.

Declares the tenant strategy: :none for a global field, or an Encryptor.Ecto.TenantContext module.

Declares that the tenant is read off the named column of each row.

Types

field_spec()

@type field_spec() :: [
  from: module(),
  to: module(),
  into: atom() | nil,
  source: Encryptor.Ecto.Migrator.Source.resolved(),
  source_authenticated: boolean(),
  validate: (term() -> boolean()) | nil
]

One field's rewrite.

:from and :to are the modules the host writes. :into names a different target column for the backfill leg of an adoption migration, and is nil for the ordinary data-only case. :source is derived rather than written: it is what Encryptor.Ecto.Migrator.Source.resolve!/2 decided about :from while the plan compiled, kept here because ADR-0004 decision 2 fixes that resolution as a compile-time step and re-deriving it at run time would put the decision back where the record took it from.

:source_authenticated and :validate are ADR-0004 decision 3, and :source_authenticated is always present in the compiled spec even where the plan did not write it: an undeclared field is true only because the compile-time check proved it (see "What the legacy cipher does not prove"), so the engine reads one key rather than deciding provability again per row.

meta()

@type meta() :: [file: String.t(), line: non_neg_integer()]

Where a compile-time failure came from, for the CompileError.

Callbacks

__plan__()

@callback __plan__() :: Encryptor.Ecto.Migrator.Plan.t()

The compiled plan. Defined by use Encryptor.Ecto.Migration.

Functions

__using__(opts)

(macro)

Declares a plan module. Takes repo:, and nothing else yet.

field(name, opts)

(macro)

Declares one field to rewrite: from:, to:, and optionally into:, source_authenticated: and validate:.

source_authenticated: is required rather than optional wherever the from: type is not one of this package's own - see "What the legacy cipher does not prove".

rewrite(schema, list)

(macro)

Opens a rewrite of one schema. Its body declares a tenant strategy and one or more fields.

tenant(strategy)

(macro)

Declares the tenant strategy: :none for a global field, or an Encryptor.Ecto.TenantContext module.

tenant_from(column)

(macro)

Declares that the tenant is read off the named column of each row.