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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user