Authentication and authorization for the three control planes: the admin API
(Ankusa.Admin.Router), the route-management API (Ankusa.Routes.Router)
and the claim-check gateway (Ankusa.ClaimCheck.Router).
Each router mounts this module as a plug ahead of :match. The plug
authenticates the request, assigns the Ankusa.Principal to
conn.assigns.principal and halts with 401 when there is none; each route
then calls authorize/3, authorize_tenant/4 or authorize_global/3 for
the scope it needs, which answers 403 otherwise. GET /health and
GET /ready are exempt: probes carry no credentials.
Modes
Each listener has its own auth block (admin.auth, routes.admin.auth,
claim_check.auth):
%{
mode: :none | :token | :jwt | :mtls,
allow_insecure: false,
tokens: [%{id: "ci", sha256: "<64 hex>", scopes: ["dlq:read", "tenant:*"]}],
jwt: %{issuer: ..., audience: ..., jwks_url: ..., keys: [...], ...},
mtls: %{subject_map: %{"cn:ops-laptop" => ["dlq:read", "tenant:*"]}}
}:none(the default) authenticates nobody and hands every request the all-powerfulAnkusa.Principal.operator/1. It is only accepted when the listener binds loopback:Ankusa.Config.new/1refusesmode: :noneon any other address unlessallow_insecure: truesays the deployment is protected by something else (a mesh, a proxy, a network policy). Eventls.verify: :verify_peerdoes not count: it keeps strangers off the port but does not say what a certificate may do; usemode: :mtls.:token—Ankusa.Admin.Auth.Token: static bearer tokens, stored as SHA-256 hashes, each with its scopes. For development, CI and machine-to-machine scripts.:jwt—Ankusa.Admin.Auth.JWT: OIDC/OAuth2 access tokens signed by your identity provider, verified against its JWKS.:mtls—Ankusa.Admin.Auth.MTLS: the verified client certificate of atls.verify: :verify_peerlistener, mapped to a principal by subject. Requires that listener'stlsblock.
Further modes are modules implementing authenticate/2.
Responses
401{"error":"unauthenticated"}withwww-authenticate: Bearer realm="ankusa"when no credential, or a bad one, was presented. The response never says which.403{"error":"forbidden","scope":"dlq:read"}when the principal lacks the named scope. A tenant scope namestenant:<id>.
Summary
Callbacks
Identify the caller of conn: a principal, or {:error, :unauthenticated}
for a missing, malformed, expired or unknown credential. opts.config is the
listener's normalised auth block.
Functions
The auth block of the control plane section.
Run fun when the principal holds scope (or every scope in a list), else
answer 403.
authorize/3 for a resource that spans tenants (the DLQ, the quarantine pen,
replay jobs, route definitions): the principal must also hold tenant:*.
authorize/3 for a resource that belongs to one tenant: the principal must
also hold tenant:<tenant> or tenant:*.
authorize/3 for a resource that spans tenants but is filtered by tenant
(the DLQ, the quarantine pen, replay jobs, the rate-limit listing).
The bearer token of conn: exactly one Authorization: Bearer <token>
header, the scheme matched case-insensitively. A bare or oversized token, a
second header, or another scheme is :error.
Validate and normalise one auth block of a listener. key is its dotted
config name ("admin.auth") and listener the section it sits in (its ip
and tls are what mode: :none and mode: :mtls are checked against).
enabled? says whether this instance runs the listener: a listener that is
not started is not held to the loopback rule. Raises ArgumentError naming
the key.
Constant-time comparison of two equal-length binaries.
Types
What an implementation of authenticate/2 is given.
@type section() :: :admin | :routes_admin | :claim_check
Which control plane the plug guards.
Callbacks
@callback authenticate(Plug.Conn.t(), opts()) :: {:ok, Ankusa.Principal.t()} | {:error, :unauthenticated}
Identify the caller of conn: a principal, or {:error, :unauthenticated}
for a missing, malformed, expired or unknown credential. opts.config is the
listener's normalised auth block.
Functions
@spec auth_config(Ankusa.Config.t(), section()) :: map()
The auth block of the control plane section.
@spec authorize(Plug.Conn.t(), String.t() | [String.t()], (Plug.Conn.t() -> Plug.Conn.t())) :: Plug.Conn.t()
Run fun when the principal holds scope (or every scope in a list), else
answer 403.
@spec authorize_global(Plug.Conn.t(), String.t() | [String.t()], (Plug.Conn.t() -> Plug.Conn.t())) :: Plug.Conn.t()
authorize/3 for a resource that spans tenants (the DLQ, the quarantine pen,
replay jobs, route definitions): the principal must also hold tenant:*.
@spec authorize_tenant( Plug.Conn.t(), String.t() | [String.t()], term(), (Plug.Conn.t() -> Plug.Conn.t()) ) :: Plug.Conn.t()
authorize/3 for a resource that belongs to one tenant: the principal must
also hold tenant:<tenant> or tenant:*.
@spec authorize_tenants( Plug.Conn.t(), String.t() | [String.t()], String.t() | nil, (Plug.Conn.t(), [String.t()] | nil -> Plug.Conn.t()) ) :: Plug.Conn.t()
authorize/3 for a resource that spans tenants but is filtered by tenant
(the DLQ, the quarantine pen, replay jobs, the rate-limit listing).
requested is the tenant the request names (nil for none). fun receives
the conn and the tenants the rows must be limited to, forced server-side:
requestedset: the principal must holdtenant:<requested>(ortenant:*), else403;fungets[requested].requestedunset and the principal holdstenant:*:fungetsnil, meaning unfiltered.requestedunset and the principal holds sometenant:<id>scopes:fungets exactly those ids.- a principal with no tenant at all is
403namingtenant:*.
@spec bearer(Plug.Conn.t()) :: {:ok, binary()} | :error
The bearer token of conn: exactly one Authorization: Bearer <token>
header, the scheme matched case-insensitively. A bare or oversized token, a
second header, or another scheme is :error.
@spec normalize!( term(), String.t(), %{:ip => term(), :tls => map() | nil, optional(atom()) => term()}, boolean() ) :: map()
Validate and normalise one auth block of a listener. key is its dotted
config name ("admin.auth") and listener the section it sits in (its ip
and tls are what mode: :none and mode: :mtls are checked against).
enabled? says whether this instance runs the listener: a listener that is
not started is not held to the loopback rule. Raises ArgumentError naming
the key.
Constant-time comparison of two equal-length binaries.