Encryptor.Ecto.String (Encryptor.Ecto v0.2.0)

Copy Markdown View Source

An encrypted text field: Encryptor.Ecto.Binary with a String.t/0 cast arm (ADR-0001 decision 1).

defmodule Payments.Encrypted.String do
  use Encryptor.Ecto.String, vault: Payments.Vault
end

defmodule Payments.Cards.Card do
  use Ecto.Schema

  schema "cards" do
    field :merchant_id, :string
    field :holder_name, Payments.Encrypted.String
  end
end

Everything below cast/2 is Encryptor.Ecto.Binary, called rather than copied: the same closed option set, the same declared "table"/"column" context, the same tenant resolution, the same :binary column, the same exception family, and the vault's bytes stored verbatim. Read that module for all of it; only the difference is documented here.

The difference is cast/2

Binary accepts any binary. This type accepts only a binary that is valid UTF-8 - which is what String.t/0 means - and returns the ordinary :error for one that is not, the way a validation failure should surface (decision 6). Choosing String over Binary for a text column is therefore a choice with an effect rather than a naming preference: a changeset that hands this field bytes from a file upload or a mis-decoded external payload gets an error on the field, instead of a column that decrypts years later into something no reader can render.

dump/3 and load/3 are Binary's unchanged, and load deliberately does not re-check validity. The exception family is fixed by decision 6 and adding a class to it is a decision rather than an implementation detail; a value that was valid when it was cast is still valid when it comes back, and bytes that were not written through this type are the migrator's problem (ADR-0002), not a new integrity event invented here.

The same holds for the migration window: a value that came back through :legacy is returned as the legacy module produced it, unchecked. That module is the host's own working reader for the column, and re-validating its answer here would turn a readable legacy row into an exception during the one window where the row is supposed to stay readable.

Everything else

nil is NULL and "" is encrypted and round-trips as "" (decision 7). The column is :binary, not :text - type/1 returns :binary whatever the plaintext was (decision 2), so the migration writes :binary here just as it does for Binary. Encrypted columns are not queryable, sortable or uniquely indexable (decision 10), and being text changes none of that.

Summary

Types

The options use Encryptor.Ecto.String accepts.

Functions

Defines an encrypted text type on the using module.

Casts a value on its way into a changeset. Never encrypts.

Checks a declaration's option set while the declaring module compiles.

Types

opts()

@type opts() :: Encryptor.Ecto.Binary.opts()

The options use Encryptor.Ecto.String accepts.

Identical to Encryptor.Ecto.Binary's: this type adds no option and removes none, and :json belongs to Encryptor.Ecto.Map alone (decision 3).

Functions

__using__(opts)

(macro)
@spec __using__(opts()) :: Macro.t()

Defines an encrypted text type on the using module.

See Encryptor.Ecto.Binary for the option set; anything outside it raises here, while the host module is compiling.

cast(value, params)

@spec cast(term(), term()) :: {:ok, String.t() | nil} | :error

Casts a value on its way into a changeset. Never encrypts.

Accepts nil and a binary that is valid UTF-8; everything else is a validation failure and returns :error.

iex> Encryptor.Ecto.String.cast("Ada Lovelace", %{})
{:ok, "Ada Lovelace"}

iex> Encryptor.Ecto.String.cast(nil, %{})
{:ok, nil}

iex> Encryptor.Ecto.String.cast(<<0xFF, 0xFE>>, %{})
:error

iex> Encryptor.Ecto.String.cast(:not_a_string, %{})
:error

validate_declaration!(module, opts)

@spec validate_declaration!(
  module(),
  keyword()
) :: keyword()

Checks a declaration's option set while the declaring module compiles.

iex> Encryptor.Ecto.String.validate_declaration!(Payments.Encrypted.String,
...>   vault: Payments.Vault
...> )
[vault: Payments.Vault]