Skip to content

Fleet API auth gate + tailnet policy + locked installs (M7, cheap half) - #116

Open
MJohnson459 wants to merge 2 commits into
mainfrom
fleet-m7-auth-gate
Open

Fleet API auth gate + tailnet policy + locked installs (M7, cheap half)#116
MJohnson459 wants to merge 2 commits into
mainfrom
fleet-m7-auth-gate

Conversation

@MJohnson459

Copy link
Copy Markdown
Contributor

The cheap half of M7: the API's own credential, the network's own rules, and a
reproducible install. The broker half — per-robot and per-operator broker
credentials and the ACL keeping the three principals apart — is untouched and
still parked behind M6, so the broker remains anonymous and fleetctl watch and
the dashboard's read path still connect to it without one.

One gate, in front of a route table

Through M3 only dispatch and audit checked a token, which left the roster,
the basemaps, the zone vocabularies, the map registry and the broker's address
readable by anything that could reach the port.

fleet_server.ROUTES is now every path the server answers — method, path
template, handler, and the credential it costs — and _handle matches against
it, takes the credential, and only then calls the handler. Making the table the
thing that dispatches, rather than a description beside a chain of elifs, is
what the acceptance criterion needed: a route added later is authenticated by
default and has to opt out in the line that declares it; an anonymous caller is
refused before the table is consulted for existence, so a 404 can never say
which routes are real; and the test walks the table rather than a hand-kept list
that goes stale the first time a route is added (a route with a new path variable
fails it with a KeyError, which is the intended way to be told).

Three write routes keep answering their own refusal, declared audits_refusal
in the table — dispatch, promote and the zone edit record the attempt naming
what was tried
before they 401, and only the handler knows what that was. The
gate still resolves the token; what it hands them is operator=None.

What is open, and one carve-out the source branch never had to make

/healthz; the static UI (not in the table at all — what an unmatched GET falls
through to); POST /v1/enroll; and both halves of M4's map exchange, the
robot's upload and the robot's bundle.tar.gz pull.

The pull is the third carve-out, where the task named two. PR #75 predates M4, so
that route did not exist when its gate was written; gating it here means a fleet
whose maps never reach its robots (test_mapsync.py fails outright), and robots
have no credential to present until the broker half lands. It is scoped as its
own table entry, so the operator's three review leaves on the same path stay
gated.

The dashboard

