Encryptor.Ecto.Declarations (Encryptor.Ecto v0.2.0)

Copy Markdown View Source

The declared encryption contexts a host's schemas carry, and the check that no two of them are the same (ADR-0001 decision 4, acceptance amendment 5).

Every encrypted field freezes a declared "table" and "column" at declaration time, and every message it writes is authenticated under that pair. Two fields that share one declared pair are therefore mutually substitutable: bytes written by one decrypt cleanly when loaded by the other, under the same tenant key. That is exactly the property the encryption context exists to deny, and nothing about the declarations themselves makes the overlap visible - the two modules need never mention each other.

Why this is a start-time check rather than a compile-time one

The obvious shape is a module attribute accumulated at compile time. It cannot work: an accumulator sees only the declarations that reach one compilation unit, and the two colliding declarations are, by construction, in modules that never reference each other. The check has to run at a point where the whole set is knowable, and that point is start time, over the modules an application's .app file lists.

So there is no registry process and no persisted state here - nothing to start, nothing to keep in sync. check_unique!/1 is a pure function over loaded modules, and the host calls it where it wants the failure:

defmodule Payments.Application do
  use Application

  @impl Application
  def start(_type, _args) do
    :ok = Encryptor.Ecto.Declarations.check_unique!(apps: [:payments])

    Supervisor.start_link(children(), strategy: :one_for_one, name: Payments.Supervisor)
  end
end

Calling it from start/2 makes a collision a boot failure on the first deploy that introduces it, which is the only moment it is free to fix. A host that would rather not fail its boot can call the same function from a test instead; what it must not do is leave the pair unchecked, because the overlap is silent in every other way.

What counts as a collision

Two declarations collide when they share a declared {table, column} pair and do not describe the same physical column. The second half matters: two Ecto schemas over one table - a read model, a partial schema, an archival twin - legitimately declare the same field, and they are not substitutable for each other because they are each other. A check that failed those would be a check hosts turn off. Physical identity is the schema's source table and the field's own :source column, so the genuine accident (two different columns pinned to one declared pair) still collides, and the legitimate duplicate does not.

Note what is not part of the comparison: the vault. Two fields sharing a declared pair on different vaults cannot actually decrypt each other's bytes, but a vault is a deployment-time binding that a later refactor can make the same, and the declared pair is the durable property. The check is the stricter one on purpose.

Renaming, and what a rename costs

Since the declared values are frozen at declaration, renaming the physical table or column is free: pin the old values with :table and :column and stored rows stay readable.

defmodule Payments.Cards.Card do
  use Ecto.Schema

  # The physical table is `payment_cards` now; the declared context is
  # still what the rows were written under.
  schema "payment_cards" do
    field :pan, Payments.Encrypted.Binary, table: "cards", column: "pan"
  end
end

Changing a declared value is the opposite: it invalidates every row in the column and is an R3 migration (ADR-0002). The rename that used to be the expensive one is the cheap one, and the cheap-looking one is the expensive one - which is the whole point of freezing them.

Summary

Types

One encrypted field declaration.

Where to look for declarations.

Functions

Raises unless every declared {table, column} pair is unique.

Lists every encrypted field declaration in scope, sorted by declared pair.

Types

declaration()

@type declaration() :: %{
  schema: module(),
  field: atom(),
  type: module(),
  table: String.t(),
  column: String.t(),
  source: String.t() | nil,
  source_column: atom()
}

One encrypted field declaration.

:table and :column are the frozen declared values - what goes into the encryption context. :source and :source_column are the physical table and column, which are only the same strings when the declaration was derived rather than pinned.

scope()

@type scope() :: [apps: [atom()], schemas: [module()]]

Where to look for declarations.

:apps names OTP applications and reads every module in each one's .app file - the ordinary host call. :schemas names schema modules directly, which is what a test wants. At least one of the two is required: there is no "check everything" default, because a library's opinion about which applications a host meant is always wrong.

Functions

check_unique!(scope)

@spec check_unique!(scope()) :: :ok

Raises unless every declared {table, column} pair is unique.

Returns :ok when it is. See the moduledoc for what counts as a collision and for where a host calls this.

iex> Encryptor.Ecto.Declarations.check_unique!(schemas: [Encryptor.Ecto.TestSchemas.Card])
:ok

list(scope)

@spec list(scope()) :: [declaration()]

Lists every encrypted field declaration in scope, sorted by declared pair.

Useful on its own: it is the answer to "what does this deploy consider encrypted, and under what context", which is otherwise spread across every schema in the application.

iex> Encryptor.Ecto.Declarations.list(schemas: [Encryptor.Ecto.TestSchemas.Card])
...> |> Enum.map(&{&1.table, &1.column})
[{"cards", "holder_name"}, {"cards", "metadata"}, {"cards", "notes"}, {"cards", "pan"}]