Route management: the allowlist guard's decisions, the CRUD over route
definitions, and the boot validation for config.routes.
This is the only module the guard and the admin API call. It owns the rules the stores can't know about — whether two enabled routes collide, which reason a rejection carries, what the dry run reports — and leaves the stores to hold definitions and the cache to remember decisions.
Off by default, deny by default
enabled?: false is the historical behaviour: every POST is captured. With
routes on, a request is captured only if it passes the IP rules and matches an
enabled route; everything else is rejected before the store is touched, so a
rejected request never becomes a record, a dispatch, or a delivery.
Decision order
- Global IP rules. Cheap bitmask scans, no route lookup, so a denied sender costs nothing else.
- Route matching, via
Ankusa.Routes.Cache(keyed by the snapshot's epoch) and, on a miss, the compiled pattern list. - The route's own IP rules, if it declares any: they replace the global list for that route, and deny when none of them matches. A global deny has already won by then.
A route declares rules so a provider can be pinned to its published ranges
while a global ban list still applies to everything — see
docs/configuration.md.
Tenants
A route is owned by a tenant (Ankusa.Routes.Route) and its id is unique
across all of them. The write and read functions take a tenant: option for
a caller acting for one tenant (the tenant-scoped API): a route another tenant
owns is then {:error, :not_found} to it, an id or path that another tenant
holds is a conflict that does not name the holder ({:conflict, nil}), and
the tenant in the body, if any, must be that tenant. Without the option the
caller is the operator and sees every tenant's routes.
Summary
Functions
Decide a request against the IP rules and the route table; segments are
already-normalized path segments (Ankusa.Routes.Matcher.normalize/2).
authorize/4 for a raw request, normalizing the path first and reporting
whether route matching came from the cache — how the guard makes a cache hit
observable in telemetry.
authorize_path/5, answering with the matched route itself rather than its
id: the guard hands it to ingest, which holds the route's tenant and source
binding against what the resolver says (Ankusa.Edge.Ingest.lookup/3). The
route is the one the decision already read, so this costs nothing extra.
Create a route.
Delete a route definition. Audited like create/3. With tenant:, a route
that tenant does not own is {:error, :not_found}, and the store checks the
owner in the delete itself.
Explain what would happen to a request, without touching the decision cache and without capturing anything: the answer to "why was my webhook rejected".
Are routes enforced on this instance?
Fetch one route definition (tenant:: only if that tenant owns it).
The global IP rules, parsed.
List routes, id ascending, with cursor pagination.
The route table's :meta row (Ankusa.Routes.Snapshot: generation,
version, epoch, IP rules, trusted proxies, count), or nil when no store has
published one.
Replace the global IP rules.
Replace a route wholesale, addressed by id (PUT semantics, idempotent).
Partial update: enabled, methods, ip_rules, and metadata only.
Validate config.routes at boot, raising ArgumentError — the same contract
as Ankusa.ClaimCheck.validate_config!/1, and for the same reason: a
misconfigured guard rejects real traffic, so it must not boot quietly.
Types
@type decision() :: {:ok, String.t()} | {:reject, :no_route | :method | :ip_denied}
@type reason() :: :matched | :no_route | :method | :ip_denied
Functions
@spec authorize(atom(), String.t(), [String.t()], Ankusa.Net.ip()) :: decision()
Decide a request against the IP rules and the route table; segments are
already-normalized path segments (Ankusa.Routes.Matcher.normalize/2).
@spec authorize_path(atom(), String.t(), [String.t()], String.t(), Ankusa.Net.ip()) :: {decision(), boolean()}
authorize/4 for a raw request, normalizing the path first and reporting
whether route matching came from the cache — how the guard makes a cache hit
observable in telemetry.
@spec capture(atom(), String.t(), [String.t()], String.t(), Ankusa.Net.ip()) :: {{:ok, Ankusa.Routes.Route.t()} | {:reject, :no_route | :method | :ip_denied}, boolean()}
authorize_path/5, answering with the matched route itself rather than its
id: the guard hands it to ingest, which holds the route's tenant and source
binding against what the resolver says (Ankusa.Edge.Ingest.lookup/3). The
route is the one the decision already read, so this costs nothing extra.
@spec create(atom(), term(), keyword()) :: {:ok, Ankusa.Routes.Route.t()} | {:error, {:invalid, String.t(), String.t()}} | {:error, {:conflict, String.t() | nil}} | {:error, :too_many_routes | :store_unavailable}
Create a route.
The id must be free, and an enabled route must not share a path with another enabled route serving one of the same methods — the two would both match, and "which one wins" would be an implementation detail. Disabled routes are free to collide: they capture nothing.
Every write here is audited (Ankusa.Audit): the route store is pluggable (ETS,
or Redis shared by a fleet), so the audit row cannot share a batch with the
change. It is written first, pending, and finished with the outcome. Options:
audit:, the Ankusa.Audit.Context of the change.
Delete a route definition. Audited like create/3. With tenant:, a route
that tenant does not own is {:error, :not_found}, and the store checks the
owner in the delete itself.
Explain what would happen to a request, without touching the decision cache and without capturing anything: the answer to "why was my webhook rejected".
The request is %{"method" =>, "path" =>, "ip" =>}. The result names the
decision, the reason, the route that matched (if any), and the IP rule that
produced an IP decision, with the scope it came from.
Are routes enforced on this instance?
@spec get(atom(), String.t(), keyword()) :: {:ok, Ankusa.Routes.Route.t()} | {:error, :not_found}
Fetch one route definition (tenant:: only if that tenant owns it).
@spec ip_rules(atom()) :: %{ default: :allow | :deny, rules: [Ankusa.Routes.Route.ip_rule()] }
The global IP rules, parsed.
Reads the snapshot, falling back to the configured rules when no table is published (a store that has not loaded). The guard is rejecting every request in that state anyway — routes enabled with nothing loaded means nothing is allowed — but the read stays total rather than crashing the API.
@spec list(atom(), keyword()) :: {:ok, %{routes: [Ankusa.Routes.Route.t()], next_cursor: String.t() | nil}}
List routes, id ascending, with cursor pagination.
cursor is the last id of the previous page (ids are opaque slugs, so
"greater than" is a total order over them). limit is clamped to 1..200 and
next_cursor is only set when there is another page. tenant: lists one
tenant's routes, by walking that tenant's index rather than the table.
@spec meta(atom()) :: Ankusa.Routes.Snapshot.meta() | nil
The route table's :meta row (Ankusa.Routes.Snapshot: generation,
version, epoch, IP rules, trusted proxies, count), or nil when no store has
published one.
The guard treats nil as a rejection: routes are enabled, so a table that
isn't loaded means "nothing is allowed", not "everything is".
@spec put_ip_rules(atom(), term(), keyword()) :: {:ok, %{default: :allow | :deny, rules: [Ankusa.Routes.Route.ip_rule()]}} | {:error, {:invalid, String.t(), String.t()}} | {:error, :store_unavailable}
Replace the global IP rules.
The list is ordered, first match wins, and default decides the case where
nothing matched — the "obvious" default: :allow is only safe when the
surrounding network is already trusted. Audited like create/3.
@spec replace(atom(), String.t(), term(), keyword()) :: {:ok, Ankusa.Routes.Route.t()} | {:error, {:invalid, String.t(), String.t()}} | {:error, {:conflict, String.t() | nil}} | {:error, :too_many_routes | :store_unavailable}
Replace a route wholesale, addressed by id (PUT semantics, idempotent).
A PUT for an id that does not exist yet creates it, so the cap applies; one
for an existing id keeps its inserted_at. Audited like create/3.
@spec update(atom(), String.t(), term(), keyword()) :: {:ok, Ankusa.Routes.Route.t()} | {:error, {:invalid, String.t(), String.t()}} | {:error, {:conflict, String.t() | nil}} | {:error, :not_found | :too_many_routes | :store_unavailable}
Partial update: enabled, methods, ip_rules, and metadata only.
path, id, tenant_id and source_id are immutable here — moving a route
changes which requests it captures and which tenant and source it admits them
into, which is a PUT (a full, reviewable definition), not a patch. Audited
like create/3.
@spec validate_config!(Ankusa.Config.t()) :: :ok
Validate config.routes at boot, raising ArgumentError — the same contract
as Ankusa.ClaimCheck.validate_config!/1, and for the same reason: a
misconfigured guard rejects real traffic, so it must not boot quietly.
Configuration that is only meaningful when routes are on (the seed) is only
checked when enabled: true; the rest is always checked, so a typo in a
config that is about to be switched on is caught by the same run that
loads it.