Encryptor.Ecto.BlindIndex.Declaration (Encryptor.Ecto v0.2.0)

Copy Markdown View Source

One blind_index/3 declaration, and the single source of truth the write side and the read side both read (ADR-0003 decisions 4, 5 and 6's declaration half).

Encryptor.Ecto.BlindIndex.blind_index/3 builds one of these while the schema module compiles and stores it on the module. Everything that computes an index value afterwards - the changeset helper, the query helper, a host building its own query - resolves the declaration from the schema and reads its normalization and its key derivation from here. That is what makes decision 5's promise structural rather than a review preference: two helpers that read one declaration cannot disagree about what the index is.

What is stored, and what is resolved later

The struct holds the declaration verbatim - the source field, the index column, and the six options - plus the schema it was written in. What it does not hold is anything read from the encrypted field: the declared table and column that reach the HKDF info string come from the field's frozen Ecto.ParameterizedType params (ADR-0001 decision 4, acceptance amendment 5), and derivation!/1 reads them at the point of use rather than copying them here. A copy would be a second place for the declared context to live, and the whole point of freezing it is that there is one.

:scope, and why :scope_declared? is carried

Decision 3a defaults a tenant-capable field's index to scope: :tenant, so :scope is always resolved on the struct and a reader never has to apply a default. Decision 3c then makes silence an error on a tenant: :none field, which is a fact about what the reviewer saw written rather than about the resolved value - scope: :global written and scope: :global inferred are the same derivation and a different schema line. :scope_declared? carries that distinction to the compile-time check, and is why the check can refuse a declaration whose resolved scope would have been correct anyway.

Which operation an index computation is

Encryptor.Ecto.BlindIndex.Derivation.selector!/3 takes the Encryptor.Ecto.TenantContext operation from its caller, because ADR-0003 does not say which of :dump/:load an index computation is. This package answers it here, at the seam where the answer becomes observable to a host resolver: a write-side computation asks with :dump and a read-side computation asks with :load, matching what the encrypted field itself would be doing at the same moment. A host whose resolver answers differently for reads and writes - a reporting job with a :load tenant it does not have on :dump is ADR-0001's own example - gets the answer it would expect from the ordinary encryption path. The ruling is recorded for the acceptance reading rather than assumed settled.

Summary

Types

Why a declaration cannot be resolved against its schema.

t()

One declared index.

Functions

Whether a module carries blind index declarations.

The derivation identity this declaration keys under.

The declaration for one {source, column} pair.

The same, raising with what is declared when nothing matches.

The encrypted field's frozen parameters, read from the schema.

The same, raising for a caller past the compile-time check.

Every index declared on a schema, in declaration order.

Every index declared over one source field.

Builds one declaration, checking its options where the host wrote them.

Normalizes one value under this declaration's normalizer.

Types

resolution_error()

@type resolution_error() :: :missing_field | :not_encrypted

Why a declaration cannot be resolved against its schema.

t()

@type t() :: %Encryptor.Ecto.BlindIndex.Declaration{
  bits: 64 | 128 | 192 | 256,
  column: atom(),
  name: String.t(),
  normalize: Encryptor.Ecto.BlindIndex.Normalizer.t(),
  schema: module(),
  scope: Encryptor.Ecto.BlindIndex.Derivation.scope(),
  scope_declared?: boolean(),
  slow: boolean(),
  source: atom(),
  version: pos_integer()
}

One declared index.

:source is the encrypted field being indexed and :column is the ordinary field the index value is stored in. :name is the index_name component of the HKDF info string (decision 2), always a binary by the time it is here.

Functions

declares?(module)

@spec declares?(module()) :: boolean()

Whether a module carries blind index declarations.

Code.ensure_loaded?/1 before function_exported?/2 for the reason Encryptor.Ecto.Declarations gives at its own use of the pair: the bare export check answers false for a module that is merely not loaded yet, and a check whose answer depends on load order passes in the suite that would have caught the mistake.

derivation!(declaration)

@spec derivation!(t()) :: Encryptor.Ecto.BlindIndex.Derivation.t()

The derivation identity this declaration keys under.

The table and column come from the encrypted field's frozen params rather than from the declaration, so an index derives under the same declared context the field encrypts under, and a physical rename moves neither.

iex> Encryptor.Ecto.BlindIndex.Declaration.fetch!(
...>   Encryptor.Ecto.TestSchemas.Customer, :email, :email_index)
...> |> Encryptor.Ecto.BlindIndex.Declaration.derivation!()
...> |> Encryptor.Ecto.BlindIndex.Derivation.info()
"encryptor_ecto/blind_index/v1|customers|email|email_index|1"

fetch(schema, source, column)

@spec fetch(module(), atom(), atom()) :: {:ok, t()} | :error

The declaration for one {source, column} pair.

iex> {:ok, declaration} =
...>   Encryptor.Ecto.BlindIndex.Declaration.fetch(
...>     Encryptor.Ecto.TestSchemas.Customer, :email, :email_index)
iex> declaration.normalize
:email

iex> Encryptor.Ecto.BlindIndex.Declaration.fetch(
...>   Encryptor.Ecto.TestSchemas.Customer, :email, :nowhere)
:error

fetch!(schema, source, column)

@spec fetch!(module(), atom(), atom()) :: t()

The same, raising with what is declared when nothing matches.

The message lists the schema's declarations because the overwhelmingly likely cause is a helper naming a column that is spelled differently at the declaration, and a reader who can see both spellings at once is done.

field_params(declaration)

@spec field_params(t()) ::
  {:ok, Encryptor.Ecto.BlindIndex.Derivation.field_params()}
  | {:error, resolution_error()}

The encrypted field's frozen parameters, read from the schema.

Returns {:error, :missing_field} when the source names no field and {:error, :not_encrypted} when it names one this package does not encrypt. Both are the compile-time check's material; field_params!/1 is the arm for a caller that has already been through that check.

field_params!(declaration)

The same, raising for a caller past the compile-time check.

iex> Encryptor.Ecto.BlindIndex.Declaration.fetch!(
...>   Encryptor.Ecto.TestSchemas.Customer, :email, :email_index)
...> |> Encryptor.Ecto.BlindIndex.Declaration.field_params!()
...> |> Map.take([:table, :column, :tenant])
%{table: "customers", column: "email", tenant: :scope}

list(schema)

@spec list(module()) :: [t()]

Every index declared on a schema, in declaration order.

A module that declares none - or is not a schema at all - has no indexes rather than an error, so a caller sweeping a host's modules does not have to ask twice.

iex> Encryptor.Ecto.BlindIndex.Declaration.list(Encryptor.Ecto.TestSchemas.Customer)
...> |> Enum.map(& &1.column)
[:email_index, :email_short_index, :phone_index]

iex> Encryptor.Ecto.BlindIndex.Declaration.list(Encryptor.Ecto.TestSchemas.Card)
[]

list(schema, source)

@spec list(module(), atom()) :: [t()]

Every index declared over one source field.

iex> Encryptor.Ecto.BlindIndex.Declaration.list(
...>   Encryptor.Ecto.TestSchemas.Customer, :email)
...> |> Enum.map(& &1.name)
["email_index", "email_short_index"]

new!(schema, source, column, opts \\ [])

@spec new!(module(), term(), term(), term()) :: t()

Builds one declaration, checking its options where the host wrote them.

iex> Encryptor.Ecto.BlindIndex.Declaration.new!(
...>   MyApp.Customer, :email, :email_index, normalize: :email)
%Encryptor.Ecto.BlindIndex.Declaration{
  schema: MyApp.Customer,
  source: :email,
  column: :email_index,
  name: "email_index",
  scope: :tenant,
  scope_declared?: false,
  normalize: :email,
  bits: 256,
  slow: false,
  version: 1
}

:name defaults to the index column's name, not the source field's. Decision 6 tables the default as "the column name" and the record uses "column" for both, so the reading is chosen here: index_name exists to distinguish two indexes over one source column (decision 2), and a default taken from the source would make every index on one field derive the same key - which is the one thing the component is there to prevent. Two indexes over one field are therefore distinct by default, and a host that wants them to share nothing but the field writes nothing extra.

iex> Encryptor.Ecto.BlindIndex.Declaration.new!(
...>   MyApp.Customer, :email, :email_short, bits: 64).name
"email_short"

An unknown option is refused where it is written rather than ignored, and so is a value outside an option's set.

iex> Encryptor.Ecto.BlindIndex.Declaration.new!(
...>   MyApp.Customer, :email, :email_index, normalise: :email)
** (ArgumentError) MyApp.Customer declares blind_index :email, :email_index with unknown options: [:normalise]. Known options: [:name, :scope, :normalize, :bits, :slow, :version].

Every argument but the schema is typed as term/0 rather than as what it has to be. This is the boundary the macro hands a host's literal words across, and a spec that promised they were already an atom and a keyword list would make the checks below unreachable to a static analyser and, worse, imply they had been made somewhere else.

normalize!(declaration, value)

@spec normalize!(t(), binary()) :: binary()

Normalizes one value under this declaration's normalizer.

The declared table and column reach the failure from the encrypted field, so a host normalizer that misbehaves produces an exception naming the schema line rather than the call site.

iex> Encryptor.Ecto.BlindIndex.Declaration.fetch!(
...>   Encryptor.Ecto.TestSchemas.Customer, :email, :email_index)
...> |> Encryptor.Ecto.BlindIndex.Declaration.normalize!(" Bob@Example.COM ")
"bob@example.com"