Encryptor.Ecto.Tenant (Encryptor.Ecto v0.2.0)

Copy Markdown View Source

The current tenant, scoped to the calling process.

This is a thin, documented wrapper over the process dictionary - the same mechanism Logger.metadata/1 and Ecto.Repo's dynamic repo use, chosen for the same reason. Ecto.Type callbacks run in the caller's process and receive the value and the type params, never the parent struct, so a process-scoped store is the only channel that reaches them without threading a parameter through every intervening signature (ADR-0001 decision 5a).

The host sets the scope explicitly at the edge of a unit of work:

Encryptor.Ecto.Tenant.put("merchant_7f3")

Scope does not propagate

It does not, and this package does not pretend it does. A Task, a Task.Supervisor child, an Oban worker, a GenServer doing the write on someone else's behalf - each starts with an empty scope. The host propagates explicitly (ADR-0001 decision 5b):

tenant = Encryptor.Ecto.Tenant.fetch!()
Task.async(fn -> Encryptor.Ecto.Tenant.wrap(tenant, &settle_batch/0) end)

Making this visible is the point: an invisible propagation mechanism is one whose gaps are also invisible.

The boundaries a host is expected to wrap

Every place a unit of work starts, and every place one crosses into a process that did not inherit the scope. ADR-0001 decision 5b names the first four; the rest are the same rule applied to the places a host meets it:

  • a Plug pipeline, once the request's tenant is known
  • an Oban worker's perform/1
  • a Task or a Task.Supervisor child that a request spawns
  • a GenServer callback doing a write on someone else's behalf
  • a Phoenix.Channel join/3, and anything the channel process runs after
  • a test case's setup - Encryptor.Ecto.TenantScope ships for exactly this
  • a seed or a data-migration script that writes encrypted rows

The list is finite because the rule is: a process that did not run the put/1 or wrap/2 has no tenant, and a write from it raises rather than guessing (decision 5c).

Prefer wrap/2 in a pooled process

A process that is checked out, used, and returned - a Phoenix.Channel process, a pooled worker, an ExUnit test process running several cases - must not leak its scope to the next unit of work. wrap/2 restores whatever was in scope before it rather than clearing, so nesting one unit of work inside another is safe:

Encryptor.Ecto.Tenant.wrap("merchant_7f3", fn ->
  # ... one signup wizard variant's writes, scoped to this merchant
end)

put/1 without a matching clear/0 or wrap/2 is the failure mode this module names loudest, because its symptom appears in the next unit of work rather than in the one with the bug.

What this module does not do

It does not decide whether a field is tenant-scoped, and it does not raise Encryptor.Ecto.MissingTenantError. A field declares its strategy at its type module, and the raise for a dump or load with no tenant in scope belongs to the type's dump/3 and load/3, which know the table and column to name. This module only holds and hands back a string.

Summary

Functions

Removes the tenant from the calling process's scope.

Returns the tenant in scope, or raises when none is.

Returns the tenant in scope for the calling process.

Puts tenant in scope for the calling process.

Runs fun with tenant in scope, then restores the previous scope.

Functions

clear()

@spec clear() :: :ok

Removes the tenant from the calling process's scope.

Returns :ok, whether or not one was in scope. Clearing is for a process that owns its whole unit of work; a process that runs several should use wrap/2, which restores rather than clears.

iex> Encryptor.Ecto.Tenant.put("merchant_7f3")
iex> Encryptor.Ecto.Tenant.clear()
:ok
iex> Encryptor.Ecto.Tenant.get()
:error

fetch!()

@spec fetch!() :: String.t()

Returns the tenant in scope, or raises when none is.

This is the call a host makes when it is about to cross a process boundary and needs the value to carry across (ADR-0001 decision 5b).

The exception raised for an empty scope here is not a contract: it reports a host bug at the boundary, rather than the per-field failure the type modules raise, and it names no table or column because it knows none.

iex> Encryptor.Ecto.Tenant.put("merchant_7f3")
iex> Encryptor.Ecto.Tenant.fetch!()
"merchant_7f3"

get()

@spec get() :: {:ok, String.t()} | :error

Returns the tenant in scope for the calling process.

{:ok, tenant} when one is in scope, :error when none is - the shape of Map.fetch/2, and never a nil tenant, which would be indistinguishable from a tenant whose identifier is genuinely absent.

iex> Encryptor.Ecto.Tenant.clear()
iex> Encryptor.Ecto.Tenant.get()
:error

put(tenant)

@spec put(String.t()) :: :ok

Puts tenant in scope for the calling process.

Returns :ok. Any tenant already in scope is replaced; the previous value is not returned, because a caller that needs it should be using wrap/2.

iex> Encryptor.Ecto.Tenant.put("merchant_7f3")
:ok
iex> Encryptor.Ecto.Tenant.get()
{:ok, "merchant_7f3"}

wrap(tenant, fun)

@spec wrap(String.t(), (-> result)) :: result when result: var

Runs fun with tenant in scope, then restores the previous scope.

Returns whatever fun returns. The previous scope is restored on the way out whether fun returns or raises, and "the previous scope" includes no scope at all - a wrap/2 in a process that had no tenant leaves it with no tenant, not with a stale one.

This is what makes the call safe in a pooled process and safe to nest:

iex> Encryptor.Ecto.Tenant.put("merchant_7f3")
iex> Encryptor.Ecto.Tenant.wrap("merchant_a19", fn ->
...>   Encryptor.Ecto.Tenant.fetch!()
...> end)
"merchant_a19"
iex> Encryptor.Ecto.Tenant.fetch!()
"merchant_7f3"