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
+101
View File
@@ -632,6 +632,10 @@ def cmd_link(args):
"device_id": dev})
if status != 200:
raise SystemExit(f"link failed (HTTP {status}): {resp}")
if resp.get("device_id"):
# The service is authoritative for the id it filed the device under
# (an older client that sent none gets one back).
cfg["device_id"] = resp["device_id"]
cfg.update({"server": args.server.rstrip("/"),
"gitea_base": resp["gitea_base"],
"login": resp["login"],
@@ -1137,6 +1141,88 @@ def cmd_watch(args):
return 0
def cmd_devices(args):
"""Every computer signed in to this account."""
cfg = require_linked(load_config())
status, resp = http_json("POST", f"{cfg['server']}/v1/devices",
body={"token": cfg["token"]})
if status == 401:
raise SystemExit("this device's access has been revoked -- "
"run: granthi-sync link")
if status != 200:
raise SystemExit(f"could not list devices (HTTP {status}): {resp}")
devices = resp.get("devices") or []
if not devices:
print("no devices recorded for this account")
return 0
this = cfg.get("device_id")
rows = [("DEVICE", "ID", "LINKED", "STATUS")]
for d in devices:
rows.append((d.get("name") or "?", (d.get("device_id") or "")[:12],
(d.get("linked_at") or "")[:19],
"REVOKED" if d.get("revoked_at") else
("active (this computer)" if d.get("device_id") == this
else "active")))
widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
for r in rows:
print(" ".join(c.ljust(w) for c, w in zip(r, widths)))
return 0
def cmd_logout(args):
"""Sign a computer out. Revocation happens at the FORGE -- the token is
deleted, so that machine's next fetch or push fails at the server. It is
not a flag a client could ignore, which is the only kind of logout worth
having for a lost laptop."""
cfg = require_linked(load_config())
target = args.device or cfg.get("device_id")
if not target:
raise SystemExit("no device id recorded; pass --device (see: "
"granthi-sync devices)")
is_self = target == cfg.get("device_id")
status, resp = http_json("POST", f"{cfg['server']}/v1/devices/revoke",
body={"token": cfg["token"], "device_id": target})
if status == 404:
raise SystemExit(f"no device {target} on this account "
f"(see: granthi-sync devices)")
if status != 200:
raise SystemExit(f"revoke failed (HTTP {status}): {resp}")
log(f"revoked {target} at the forge ({resp.get('revoked_at')})")
if is_self:
# Drop the local token too. The forge already refuses it, but leaving
# a dead secret on disk is pointless risk -- and `status` should say
# "not linked" rather than pretend.
for key in ("token", "token_name"):
cfg.pop(key, None)
cfg["logged_out_at"] = datetime.now(timezone.utc).isoformat(
timespec="seconds")
save_config(cfg)
log("local token deleted; syncing stops at the next pass. "
"Linked folders are left on disk untouched.")
log("run `granthi-sync link` to sign back in")
return 0
def cmd_activity(args):
"""Security events for this account, newest first."""
cfg = require_linked(load_config())
status, resp = http_json("POST", f"{cfg['server']}/v1/audit",
body={"token": cfg["token"],
"limit": args.limit})
if status != 200:
raise SystemExit(f"could not read activity (HTTP {status}): {resp}")
events = resp.get("events") or []
if not events:
print("no recorded events for this account")
for e in events:
extra = " ".join(f"{k}={v}" for k, v in sorted(e.items())
if k not in ("ts", "event", "login"))
print(f"{e.get('ts')} {e.get('event'):<22} {extra}")
if resp.get("note"):
print(f"\nnote: {resp['note']}")
return 0
def _folder_meta(cfg, folder):
path = os.path.abspath(folder)
meta = cfg.get("folders", {}).get(path)
@@ -1328,6 +1414,21 @@ def main(argv=None):
sp.add_argument("--once", action="store_true", help="single pass then exit")
sp.set_defaults(fn=cmd_watch)
sp = sub.add_parser("devices",
help="list the computers signed in to this account")
sp.set_defaults(fn=cmd_devices)
sp = sub.add_parser("logout",
help="sign a computer out (revokes it at the forge)")
sp.add_argument("--device",
help="device id to revoke (default: this computer)")
sp.set_defaults(fn=cmd_logout)
sp = sub.add_parser("activity",
help="security events for this account")
sp.add_argument("--limit", type=int, default=50)
sp.set_defaults(fn=cmd_activity)
sp = sub.add_parser("status", help="show linked folders")
sp.set_defaults(fn=cmd_status)