Current section
Files
Jump to
Current section
Files
lib/data_layer/verifiers/validate_sensitive.ex
defmodule AshAge.DataLayer.Verifiers.ValidateSensitive do
@moduledoc """
Raises a `Spark.Error.DslError` at compile verification when the
`age do sensitive [...] end` classification cannot hold (Spark surfaces
verifier errors as compiler diagnostics — build-blocking under
`--warnings-as-errors`):
- **R1** — every listed name is a declared attribute. A typo would silently
protect nothing.
- **R2** — every sensitive attribute is binary-storage-typed
(`Ash.Type.storage_type == :binary`: app-side-encrypted bytes, `$age64$`
round-trip) or listed in `skip` (never written to the graph).
- **R3** — the multitenancy discriminator is not sensitive. It is a plaintext
selector by design: Ash core injects it as a plaintext filter/force-set,
and ash_age holds no key material to encrypt it.
- **R4** — an `edge` `properties` key naming a sensitive attribute requires
every same-named DECLARED action argument to be binary-storage-typed;
otherwise the classified datum flows onto edges as plaintext through a
same-named plaintext argument. (`AshAge.Changes.CreateEdge` enforces the
runtime half for undeclared/injected arguments.)
The verifier checks a TYPE SHAPE, not encryption: a `:binary` attribute
holding plaintext bytes passes. Encrypting is the host app's obligation —
ash_age cannot verify ciphertext.
"""
use Spark.Dsl.Verifier
alias AshAge.Type.Cast
alias Spark.Dsl.Verifier
alias Spark.Error.DslError
@impl true
def verify(dsl_state) do
case Verifier.get_option(dsl_state, [:age], :sensitive, []) do
[] -> :ok
sensitive -> do_verify(dsl_state, sensitive)
end
end
defp do_verify(dsl_state, sensitive) do
module = Verifier.get_persisted(dsl_state, :module)
skip = Verifier.get_option(dsl_state, [:age], :skip, [])
attributes = Verifier.get_entities(dsl_state, [:attributes])
attr_by_name = Map.new(attributes, &{&1.name, &1})
tenant_attr = Verifier.get_option(dsl_state, [:multitenancy], :attribute)
with :ok <- known_attributes(module, sensitive, attr_by_name),
:ok <- not_the_discriminator(module, sensitive, tenant_attr),
:ok <- encrypted_or_skipped(module, sensitive, attr_by_name, skip) do
edge_property_arguments(module, sensitive, dsl_state)
end
end
# R1
defp known_attributes(module, sensitive, attr_by_name) do
case Enum.reject(sensitive, &Map.has_key?(attr_by_name, &1)) do
[] ->
:ok
unknown ->
{:error,
DslError.exception(
module: module,
path: [:age, :sensitive],
message:
"#{inspect(unknown)} in `sensitive` is not a declared attribute. " <>
"A typo here would silently protect nothing, so it fails closed."
)}
end
end
# R3
defp not_the_discriminator(_module, _sensitive, nil), do: :ok
defp not_the_discriminator(module, sensitive, tenant_attr) do
if tenant_attr in sensitive do
{:error,
DslError.exception(
module: module,
path: [:age, :sensitive],
message:
"the multitenancy attribute #{inspect(tenant_attr)} cannot be `sensitive`: " <>
"it is a plaintext selector by design (Ash core injects it as a plaintext " <>
"filter and force-set; ash_age holds no key material to encrypt it)."
)}
else
:ok
end
end
# R2
defp encrypted_or_skipped(module, sensitive, attr_by_name, skip) do
offender =
Enum.find(sensitive, fn name ->
# safe: R1 (known_attributes) guaranteed membership before this runs
attr = Map.fetch!(attr_by_name, name)
name not in skip and not Cast.binary_storage?(attr.type, attr.constraints)
end)
case offender do
nil ->
:ok
name ->
{:error,
DslError.exception(
module: module,
path: [:age, :sensitive],
message:
"sensitive attribute #{inspect(name)} must be binary-storage-typed or listed " <>
"in `skip`. A sensitive attribute stored as plaintext defeats the " <>
"classification; store app-side-encrypted bytes in a :binary-typed " <>
"attribute, or skip it so it never reaches the graph."
)}
end
end
# R4
defp edge_property_arguments(module, sensitive, dsl_state) do
edge_keys =
dsl_state
|> Verifier.get_entities([:age])
|> Enum.filter(&match?(%AshAge.Edge{}, &1))
|> Enum.flat_map(& &1.properties)
|> Enum.filter(&(&1 in sensitive))
|> Enum.uniq()
if edge_keys == [] do
:ok
else
offending =
dsl_state
|> Verifier.get_entities([:actions])
|> Enum.find_value(&offending_argument(&1, edge_keys))
case offending do
nil ->
:ok
{action_name, arg_name} ->
{:error,
DslError.exception(
module: module,
path: [:age, :sensitive],
message:
"edge property #{inspect(arg_name)} names a sensitive attribute, so every " <>
"same-named action argument must be a binary-storage-typed declared " <>
"action argument — otherwise the classified datum reaches edges as " <>
"plaintext. Offending argument on action #{inspect(action_name)}; " <>
"retype it :binary or rename it."
)}
end
end
end
# `{action_name, arg_name}` for the first non-binary-storage declared argument
# whose name collides with a sensitive edge-property key; nil when the action
# is clean. Keeps the action name so the R4 error can say WHERE.
defp offending_argument(action, edge_keys) do
(Map.get(action, :arguments) || [])
|> Enum.find(fn arg ->
arg.name in edge_keys and not Cast.binary_storage?(arg.type, arg.constraints)
end)
|> case do
nil -> nil
arg -> {action.name, arg.name}
end
end
end