Current section

Files

Jump to
ash_age lib multitenancy.ex
Raw

lib/multitenancy.ex

defmodule AshAge.Multitenancy do
@moduledoc """
Resolves the AGE graph name for a `:context`-multitenant resource + tenant.
The resolved name is a Postgres schema (AGE graph), so it must be a valid AGE
identifier, at most 63 bytes, injective (distinct tenants map to distinct
graphs — a hard isolation invariant), and deterministic.
Default two-branch encoder:
* **pass-through** — a tenant whose stringified form is identifier-body-clean
(`[A-Za-z0-9_]+`) becomes `"t_" <> tenant`; the `t_` prefix supplies the
required letter start, so a leading digit (ULID, integer id) is fine. Keeps
ULID/integer/slug tenants readable.
* **encode** — any other tenant (hyphens/exotic, e.g. a UUID) becomes
`"g" <> Base.encode32(tenant, :lower, no-padding)`; alphabet `[a-z2-7]` is
all valid, the `g` prefix guarantees a letter start, and a 36-byte UUID
string fits at 59 bytes.
Injectivity: branch A always starts `t`, branch B always starts `g`, so the two
namespaces are disjoint; each branch is a constant prefix over an injective
input. Anything that will not fit 63 bytes fails **closed** with a value-free
error, steering the host to the `tenant_graph` MFA override. Injectivity is over
the *stringified* tenant (integer `42` and string `"42"` collide) — harmless in
a homogeneous tenant space; the MFA is the escape hatch otherwise.
"""
alias AshAge.DataLayer.Info
@max_bytes 63
@identifier_body ~r/\A[A-Za-z0-9_]+\z/
@identifier_full ~r/\A[A-Za-z_][A-Za-z0-9_]*\z/
@doc """
Returns the AGE graph name for `resource` and `tenant`.
Uses the resource's `tenant_graph` MFA if configured, otherwise the default
two-branch encoder. Always returns a valid AGE identifier or raises a
value-free `ArgumentError` (fail-closed).
"""
@spec graph_name(Ash.Resource.t(), term()) :: String.t()
def graph_name(resource, tenant) do
case Info.tenant_graph(resource) do
nil -> default_encode(tenant)
{m, f, a} -> validate_mfa!(apply(m, f, [tenant | a]))
end
end
defp default_encode(tenant) do
str = to_string(tenant)
passthrough = "t_" <> str
cond do
str == "" ->
fail_closed!()
Regex.match?(@identifier_body, str) and byte_size(passthrough) <= @max_bytes ->
passthrough
# Only the "dirty"/overflow branch needs base32 — compute it lazily here so
# the passthrough hot path (ULID/integer/slug tenants) never pays for it.
true ->
encoded = "g" <> Base.encode32(str, case: :lower, padding: false)
if byte_size(encoded) <= @max_bytes, do: encoded, else: fail_closed!()
end
end
# The MFA output is a tenant-derived value; validate it here with a boolean
# check and raise a REDACTED error rather than routing it through
# `AshAge.Migration.validate_identifier!/1`, which echoes `inspect(name)`.
defp validate_mfa!(name) when is_binary(name) do
if Regex.match?(@identifier_full, name) and byte_size(name) <= @max_bytes do
name
else
raise ArgumentError,
"the tenant_graph MFA returned an invalid or too-long AGE identifier (value redacted)"
end
end
defp validate_mfa!(_) do
raise ArgumentError, "the tenant_graph MFA must return a String AGE identifier"
end
defp fail_closed! do
raise ArgumentError,
"could not derive a valid AGE graph identifier for the tenant within " <>
"#{@max_bytes} bytes (tenant value redacted); configure a `tenant_graph` " <>
"MFA for this tenant space"
end
end