Skip to content

fix(node): every /admin endpoint is behind the admin token, not just the ones that happened to be layered - #769

Merged
cryptskii merged 1 commit into
mainfrom
fix/admin-registry-endpoints-require-auth
Sep 5, 2026
Merged

fix(node): every /admin endpoint is behind the admin token, not just the ones that happened to be layered#769
cryptskii merged 1 commit into
mainfrom
fix/admin-registry-endpoints-require-auth

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

Item C of the locked remediation order — cut from main, not stacked on #768, and deliberately carrying none of the slot-register hardening.

The hole

POST /admin/registry/seed and POST /admin/registry/update were reachable by anyone who could reach the port. seed inserts a node into the registry; update runs a deterministic add/prune pass over it. Both mutate the node registry — the set a client resolves members from — and neither was authenticated.

Why it happened, and why the obvious fix is the wrong one

The discipline did not fail. The composition did. There were two sibling /admin routers nested at the same path, and only one carried the auth layer:

.nest("/admin", admin_router)           // cleanup, maintenance — from_fn(admin_auth)
.nest("/admin", registry_admin_router)  // seed, update — no layer

Adding a second from_fn(admin_auth) next door would fix these two endpoints and leave the same shape, and the same trap for the next admin endpoint someone adds.

So instead there is one admin surface. api::infra::admin::admin_surface owns every /admin route, applies the token check once, and supplies the AppState extension. scaling::admin_routes() contributes bare routes and deliberately carries no layers of its own — the doc comment says so and says why. main mounts one value. A new admin endpoint is authenticated because there is no longer a path by which it could not be.

The test

every_admin_route_refuses_an_unauthenticated_caller walks all four routes (cleanup, maintenance, registry/seed, registry/update) and asserts both directions:

  • no token → 401
  • wrong token → 401
  • right token → not 401, and specifically 400: the request reached its handler and was rejected on its own input.

The positive half is the part that matters. A layer that refuses everyone would satisfy the negative assertions and prove nothing. Each of these four handlers validates its input before touching the database, so the test needs no server on either backend.

Mutation controls

Each restored, with git diff | shasum verified identical before and after.

mutation result
registry routes merged after the auth layer — the exact historical defect /admin/registry/seed answered 400, having reached the handler unauthenticated, while /admin/cleanup still refused. The test named the route.
token_matches always agrees route test red plus both token_matches unit tests
drop the auth layer outright does not compileadmin_auth and require_admin_token go dead under #![deny(warnings)]. A refusal, but not a test result, so the two behavioural mutations above are the controls that count.

Operational note

These two endpoints now require DSM_ADMIN_TOKEN on the node and x-dsm-admin-token on the request — exactly what /admin/cleanup and /admin/maintenance already required. A node with no token set fail-closes all four (the pre-existing DSM_INSECURE_ALLOW_NO_ADMIN_TOKEN=1 debug escape hatch is unchanged and untouched).

Nothing in this repository calls them: no script, no deploy recipe, no doc. If the fleet has been using registry/seed to bootstrap, the node needs DSM_ADMIN_TOKEN in its environment before that call will work again.

Verification

Rust 1.98.0 — the version rust-toolchain.toml declares.

  • root make lint — exit 0
  • workspace board — 3978 passed, 0 failed
  • node board (SQLite, --no-default-features --features local-dev,strict) — 277 passed, 0 failed
  • node board (default features / Postgres) — 239 passed, 0 failed — the new test executes on both backends
  • ci/production_safety_checks.sh (incl. TLA+), scripts/ci_scan.sh, ci/no_clock_and_no_json.sh, scripts/check_forbidden_symbols.sh — all exit 0

Scope

Only the /admin composition and its test. No slot-register, observation-taxonomy or Postgres-board changes — those are #768.

…the ones that happened to be layered

`POST /admin/registry/seed` and `POST /admin/registry/update` were reachable
by anyone who could reach the port. `seed` inserts a node into the registry;
`update` runs an add/prune pass over it. Both mutate the node registry — the
set a client resolves members from — and neither was authenticated.

The discipline did not fail; the composition did. There were two sibling
`/admin` routers nested at the same path, and only one of them carried
`from_fn(admin_auth)`:

    .nest("/admin", admin_router)           // cleanup, maintenance — layered
    .nest("/admin", registry_admin_router)  // seed, update — not layered

Fixing this by adding a second auth layer next door would leave the same
shape and the same trap for the next admin endpoint. Instead there is now ONE
admin surface: `api::infra::admin::admin_surface` owns every `/admin` route,
applies the token check once, and supplies the `AppState` extension.
`scaling::admin_routes` contributes bare routes and deliberately carries no
layers of its own, so an admin endpoint added to it is authenticated because
there is no longer a path by which it could not be. `main` mounts one value.

`every_admin_route_refuses_an_unauthenticated_caller` walks all four routes
and asserts both directions: no token and a wrong token are refused, and the
right token ADMITS — each request then reaches its handler and is rejected on
its own input (400), because a layer that refuses everyone would prove
nothing about one that refuses strangers.

Mutation controls, each restored with a verified tree stamp:
- registry routes merged AFTER the auth layer instead of before — the exact
  historical defect: `/admin/registry/seed` answered 400, having reached the
  handler unauthenticated, while `/admin/cleanup` still refused. The test
  named the route.
- `token_matches` always agreeing — the route test and both token unit tests
  red.
- dropping the auth layer outright does not compile (`admin_auth` and
  `require_admin_token` become dead under `#![deny(warnings)]`), which is a
  refusal but not a test result, so the two behavioural mutations above are
  the controls that matter.

OPERATIONAL: these two endpoints now require `DSM_ADMIN_TOKEN` on the node
and `x-dsm-admin-token` on the request, exactly as `/admin/cleanup` and
`/admin/maintenance` already did. A node with no token set refuses all four.
Nothing in this repository calls them — no script, no deploy recipe, no doc.

Verification (Rust 1.98.0, the version rust-toolchain.toml declares):
- root `make lint` exit 0
- workspace board 3978 passed, 0 failed
- node board (local-dev,strict / SQLite) 277 passed, 0 failed
- node board (default features / Postgres) 239 passed, 0 failed — the new
  test executes on both backends
- ci/production_safety_checks.sh, scripts/ci_scan.sh,
  ci/no_clock_and_no_json.sh, scripts/check_forbidden_symbols.sh all exit 0
@cryptskii
cryptskii merged commit e3e8d14 into main Sep 5, 2026
15 checks passed
@cryptskii
cryptskii deleted the fix/admin-registry-endpoints-require-auth branch September 5, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant