Ankusa.Admin.Auth behaviour (ankusa v0.7.0)

Copy Markdown View Source

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-powerful Ankusa.Principal.operator/1. It is only accepted when the listener binds loopback: Ankusa.Config.new/1 refuses mode: :none on any other address unless allow_insecure: true says the deployment is protected by something else (a mesh, a proxy, a network policy). Even tls.verify: :verify_peer does not count: it keeps strangers off the port but does not say what a certificate may do; use mode: :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 a tls.verify: :verify_peer listener, mapped to a principal by subject. Requires that listener's tls block.

Further modes are modules implementing authenticate/2.

Responses

  • 401 {"error":"unauthenticated"} with www-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 names tenant:<id>.

Summary

Types

What an implementation of authenticate/2 is given.

Which control plane the plug guards.

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

opts()

@type opts() :: %{instance: atom(), section: section(), config: map()}

What an implementation of authenticate/2 is given.

section()

@type section() :: :admin | :routes_admin | :claim_check

Which control plane the plug guards.

Callbacks

authenticate(t, opts)

@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

auth_config(config, atom)

@spec auth_config(Ankusa.Config.t(), section()) :: map()

The auth block of the control plane section.

authorize(conn, scope, fun)

@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.

authorize_global(conn, scope, fun)

@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:*.

authorize_tenant(conn, scope, tenant, fun)

@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:*.

authorize_tenants(conn, scope, requested, fun)

@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:

  • requested set: the principal must hold tenant:<requested> (or tenant:*), else 403; fun gets [requested].
  • requested unset and the principal holds tenant:*: fun gets nil, meaning unfiltered.
  • requested unset and the principal holds some tenant:<id> scopes: fun gets exactly those ids.
  • a principal with no tenant at all is 403 naming tenant:*.

bearer(conn)

@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.

normalize!(auth, key, map, enabled?)

@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.

secure_compare(a, b)

@spec secure_compare(binary(), binary()) :: boolean()

Constant-time comparison of two equal-length binaries.