Ankusa.Routes (ankusa v0.7.0)

Copy Markdown View Source

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

  1. Global IP rules. Cheap bitmask scans, no route lookup, so a denied sender costs nothing else.
  2. Route matching, via Ankusa.Routes.Cache (keyed by the snapshot's epoch) and, on a miss, the compiled pattern list.
  3. 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.

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

decision()

@type decision() :: {:ok, String.t()} | {:reject, :no_route | :method | :ip_denied}

reason()

@type reason() :: :matched | :no_route | :method | :ip_denied

Functions

authorize(instance, method, segments, ip)

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

authorize_path(instance, method, path_info, request_path, ip)

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

capture(instance, method, path_info, request_path, ip)

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

create(instance, attrs, opts \\ [])

@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(instance, id, opts \\ [])

@spec delete(atom(), String.t(), keyword()) ::
  :ok | {:error, :not_found | :store_unavailable}

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.

dry_run(instance, request)

@spec dry_run(atom(), map()) ::
  {:ok, map()} | {:error, {:invalid, String.t(), String.t()}}

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.

enabled?(instance)

@spec enabled?(atom()) :: boolean()

Are routes enforced on this instance?

get(instance, id, opts \\ [])

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

ip_rules(instance)

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

list(instance, opts \\ [])

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

meta(instance)

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

put_ip_rules(instance, attrs, opts \\ [])

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

replace(instance, id, attrs, opts \\ [])

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

update(instance, id, patch, opts \\ [])

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

validate_config!(config)

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