feat(link): device registry, immediate sign-out, and an audit trail

Answers three questions that had no answer: which computers are connected,
how do I cut one off, and what is recorded.

- state.json gains a device registry hanging off the identity that owns it,
  so 'which computers can reach my files' cannot drift from the identity map.
  Clients older than v1.2 send no device_id and fall back to the token name,
  so they still register.
- POST /v1/devices lists them; POST /v1/devices/revoke deletes that device's
  forge token via admin basic auth + Sudo (verified 204 on 1.27.2, after
  which the token is 401 immediately). Revocation is deliberately NOT rate
  limited -- nobody should be throttled out of signing out a lost laptop.
- The device id is now part of the token NAME. Revocation deletes by name,
  so two machines called 'macbook' linked in the same second would otherwise
  collide and signing one out would kill the other.
- A failed forge deletion is not recorded as revoked: a registry claiming
  'revoked' while the token still works is worse than an honest error.
- Append-only JSONL audit log (0600, rotates at 64MB), separate from
  state.json because state is rewritten atomically on every change and an
  audit trail the audited thing can rewrite is not one. A failed audit write
  is logged loudly and never breaks the request.
- Authorisation everywhere: the forge decides who a token belongs to
  (GET /api/v1/user). No login is ever read from the request body.
- Client: devices / logout / activity.

The audit log records granthi-link events only -- git pushes and pulls never
pass through this service. /v1/audit returns that caveat in its own response
rather than letting the log read as file activity.

172 tests (was 153).
This commit is contained in:
claude
2026-08-23 12:16:37 -04:00
parent a9321590f5
commit 6b2d1a64c0
4 changed files with 680 additions and 17 deletions
+59 -1
View File
@@ -403,7 +403,7 @@ deleted it again, `DELETE …/tokens/{id}` returning 204 under basic auth):
## Tests
* `python3 -m unittest discover -s tests` — 153 tests. The v1.2 additions
* `python3 -m unittest discover -s tests` — 172 tests. The v1.2 additions
cover: a snapshot capturing uncommitted work while HEAD, the index and the
working tree stay byte-identical; snapshots landing outside `refs/heads`;
an unchanged tree not being re-pushed; a diverged folder still being backed
@@ -445,6 +445,64 @@ the provisioning service creates their forge account + scoped token on the
fly — the forge never sees a password and the user never sees the forge admin.
Every linked folder becomes a private repo under their account.
## Devices, sign-out, and the audit trail
**`granthi-sync devices`** lists every computer signed in to the account —
name, id, when it linked, and whether it is still active. The registry hangs
off the identity that owns it in `state.json`, so "which computers can reach
my files" has one answer that cannot drift from the identity map.
**`granthi-sync logout [--device ID]`** signs a computer out. Revocation
happens **at the forge**: granthi-link deletes that device's Gitea token
(admin basic auth + `Sudo`, the only mechanism Gitea 1.27 accepts — verified
204, after which the token returns 401 immediately). It is not a flag a
client could ignore, which is the only kind of sign-out worth having for a
laptop somebody lost. Signing out the current computer also deletes the local
token; linked folders are left on disk untouched.
Two properties worth keeping:
* The device id is part of the **token name**, because revocation deletes by
name. Without it, one user linking two machines called "macbook" in the
same second would collide, and signing one out would kill the other.
* A failed forge deletion is **not** recorded as revoked. A registry that
says "revoked" while the token still works is worse than an honest error.
**`granthi-sync activity`** shows the security events for the account:
`device.link`, `device.revoke`, `device.revoke.denied`, with timestamp,
device, and client IP. The log is append-only JSONL (0600, rotated at 64 MB),
kept **separate** from `state.json` on purpose — state is rewritten
atomically on every change, and an audit trail the audited thing can rewrite
is not an audit trail. A failed audit write is logged loudly and never breaks
the request it was auditing.
**What the audit log cannot see, and this matters.** Every event in it is one
granthi-link handled. **Git pushes and pulls do not pass through this
service** — they go straight to the forge — so:
| you want to know | where it actually lives |
|---|---|
| who linked/revoked a computer, from what IP | `granthi-sync activity` (this log) |
| who pushed what, and when | Gitea: the repo's activity feed and commit history |
| who *pulled* or cloned | **nowhere by default** — Gitea does not record fetches unless its router access log is enabled |
| last time a device used its token | Gitea `access_token.updated_unix` |
Reading the audit log and believing it lists file activity would be a real
mistake, so `/v1/audit` returns that caveat in its own response.
### Endpoints added
* `POST /v1/devices {token}` → the caller's devices.
* `POST /v1/devices/revoke {token, device_id}` → kills that device's forge
token. **Never rate-limited** — nobody should be throttled out of signing
a lost laptop out.
* `POST /v1/audit {token, limit}` → the caller's own events.
Authorisation on all three is the same: the caller proves who they are by
holding a working forge token, and **the forge decides** whose it is
(`GET /api/v1/user`). No login is ever read from the request body, so a body
claiming another account changes nothing.
## Next phase — invites and per-repo access (designed, not built)
Today `/v1/link` creates an account and every folder becomes a private repo