Ankusa.Routes.Route (ankusa v0.7.0)

Copy Markdown View Source

One route definition: which requests the edge captures when route management is on.

A route is a capture gate that is owned by a tenant and may be bound to a source. It decides method and path matching and, optionally, which client addresses may use it; Ankusa.RouteResolver still maps the accepted request to a source_id and tenant_id, and the route then has to agree with that answer (Ankusa.Edge.Ingest.lookup/3):

  • tenant_id (default "default") — the owning tenant. A route owned by a tenant admits hooks only into that tenant: a request whose resolver names another tenant, or whose source belongs to another tenant, is a 404. The tenant "default" is the shared one (as it is for a source): such a route is not tied to a tenant. A route owned by a tenant, matched on a shared source with no tenant in the URL, files the hook under the route's tenant.
  • source_id (default nil) — the one source the route admits. A request the resolver maps to another source is a 404; nil leaves it to the resolver.

Path patterns

Segments are /-separated and one of three things:

  • a literal (hooks, stripe.com, 2024-01-01) — matched with ==;
  • a param (:tenant, :source_id) — exactly one segment, any value;
  • the wildcard *, legal only as the last segment — one or more remaining segments, so /hooks/shopify/* matches /hooks/shopify/a/b and not /hooks/shopify.

No regex: it invites ReDoS in a component that runs before any authentication, and neither the :tenant nor the * form needs it.

Validation

Every rule lives in from_attrs/2, so the admin API, the config seed, and a Redis-stored definition are all parsed by the same code and produce the same error tuples. {:invalid, field, message} names the offending field and, where the field is a list, the index.

Summary

Functions

Build a route from client or config attributes.

The inverse of to_json/1, for a definition read back from a store.

A UTC timestamp truncated to the second: the granularity these ids keep.

Parse a global rule list — the admin API's PUT /admin/ip-rules body and the Redis store's ip_rules value are both this shape — into the store form.

Parse an ordered rule list into ip_rule/0s, naming the index of the first bad rule.

The JSON view of one IP rule, for the admin API.

The JSON view of a whole global rule list (%{default: ..., rules: [...]}).

Coerce a map or keyword list into a string-keyed map. field names the value in error tuples: a non-keyword list is "must be a map" (the stricter ip_rules message), anything else non-map is "must be a map or keyword list".

The JSON view of a route: what the admin API returns and Redis stores.

The JSON a store persists: to_json/1 without the binding fields a route that has none (tenant_id: "default", source_id: nil) leaves out, so a node that predates them still reads every definition written before a tenant route exists (from_json/1 gives the omitted fields their defaults).

Types

ip_rule()

@type ip_rule() :: %{
  action: :allow | :deny,
  cidr: %CIDR{first: term(), hosts: term(), last: term(), mask: term()}
}

t()

@type t() :: %Ankusa.Routes.Route{
  enabled: boolean(),
  id: String.t(),
  inserted_at: DateTime.t(),
  ip_rules: [ip_rule()],
  metadata: map(),
  methods: [String.t()],
  path: String.t(),
  revision: pos_integer(),
  source_id: String.t() | nil,
  tenant_id: String.t(),
  updated_at: DateTime.t()
}

Functions

from_attrs(attrs, opts \\ [])

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

Build a route from client or config attributes.

opts[:id] wins over attrs["id"]: on PUT/PATCH the id in the URL is the one being addressed, and a body naming a different one must not silently move the route. An id is generated when neither is present.

Attrs are a map or keyword list with string or atom keys; inserted_at and updated_at are not settable, so an update cannot forge its own history.

from_json(json)

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

The inverse of to_json/1, for a definition read back from a store.

new_timestamp()

@spec new_timestamp() :: DateTime.t()

A UTC timestamp truncated to the second: the granularity these ids keep.

parse_ip_rules(attrs)

@spec parse_ip_rules(term()) ::
  {:ok, %{default: :allow | :deny, rules: [ip_rule()]}}
  | {:error, {:invalid, String.t(), String.t()}}

Parse a global rule list — the admin API's PUT /admin/ip-rules body and the Redis store's ip_rules value are both this shape — into the store form.

default and rules are both required. This replaces a security control wholesale, so nothing is filled in on the caller's behalf: an omitted default used to mean allow, which quietly turned a deny-by-default list into an open one.

parse_rules(rules)

@spec parse_rules(term()) :: {:ok, [ip_rule()]} | {:error, String.t()}

Parse an ordered rule list into ip_rule/0s, naming the index of the first bad rule.

Shared by a route's own ip_rules, config.routes.ip_rules.rules, and the admin API's PUT /admin/ip-rules body, so a rule that is accepted in one place is accepted in all three and the message is the same.

rule_json(map)

@spec rule_json(ip_rule()) :: map()

The JSON view of one IP rule, for the admin API.

rules_json(map)

@spec rules_json(%{default: :allow | :deny, rules: [ip_rule()]}) :: map()

The JSON view of a whole global rule list (%{default: ..., rules: [...]}).

The admin API's /admin/ip-rules body and the Redis store's ip_rules value are this shape, so a rule renders identically wherever it is read from.

stringify(value, field)

@spec stringify(term(), String.t()) ::
  {:ok, %{required(String.t()) => term()}}
  | {:error, {:invalid, String.t(), String.t()}}

Coerce a map or keyword list into a string-keyed map. field names the value in error tuples: a non-keyword list is "must be a map" (the stricter ip_rules message), anything else non-map is "must be a map or keyword list".

to_json(route)

@spec to_json(t()) :: map()

The JSON view of a route: what the admin API returns and Redis stores.

to_stored_json(route)

@spec to_stored_json(t()) :: map()

The JSON a store persists: to_json/1 without the binding fields a route that has none (tenant_id: "default", source_id: nil) leaves out, so a node that predates them still reads every definition written before a tenant route exists (from_json/1 gives the omitted fields their defaults).