/v1/config is operator-only like everything else, so there is no read-only mode
left: the page is signed in or asking to be. Boot became start() — no token
shows the gate, a token that stops working shows it again, and pasting one starts
everything with no reload. Two things fell out:

  • The basemap is fetched with the token and decoded from a blob, because
    <img src> carries no Authorization header and gating map.png otherwise
    blanks both canvases. (PR M7: security hardening (cross-cutting) #75 gated map.png and did not do this.)
  • The dispatch note stopped being overwritten. One line carried both what the
    selected capability does and what the last dispatch did, so any re-render
    replaced the outcome the operator had just read — a pre-existing race the
    slower blob fetch made reproducible. The summary is now written when the
    selection changes, keyed on robot and capability.

fleetctl robots and fleetctl sites needed the token too, for the same reason.

Tailnet policy, locked installs

mote_bringup/tailscale/policy.hujson is committed as source of truth and pasted
into the admin console (docs/fleet/README.md §1a). Its own tests block
asserts no robot can reach another robot, and Tailscale refuses to save a policy
that fails one. test_tailnet_roles.py adds what the console cannot: the file
still parses, and every tag install.sh can advertise is one the policy declares.

Provisioning runs pixi install --locked before pixi run build, pinned by a
test that also holds the ordering.

The two judgement calls

  • The [hidden] bug is already gone. style.css carries
    [hidden] { display: none !important } with its own note, landed in the
    dashboard rework. Nothing ported; recorded in CLAUDE.md.
  • docs/fleet/security.md is not carried over. Trimmed to what this lands it
    would be a document whose substance is "the API needs a token" (already
    contract in fleet-api.md §Authentication) and "the broker is anonymous". The
    posture is stated in fleet-api.md, control-plane.md §Security posture, and
    README.md §6 instead. It is worth writing when the broker half gives it three
    planes to describe.

Verification

  • mote_fleet/test: 324 passed, 0 skipped, with a real mosquitto on PATH —
    the two e2e suites and the outage test included. (test_e2e_map_registry.py's
    _get needed the harness's token; found by running it, not by reading it.)
  • pre-commit run --all-files: clean.
  • ui_check.py (real container broker, real fleet server, headless Chrome,
    fake_robots): 49/49, up from 47 — two new checks assert the page asks for
    a token when it has none and shows no fleet, and that pasting one signs it in
    without a reload. Roster, map, zones, dispatch, review, zone editing and
    promotion all pass with a token.

Not run: anything on real hardware, and the policy against a live tailnet — an
operator has to paste it into the console, where Tailscale evaluates the tests
block.

MJohnson459 and others added 2 commits September 2, 2026 16:14
…licy

Through M3 the fleet API had one credential on one path. Dispatch authorized an
operator token; the roster, the basemaps, the zone vocabularies, the map
registry and the broker's address were readable by anything that could reach the
port, and the argument for that was the tailnet. This closes the API half of M7
and leaves the broker half alone: the broker is still anonymous, and per-robot
and per-operator broker credentials wait on M6.

The gate is one check in front of routing, and what it checks against is a
table. `fleet_server.ROUTES` is now every path the server answers — method, path
template, handler and the credential it costs — and `_handle` matches, takes the
credential and only then calls the handler. That the table *dispatches*, rather
than describing a chain of `elif`s, is the point: a route added later is
authenticated by default and has to opt out in the line that declares it, an
anonymous caller is refused before the table is consulted for existence (a 404
would say which routes are real), and the acceptance is a test that walks the
table rather than a hand-kept list of routes that goes stale the first time one
is added. A route with a new path variable fails that test with a KeyError,
which is the intended way to be told.

Three write routes keep answering their own refusal, declared as
`audits_refusal` in the table: dispatch, promote and the zone edit record the
attempt naming what was tried before they 401, and only the handler knows what
that was. The gate still resolves the token; what it hands them is
`operator=None`.

What is open is open for a reason. `/healthz`, because a liveness probe that
needs a secret is one nobody wires up. The static UI, which is not in the table
at all but what an unmatched GET falls through to, because the page has to load
in order to ask for a token. Enrollment, which carries its own credential.
And both halves of M4's map exchange — the robot's upload and the robot's
`bundle.tar.gz` pull. The pull is a carve-out this milestone's own branch never
had to make, having been written before M4 existed: gating it means a fleet
whose maps never reach its robots, and robots have no credential to present
until the broker half lands.

The dashboard has two states now, signed in or asking to be, because
`/v1/config` is operator-only like everything else and there is no read-only
mode left to fall back to. Boot became `start()`: no token shows the gate, a
token that stops working shows it again, and pasting one starts everything with
no reload. Two things fell out. The basemap is fetched with the token and
decoded from a blob, because `<img src>` carries no Authorization header and
gating map.png otherwise blanks both canvases. And the dispatch note stopped
being overwritten — one line carried both what the selected capability does and
what the last dispatch did, so any re-render replaced the outcome the operator
had just read; the summary is now written when the selection changes, keyed on
robot and capability. `fleetctl robots` and `fleetctl sites` needed the token
too, for the same reason.

The network half is `mote_bringup/tailscale/policy.hujson`: the committed access
policy, pasted into the admin console, whose own `tests` block asserts that no
robot can reach another robot — Tailscale refuses to save a policy that fails
one, so the criterion is checked by the thing enforcing it. `test_tailnet_roles`
adds what the console cannot: that the file parses, and that every tag
`install.sh` can advertise is one the policy declares.

Provisioning runs `pixi install --locked` before the build, so a robot installs
the dependency set that was tested rather than solving a new one on a machine
nobody is watching.

The M3 `[hidden]` defect this milestone's branch also carried is already fixed
on main, so nothing was ported for it.

Verified: `mote_fleet/test` 324 passed with a real mosquitto on PATH (the two
e2e suites and the outage test included, none skipped); `pre-commit run
--all-files` clean; `ui_check.py` 49/49 against a real broker, server and
headless Chrome, two of those checks new — the page asks for a token when it has
none and shows no fleet, and pasting one signs it in without a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UcGpBYdT7QUHoy8hRH5EzZ
control-plane.md renamed the section to "Security posture (and what is
still owed)"; the README still linked the old M7 slug, which the strict
docs build rejects.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeNdP1SdpcegFhMaFiuHi4
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