The route-management API: CRUD over route definitions, the global IP rules, and the dry run.
It listens on its own port (routes.admin.port), never on the ingest port:
managing routes is an operator action, and mixing it into the capture surface
would put a second, easily-forgotten endpoint next to the one that accepts
provider traffic.
Authentication and scopes
Every route but GET /health needs a credential (routes.admin.auth, see
Ankusa.Admin.Auth): 401 without one, 403 without the scope. Reading
(GET, and the dry run POST /admin/routes/test) needs routes:read; every
other write needs routes:write.
There are two surfaces over the same definitions. The operator surface,
/admin/routes and /admin/ip-rules, spans tenants, so its scopes only count
together with tenant:*: a principal limited to some tenants must not edit
what every tenant's ingest is checked against. The tenant surface,
/v1/tenants/:tenant/routes, needs tenant:<tenant> (or tenant:*) instead,
and shows and changes only that tenant's routes: one that another tenant owns
is 404, a body naming another tenant is 400, and a principal without the
tenant is 403. A tenant that is not [A-Za-z0-9_-]{1,64} is
400 invalid_tenant.
Responding
| Method | Path | Success |
|---|
| GET | /admin/routes?enabled=&limit=&cursor= | 200 {"routes": [...], "next_cursor": id | null} |
| POST | /admin/routes | 201 route |
| GET | /admin/routes/:id | 200 route |
| PUT | /admin/routes/:id | 200 route (idempotent; creates if absent) |
| PATCH | /admin/routes/:id | 200 route |
| DELETE | /admin/routes/:id | 204 |
| GET | /v1/tenants/:tenant/routes?enabled=&limit=&cursor= | 200 the page of that tenant's routes |
| POST | /v1/tenants/:tenant/routes | 201 route owned by the tenant |
| GET | /v1/tenants/:tenant/routes/:id | 200 route |
| PUT | /v1/tenants/:tenant/routes/:id | 200 route (idempotent; creates if absent) |
| PATCH | /v1/tenants/:tenant/routes/:id | 200 route |
| DELETE | /v1/tenants/:tenant/routes/:id | 204 |
| GET | /admin/ip-rules | 200 {"default": ..., "rules": [...]} |
| PUT | /admin/ip-rules | 200 the stored rules |
| POST | /admin/routes/test | 200 {"decision": ..., "reason": ..., "route_id": ..., "ip_rule": ...} |
| GET | /health | 200 {"status": "ok", "routes": n} |
Errors are JSON objects with a stable error code:
invalid_route (with field and message), duplicate_route (with
conflicting_id, left out on the tenant surface when another tenant holds the
id or the path), too_many_routes (with max_routes), invalid_query,
invalid_tenant, invalid_ip_rules, invalid_request, invalid_body,
not_found, and store_unavailable (a 503: the backing store could not be
reached, or a write lost every race against concurrent writers — retry).
Writes are checked against the table they were validated against: two requests
creating the same id, or two enabled routes for the same path and method,
cannot both succeed however they interleave. PUT /admin/ip-rules replaces
the whole rule set, so it requires both default and rules.
POST /admin/routes/test is the dry run: it answers "what would happen to
this request" without capturing anything and without touching the decision
cache, so an operator can validate a route change before it goes live.
Contract
priv/openapi/admin.v1.yaml is the HTTP contract for these endpoints (the
routes tag), and it is the source of truth:
test/ankusa/routes/router_openapi_test.exs builds each documented request
from the examples in it and asserts the response against the documented
schema and example, so this module answers what the document says or the suite
fails. Change one, change the other.
Summary
Functions
Callback implementation for Plug.call/2.
Callback implementation for Plug.init/1.