Encryptor.Ecto.Map (Encryptor.Ecto v0.2.0)

Copy Markdown View Source

An encrypted map field: Encryptor.Ecto.Binary over a serialized map (ADR-0001 decisions 1 and 8).

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

defmodule Payments.Cards.Card do
  use Ecto.Schema

  schema "cards" do
    field :merchant_id, :string
    field :metadata, Payments.Encrypted.Map
  end
end

Everything below the serializer is Encryptor.Ecto.Binary, called rather than copied: the same closed option set plus :json, 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 differences are documented here.

The serializer, and :json

:json names any module exporting encode!/1 and decode!/1, and defaults to Jason (decision 8). It is checked at init/2 rather than assumed, so a declaration naming a module that cannot serialize fails where the field is declared instead of on the first write of a production row.

The serializer runs on the plaintext side of the vault call - encoding before the dump, decoding after the load - so its failure is neither an encryption failure nor an integrity event, and it raises Encryptor.Ecto.SerializationError rather than being folded into either. Like every exception in the family, it carries the declared table, the declared column and the context key names and no value: a serialization failure is the point in this package where a plaintext is closest to hand.

Two arms go slightly beyond what decision 6's table enumerates, and both are noted rather than assumed. A payload the serializer parsed successfully into something that is not a map raises the same exception with a {:not_a_map, tag} reason - it is a deserialization result the type cannot return, and calling it an integrity event would blame the vault for a serializer's answer. And a :json module that is not loadable, or does not export both functions, is refused at init/2 rather than at the first write; decision 3 makes unknown options a compile-time error and says nothing about an option's value, so this is a fail-fast extension of it.

Its :tenant field is nil even on the decode side, where one was resolved. This layer resolves no tenant of its own - Binary does, inside the call this one wraps - and reporting a tenant it would have to re-resolve to know is a second source of truth for the value the whole encryption context turns on.

A loaded map has string keys

Which is what :map-typed Ecto columns already do. Atom keys are not offered, and adding them later would not be a bug fix: String.to_existing_atom/1 fails on a key whose atom the loading node has not created, and the alternative is an unbounded-atom hazard driven by stored data.

The consequence a host meets first: a map cast with atom keys dumps fine and loads back with string keys, so %{channel: "web"} in and %{"channel" => "web"} out. cast/2 does not silently rewrite the keys to hide it - Ecto's own :map type does not either, and a normalization that happens in one direction only is harder to reason about than the asymmetry it conceals. Declare the map with string keys and the round trip is exact.

Decision 8 states this as "always", and with the default serializer it is: Jason produces string keys and nothing here can change that. It is not verified for a host-supplied :json, which the same decision permits to be any module exporting encode!/1 and decode!/1. load/3 checks that the payload deserialized into a map and stops there - walking every key of every loaded map on every read is a per-row cost, and refusing one would be a failure class decision 6's table does not list. A host naming its own serializer is trusted to honour the rule, and this says so rather than repeating a guarantee the code does not enforce.

Structs are refused at cast/2, and the record does not settle it

A struct is a map, and this type rejects one anyway. Stated plainly, because it is a choice rather than a citation: decision 6 gives "a non-map handed to Map" as the cast failure, and on a literal reading a struct is not that. Decision 8 leans the other way - "a host wanting struct fidelity uses an embedded schema over Encryptor.Ecto.Map, not a smarter serializer" - but neither settles the arm.

Refusing is the reading taken here, for one reason: a struct that does carry a Jason.Encoder round-trips to a plain map silently, on every read. It is also the direction that is cheap to undo - relaxing cast/2 later breaks no host, while tightening it later breaks every host that had been passing structs. If the operator rules the other way, the change is one guard on one clause.

:erlang.term_to_binary/1 is refused, on the record

It makes the payload a deserialization surface, ties the stored bytes to the BEAM, and would let a successfully-authenticated-but-hostile payload construct arbitrary terms. Encrypted bytes are still bytes that may have come from a restore of somebody else's backup, so the deserializer stays boring on purpose (decision 8).

nil, and empty

