The shared shape, and the redaction rules, of the encrypted-field exception family (ADR-0001 decision 6, acceptance amendment 2).
Every exception this package raises from dump/3 and load/3 carries the
same four identifying values - the declared table, the declared column, the
encryption-context keys, and the upstream reason - plus the tenant
identifier where one was resolved. Individual exceptions add a field or two
of their own; none of them adds a value.
The prohibition this module exists to enforce
No exception message, log line, or
Exception.message/1ever includes plaintext, ciphertext bytes, or key material - including in theInspectimplementation of the exception struct.
That is ADR-0001 decision 6, and it is a decision rather than a review
preference. It is enforced structurally here rather than by convention in
five separate modules: __using__/1 defines the struct, message/1, and
an Inspect implementation that replaces the derived one, so an exception
in this family cannot be added without the override.
The mechanism is redact/1, which renders a term the upstream vault
supplied. Only atoms and integers survive it verbatim; tuples and lists are
rendered structurally with each element redacted in turn; a binary is
reduced to its byte count and everything else to a bare marker. The rule is
deliberately about the shape of a term rather than about where it came
from, because a vault :reason is an opaque term this layer cannot inspect
for secrets - a binary in one is as likely to be a ciphertext fragment as a
message.
The declared table, the declared column and the context keys are not redacted: they are declaration-time strings, they are what makes a failure diagnosable, and the bead's rule names them as carried. The tenant identifier is likewise rendered, as the one value the prohibition exempts.
The cost, stated
Redaction by shape loses operator-facing detail that happens to be a binary.
{:encryption_context_mismatch, "table"} in a DecryptError's :engine
field renders its tag and the byte count of its key name, not the key name.
The alternative - a heuristic that lets "short, printable" binaries through -
passes a three-character plaintext, so the structural rule is the one that
holds.
Where the guarantee stops
It covers every rendering this layer controls: Exception.message/1,
Exception.format/3 and the stacktrace it prints, and Kernel.inspect/2.
It does not cover a caller who deliberately steps around the Inspect
protocol - inspect(error, structs: false), or Map.from_struct/1 followed
by anything. Those bypass every implementation in the system, Ecto's own
@derive Inspect redaction included, and no implementation can defend
against them. The :reason field holds the vault's term rather than a
pre-rendered string precisely so that a caller can still match on it, which
is what leaves the term reachable at all.
Summary
Types
The identifying values every exception in the family carries.
A rendered label: value pair, ready to join into a detail list.
Functions
Defines one member of the exception family.
The rendered detail pairs every exception in the family carries.
Renders a term supplied by the vault, keeping plaintext, ciphertext bytes and key material out of the result.
Renders detail pairs as the Inspect form of an exception struct.
Renders a headline and its detail pairs as an Exception.message/1 result.
Types
@type common() :: [ table: String.t() | nil, column: String.t() | nil, context_keys: [String.t()], tenant: term(), reason: term() ]
The identifying values every exception in the family carries.
:context_keys are encryption-context key names; the context's values are
never carried. :tenant is the resolved tenant identifier, the single value
ADR-0001 decision 6 exempts from the prohibition.
A rendered label: value pair, ready to join into a detail list.
Functions
Defines one member of the exception family.
Injects the common struct fields, an Exception implementation whose
message/1 renders the headline and the detail list, and an Inspect
implementation that replaces the derived one. :extra_fields adds
struct fields beyond the common set.
The using module must define headline/0. It may define extra_detail/1,
returning detail/0 pairs appended to the common ones; the default
returns none.
The rendered detail pairs every exception in the family carries.
Renders a term supplied by the vault, keeping plaintext, ciphertext bytes and key material out of the result.
Atoms and integers render as themselves. Tuples and lists render structurally, with every element redacted in turn. A binary renders as its byte count, and any other term as a bare marker.
iex> Encryptor.Ecto.Error.redact(:decrypt_failed)
":decrypt_failed"
iex> Encryptor.Ecto.Error.redact({:missing_required_context_keys, [:table]})
"{:missing_required_context_keys, [:table]}"
iex> Encryptor.Ecto.Error.redact("4111111111111111")
"<<redacted 16 bytes>>"
iex> Encryptor.Ecto.Error.redact(%{card_number: "4111111111111111"})
"<redacted>"
Renders detail pairs as the Inspect form of an exception struct.
Deliberately not the derived form: the derived one prints every field with
Inspect defaults, which is precisely what ADR-0001 decision 6 forbids.
Renders a headline and its detail pairs as an Exception.message/1 result.