headscale: acl policy editor, node owner and tags

policy: /_officer/policy GET/PUT. the text goes up byte for byte and
headscale's verdict comes back verbatim — it owns the only parser that
resolves groups, tags and hosts, so a second one here would disagree with
what actually enforces. a file-backed policy is still served over GET and
only refuses on PUT, so writability cannot be read: the first save finds
out, and a refusal becomes a persistent read-only banner rather than a
rejection the owner would go hunting for a syntax error over.

nodes: move between users, and a tag editor for the setTags route that had
no ui. both sit together in the expanded card because both decide which
policy rules apply to a node, and a move says so before it happens.
This commit is contained in:
2026-08-05 16:48:27 +00:00
parent d6d405100c
commit e8b19229cb
12 changed files with 550 additions and 16 deletions
+14 -12
View File
@@ -100,22 +100,24 @@ it exists in the reference app, so none of it was in scope for parity.
## Headscale
Gaps against headscale's own API, found while reading the app on 2026-08-05. None is urgent; the
first is the only one you would otherwise SSH in to do.
Gaps against headscale's own API, found while reading the app on 2026-08-05 — all closed the same
day. Kept here for what the policy work turned up, which is not obvious from the code.
- [ ] **ACL policy is not wired at all.** `/api/v1/policy` (GET/PUT) has no sidecar route and no
screen. Editing the policy means SSHing to the host, which is the one thing this app exists to
avoid. Wants a text editor with the server's own validation error surfaced on save, not a
form — the policy is HuJSON and headscale is the authority on whether it parses.
- [x] **ACL policy.** `/api/v1/policy` GET/PUT behind `/_officer/policy`, with a plain HuJSON textarea
that sends the text byte for byte and shows headscale's verdict verbatim (line and column
included). Officer does not pre-validate: headscale owns the only parser that resolves groups,
tags and hosts, and a second weaker one would disagree with the thing that actually enforces.
**The mode cannot be read.** A file-backed policy is still served over GET; only a PUT refuses,
with "update is disabled for modes other than 'database'". So the first save is what discovers
writability, and a refusal becomes a persistent read-only banner. Verified live, not inferred.
- [ ] **A node cannot be moved between users.** `/api/v1/node/{id}/user` is missing, so a node can be
renamed, tagged, route-approved and expired, but not re-owned.
- [x] **A node can be moved between users.** `/api/v1/node/{id}/user`, in the expanded node card next
to the tag editor — both being the things that decide which policy rules apply to a node.
- [ ] **Users are create/delete/list only.** No rename (`/api/v1/user/{id}/rename/{newName}`).
- [x] **User rename** — was already shipped end to end (`/users/:id/rename`, `UsersView`); this entry
was stale when it was written.
- [ ] **Nothing refreshes.** No query in `useHeadscaleData.ts` polls, so a node going offline (or
coming back) only appears on a manual reload. A modest `refetchInterval` on the nodes list is
probably the whole fix.
- [x] **Polling**`useHeadscaleNodes` has had `refetchInterval: 20_000` all along; also stale.
## Known bugs