Fleet API auth gate + tailnet policy + locked installs (M7, cheap half) - #116
Open
MJohnson459 wants to merge 2 commits into
Open
Fleet API auth gate + tailnet policy + locked installs (M7, cheap half)#116MJohnson459 wants to merge 2 commits into
MJohnson459 wants to merge 2 commits into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 watchandthe dashboard's read path still connect to it without one.
One gate, in front of a route table
Through M3 only
dispatchandauditchecked 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.ROUTESis now every path the server answers — method, pathtemplate, handler, and the credential it costs — and
_handlematches againstit, 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, iswhat 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_refusalin 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 fallsthrough to);
POST /v1/enroll; and both halves of M4's map exchange, therobot's upload and the robot's
bundle.tar.gzpull.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.pyfails outright), and robotshave 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/configis operator-only like everything else, so there is no read-only modeleft: the page is signed in or asking to be. Boot became
start()— no tokenshows the gate, a token that stops working shows it again, and pasting one starts
everything with no reload. Two things fell out:
<img src>carries noAuthorizationheader and gatingmap.pngotherwiseblanks both canvases. (PR M7: security hardening (cross-cutting) #75 gated
map.pngand did not do this.)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 robotsandfleetctl sitesneeded the token too, for the same reason.Tailnet policy, locked installs
mote_bringup/tailscale/policy.hujsonis committed as source of truth and pastedinto the admin console (
docs/fleet/README.md§1a). Its owntestsblockasserts no robot can reach another robot, and Tailscale refuses to save a policy
that fails one.
test_tailnet_roles.pyadds what the console cannot: the filestill parses, and every tag
install.shcan advertise is one the policy declares.Provisioning runs
pixi install --lockedbeforepixi run build, pinned by atest that also holds the ordering.
The two judgement calls
[hidden]bug is already gone.style.csscarries[hidden] { display: none !important }with its own note, landed in thedashboard rework. Nothing ported; recorded in
CLAUDE.md.docs/fleet/security.mdis not carried over. Trimmed to what this lands itwould be a document whose substance is "the API needs a token" (already
contract in
fleet-api.md§Authentication) and "the broker is anonymous". Theposture is stated in
fleet-api.md,control-plane.md§Security posture, andREADME.md§6 instead. It is worth writing when the broker half gives it threeplanes 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_getneeded 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 fora 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
testsblock.