Map verified requests to Ash actors and tenants

Copy Markdown View Source

What you will build: An Ash resource with tenant isolation and a reader policy, plus a scope that maps a verified Web Bot Auth key to the actor and tenant for a read. Install Ash (~> 3.34) and RequestSeal; all keys and request values are defined below. This example uses the public Ash scope contract and Ash policies.

Verify the caller

Generate an example Ed25519 key. In your application, reuse a long-lived key handle and configure trust in its public key independently.

{_public_bytes, seed} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
{:ok, key} = RequestSeal.Custody.public_key(handle)
{:ok, thumbprint} = RequestSeal.PublicKey.thumbprint(key)
signer = fn "ed25519", bytes -> RequestSeal.Custody.sign(handle, bytes) end

Build the request once, retaining its exact JSON bytes and digest. This held-key policy leaves body integrity unchecked:

{:ok, message} =
  RequestSeal.Message.request(
    "POST",
    "https://api.example.com/webhooks",
    [],
    ~s({"event":"created"}),
    digest: ["sha-256"]
  )

Sign the request as an agent:

now = System.system_time(:second)

{:ok, agent_request} =
  RequestSeal.WebBotAuth.sign(
    message,
    %{
      label: "agent",
      agent: %{location: "https://agent.example", type: :directory},
      key: key,
      algorithm: "ed25519",
      created: now,
      expires: now + 60,
      nonce: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
    },
    signer
  )

Trust the held key by its thumbprint, then verify the agent request:

{:ok, agent_policy} =
  RequestSeal.WebBotAuth.Policy.new(%{
    algorithms: ["ed25519"],
    agents: fn _ -> :error end,
    cache: nil,
    unresolved:
      {:held_keys,
       fn
         %{keyid: ^thumbprint} -> {:ok, %{algorithm: "ed25519", key: key}}
         _ -> :error
       end},
    freshness: %{clock: fn -> System.system_time(:second) end, max_age: 60, skew: 5},
    content: :not_required,
    replay: :not_required
  })

{:ok, envelope} = RequestSeal.WebBotAuth.verify(agent_request, agent_policy)
verification = envelope.signatures["agent"]

1. Define a resource with a read policy

defmodule AshApp.Document do
  use Ash.Resource,
    domain: AshApp.Documents,
    data_layer: Ash.DataLayer.Ets,
    authorizers: [Ash.Policy.Authorizer]

  ets do
    private?(false)
  end

  multitenancy do
    strategy(:attribute)
    attribute(:tenant_id)
  end

  attributes do
    uuid_primary_key(:id)
    attribute(:tenant_id, :string, allow_nil?: false)
  end

  actions do
    defaults([:read])
  end

  policies do
    policy action_type(:read) do
      authorize_if(actor_attribute_equals(:role, :reader))
    end
  end
end

Register the resource in an Ash domain:

defmodule AshApp.Documents do
  use Ash.Domain, validate_config_inclusion?: false

  resources do
    resource(AshApp.Document)
  end
end

This ETS resource stores documents by tenant and permits reads by a reader actor. Replace it with your resource and your access rules.

2. Map the trusted principal and run an authorized read

{:ok, scope} =
  RequestSeal.Ash.scope(verification, %{
    actor: fn
      %{kind: :key, thumbprint: ^thumbprint} -> {:ok, %{role: :reader}}
      _ -> :error
    end,
    tenant: {:value, "demo"},
    unattributed: :reject
  })

documents = Ash.read!(AshApp.Document, scope: scope, authorize?: true)

A held-key Web Bot Auth result binds only the trusted thumbprint; URL attribution requires trusted discovery. Never construct or modify a verification struct to manufacture attribution. Generic RFC 9421 returns :unattributed; unattributed: :anonymous creates a nil actor without invoking your actor callback, while :reject rejects it. Tenant choices are :none, {:value, tenant}, or a function of an attributed principal. The explicit tenant here is application configuration.

Options and policy boundaries

The scope always requests authorize?: true. RequestSeal.Ash.Scope.to_opts/1 produces actor, tenant, context, and authorization options without requiring Ash; when Ash is compiled in, scope: scope uses its protocol. If you add Ash after compiling RequestSeal, run mix deps.compile request_seal --force.

Ash's explicit actor, tenant, and authorization options override scope values; do not pass authorize?: false on an authenticated action. Context contains only bounded verification facts, excluding principal values, parameters, key IDs, bytes, keys, and receipt contents. A replay receipt becomes replay: :claimed.

Tenant filters constrain reads, not records already loaded under another tenant. For update/destroy actions, enforce the original record's tenant against the action tenant in your policies, and pass the intended tenant: explicitly or use Scope.to_opts/1. A query filter alone cannot guard a loaded record mutation. Map principals to minimal non-secret actors: Ash Forbidden inspection and enabled policy-breakdown logs can expose your actor.

Errors

ReasonMeaning and action
:unattributedSelect anonymous access explicitly or use trusted identity attribution.
:actor_unboundYour actor mapping rejected or returned a malformed/nil actor.
:tenant_unboundYour tenant mapping failed.
:invalid_bindingProvide exactly actor, tenant, and unattributed choices.
:invalid_verificationPass a successful trusted core result with valid bounded facts.

These errors use the :binding layer and retain no callback text. Ash.Error.Forbidden is a separate authorization denial by your resource's policies.

Module docs: RequestSeal.Ash, RequestSeal.Ash.Scope, RequestSeal.Verification, RequestSeal.Error.