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
endEverything 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
@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).
@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
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.
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
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).
Embeds as the cast value rather than the dumped one (decision 9).
iex> Encryptor.Ecto.Map.embed_as(:json, %{})
:self
Compares plaintext maps, never stored bytes (decision 9).
iex> Encryptor.Ecto.Map.equal?(%{"channel" => "web"}, %{"channel" => "web"}, %{})
true
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.
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.
@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
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]