nil dumps and loads as nil with no encryption, and %{} is not nil: it is serialized, encrypted, and round-trips as %{} (decision 7). The distinction between "no value" and "empty value" is the host's to make and this layer preserves it exactly.

Summary

Types

The options use Encryptor.Ecto.Map accepts.

What init/2 freezes: Encryptor.Ecto.Binary's params plus the serializer.

Functions

Defines an encrypted map type on the using module.

Casts a value on its way into a changeset. Never encrypts, never serializes.

Serializes a map and encrypts the result.

Embeds as the cast value rather than the dumped one (decision 9).

Compares plaintext maps, never stored bytes (decision 9).

Freezes the declared context and the serializer.

Decrypts the stored bytes and deserializes them back into a map.

The column type, which is :binary - a serialized map is bytes like any other plaintext (decision 2).

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

Types

opts()

@type opts() :: [
  json: module(),
  vault: module(),
  tenant: :scope | :none | module(),
  context: %{optional(String.t()) => String.t()},
  legacy: module(),
  table: String.t(),
  column: String.t()
]

The options use Encryptor.Ecto.Map accepts.

Encryptor.Ecto.Binary's set plus :json, which is Map-only (decision 3).

params()

@type params() :: %{
  vault: module(),
  tenant: :scope | :none | module(),
  context: %{optional(String.t()) => String.t()},
  table: String.t(),
  column: String.t(),
  legacy: module() | nil,
  json: module()
}

What init/2 freezes: Encryptor.Ecto.Binary's params plus the serializer.

Functions

__using__(opts)

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

Defines an encrypted map type on the using module.

See Encryptor.Ecto.Binary for the shared option set, and :json above; anything outside the two raises here, while the host module is compiling.

cast(value, params)

@spec cast(term(), term()) :: {:ok, map() | nil} | :error

Casts a value on its way into a changeset. Never encrypts, never serializes.

Accepts nil and a plain map; a non-map is a validation failure (decision 6), and so is a struct, which could not round-trip.

iex> Encryptor.Ecto.Map.cast(%{"channel" => "web"}, %{})
{:ok, %{"channel" => "web"}}

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

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

iex> Encryptor.Ecto.Map.cast("channel=web", %{})
:error

iex> Encryptor.Ecto.Map.cast(%URI{}, %{})
:error

dump(value, dumper, params)

@spec dump(term(), function(), params()) :: {:ok, binary() | nil}

Serializes a map and encrypts the result.

nil passes through unencrypted. There is no :error arm: a serializer failure raises Encryptor.Ecto.SerializationError and every failure below it raises whatever Encryptor.Ecto.Binary.dump/3 raises (decision 6).

embed_as(format, params)

@spec embed_as(atom(), term()) :: :self

Embeds as the cast value rather than the dumped one (decision 9).

iex> Encryptor.Ecto.Map.embed_as(:json, %{})
:self

equal?(left, right, params)

@spec equal?(term(), term(), term()) :: boolean()

Compares plaintext maps, never stored bytes (decision 9).

iex> Encryptor.Ecto.Map.equal?(%{"channel" => "web"}, %{"channel" => "web"}, %{})
true

init(declared, field_opts)

@spec init(keyword(), keyword()) :: params()

Freezes the declared context and the serializer.

Encryptor.Ecto.Binary.init/2 does the first half - the option validation, the tenant strategy, and the "table"/"column" derivation - and the serializer is resolved and checked here.

load(value, loader, params)

@spec load(term(), function(), params()) :: {:ok, map() | nil}

Decrypts the stored bytes and deserializes them back into a map.

nil passes through. A decrypt failure is Encryptor.Ecto.Binary's to raise; a payload that decrypts and then fails to parse, or that parses into something other than a map, is Encryptor.Ecto.SerializationError.

A value that came back through the migration window's :legacy module is not deserialized: the legacy type is a map type too, and its load/1 has already returned the map. This is why the call below asks Encryptor.Ecto.Binary which arm answered rather than taking the value.

type(params)

@spec type(term()) :: :binary

The column type, which is :binary - a serialized map is bytes like any other plaintext (decision 2).

iex> Encryptor.Ecto.Map.type(%{})
:binary

validate_declaration!(module, opts)

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

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

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