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
+267 -11
View File
@@ -64,6 +64,7 @@ import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -73,6 +74,7 @@ LOG = logging.getLogger("granthi-link")
DEFAULT_CONFIG = "/opt/granthi-link/config.json"
DEFAULT_STATE = "/opt/granthi-link/state.json"
DEFAULT_AUDIT = "/opt/granthi-link/audit.jsonl"
MAX_BODY_BYTES = 64 * 1024
TEST_MODE_ENV = "GRANTHI_LINK_ALLOW_TEST_MODE"
@@ -84,7 +86,13 @@ LOGIN_SAFE = re.compile(r"[^a-zA-Z0-9._-]+")
# Rate limiting. /v1/link is the expensive endpoint -- it round-trips Zitadel,
# can CREATE a forge account and always mints a token -- so its default is
# deliberately tight. /v1/repos only spends the caller's own token.
DEFAULT_RATE_RULES = {"/v1/link": (5, 3600), "/v1/repos": (60, 3600)}
DEFAULT_RATE_RULES = {"/v1/link": (5, 3600), "/v1/repos": (60, 3600),
# Reads are cheap but still authenticated work.
"/v1/devices": (120, 3600),
"/v1/audit": (120, 3600),
# Revocation is a safety action: never throttle
# someone out of signing a lost laptop out.
"/v1/devices/revoke": (0, 3600)}
MAX_RATE_KEYS = 10000
@@ -322,6 +330,115 @@ class IdentityStore:
data["identities"][str(sub)] = record
self._write(data)
# -- device registry ---------------------------------------------------
# Devices hang off the identity that owns them, so "which computers can
# reach my files" has exactly one answer and it cannot drift from the
# identity map. Callers hold self.lock.
def record_device(self, sub, device_id, record):
data = self._load()
ident = data["identities"].get(str(sub))
if ident is None:
return False
devices = ident.setdefault("devices", {})
existing = devices.get(device_id, {})
# Re-linking the same device REPLACES its token name and clears any
# previous revocation: the user just proved identity again.
existing.update(record)
existing["revoked_at"] = None
devices[device_id] = existing
data["identities"][str(sub)] = ident
self._write(data)
return True
def devices_for_login(self, login):
"""(sub, devices) for a Gitea login, or (None, {}).
Keyed on login because the caller authenticates with a forge token,
which proves a login -- not a Zitadel sub.
"""
data = self._load()
for sub, ident in data["identities"].items():
if ident.get("login") == login:
return sub, dict(ident.get("devices") or {})
return None, {}
def mark_revoked(self, sub, device_id, when):
data = self._load()
ident = data["identities"].get(str(sub)) or {}
device = (ident.get("devices") or {}).get(device_id)
if device is None:
return False
device["revoked_at"] = when
self._write(data)
return True
# --------------------------------------------------------------------------
# Audit log: append-only JSONL, one line per security-relevant event
# --------------------------------------------------------------------------
class AuditLog:
"""Who linked a computer, from where, when, and what was refused.
Append-only and separate from the identity map on purpose: state.json is
rewritten atomically on every change, so anything kept only there is one
bad write away from gone. An audit trail that can be rewritten by the
thing it audits is not an audit trail.
NOTE what this can and cannot see. Every event here is one granthi-link
handled. Git pushes and pulls do NOT pass through this service -- they go
straight to the forge -- so they are recorded by Gitea, not here. Reading
this file and believing it lists file activity would be a real mistake.
"""
def __init__(self, path, keep_bytes=64 * 1024 * 1024):
self.path = path
self.keep_bytes = keep_bytes
self.lock = threading.Lock()
def write(self, event, **fields):
line = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"event": event}
line.update({k: v for k, v in fields.items() if v is not None})
try:
with self.lock:
self._rotate_if_needed()
fd = os.open(self.path,
os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600)
with os.fdopen(fd, "a") as f:
f.write(json.dumps(line, sort_keys=True) + "\n")
except OSError as e:
# Never let auditing break the request it is auditing, but say so
# loudly -- a silently dead audit log is worse than none.
LOG.error("AUDIT WRITE FAILED (%s): %s", e, line)
def _rotate_if_needed(self):
try:
if os.path.getsize(self.path) < self.keep_bytes:
return
except OSError:
return
os.replace(self.path, self.path + ".1")
def read_for(self, login, limit=200):
"""Events belonging to one login, newest first."""
out = []
for path in (self.path, self.path + ".1"):
try:
with open(path) as f:
for raw in f:
try:
rec = json.loads(raw)
except ValueError:
continue
if rec.get("login") == login:
out.append(rec)
except OSError:
continue
out.sort(key=lambda r: r.get("ts") or "", reverse=True)
return out[:limit]
# --------------------------------------------------------------------------
# Core logic (class so tests can instantiate with a stub config)
@@ -337,6 +454,7 @@ class LinkService:
self.userinfo_url = config.get(
"zitadel_userinfo", "https://id.shre.ai/oidc/v1/userinfo")
self.state = IdentityStore(config.get("state_path", DEFAULT_STATE))
self.audit = AuditLog(config.get("audit_path", DEFAULT_AUDIT))
# Rate limiting is ON by default: this endpoint creates accounts and
# mints tokens, so the safe default is limited, and disabling it has
@@ -501,12 +619,20 @@ class LinkService:
return f"gitea admin user create failed (HTTP {status}): {resp}"
return None
def mint_token(self, login, device_name):
def mint_token(self, login, device_name, device_id=None):
"""Admin basic auth + Sudo header. Verified on Gitea 1.27.1:
token-auth sudo (header or ?sudo=) -> 401; basic+Sudo -> 201."""
token-auth sudo (header or ?sudo=) -> 401; basic+Sudo -> 201.
The device id is part of the token NAME because revocation deletes by
name. With only device_name + a one-second timestamp, one user linking
two machines called "macbook" in the same second would collide -- and
a collision means signing out one laptop kills the other's access.
"""
safe_dev = LOGIN_SAFE.sub("-", device_name or "device")[:40]
token_name = "granthi-sync-{}-{}".format(
safe_dev, datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"))
suffix = LOGIN_SAFE.sub("-", str(device_id or ""))[:12]
token_name = "granthi-sync-{}-{}{}".format(
safe_dev, datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"),
f"-{suffix}" if suffix else "")
status, resp = http_json(
"POST", f"{self.gitea}/api/v1/users/{login}/tokens",
headers={
@@ -596,7 +722,7 @@ class LinkService:
"identity"}, None
# -- endpoints ---------------------------------------------------------
def link(self, body):
def link(self, body, client_ip=None):
device_name = body.get("device_name") or "device"
if self.test_mode_enabled() and isinstance(body.get("test_userinfo"), dict):
LOG.warning("TEST-MODE link request (stubbed userinfo)")
@@ -619,14 +745,133 @@ class LinkService:
return 500, {"error": "identity state unavailable"}
if login is None:
return status, err_resp
gitea_token, token_name, err = self.mint_token(login, device_name)
wanted_device = str(body.get("device_id") or "").strip() or None
gitea_token, token_name, err = self.mint_token(login, device_name,
wanted_device)
if err:
return 502, {"error": err}
LOG.info("minted token %s for %s (sub %s)", token_name, login, sub)
# A device id the client generated once and keeps. Clients older than
# v1.2 do not send one; fall back to the token name, which is unique
# per link, so every device still lands in the registry.
device_id = wanted_device or token_name
with self.state.lock:
self.state.record_device(sub, device_id, {
"name": device_name,
"token_name": token_name,
"linked_at": datetime.now(timezone.utc).isoformat(
timespec="seconds"),
})
self.audit.write("device.link", login=login, device_id=device_id,
device_name=device_name, token_name=token_name,
client_ip=client_ip)
return 200, {"gitea_base": self.public_gitea, "login": login,
"token": gitea_token, "token_name": token_name}
"token": gitea_token, "token_name": token_name,
"device_id": device_id}
def repos(self, body):
# -- device registry endpoints -----------------------------------------
def whoami(self, token):
"""The login a forge token belongs to, or None.
Authorisation for every device endpoint rests on this: the caller
proves who they are by holding a working token for that account, and
the forge is the one that decides. No login is ever taken from the
request body.
"""
if not token or not isinstance(token, str):
return None
status, resp = http_json(
"GET", f"{self.gitea}/api/v1/user",
headers={"Authorization": f"token {token}"})
if status == 200 and isinstance(resp, dict):
return resp.get("login")
return None
def devices(self, body, client_ip=None):
login = self.whoami(body.get("token"))
if not login:
return 401, {"error": "invalid or revoked token"}
with self.state.lock:
_, devices = self.state.devices_for_login(login)
out = []
for device_id, rec in sorted(
devices.items(), key=lambda kv: kv[1].get("linked_at") or ""):
out.append({"device_id": device_id,
"name": rec.get("name") or "device",
"linked_at": rec.get("linked_at"),
"token_name": rec.get("token_name"),
"revoked_at": rec.get("revoked_at")})
return 200, {"login": login, "devices": out}
def revoke_device(self, body, client_ip=None):
"""Sign one computer out. Immediate: the forge token is deleted, so
the next fetch or push from that machine fails at the server. There
is no 'stop syncing' flag for a client to honour or ignore."""
login = self.whoami(body.get("token"))
if not login:
return 401, {"error": "invalid or revoked token"}
device_id = str(body.get("device_id") or "").strip()
if not device_id:
return 400, {"error": "device_id required"}
with self.state.lock:
sub, devices = self.state.devices_for_login(login)
record = devices.get(device_id)
if sub is None or record is None:
# Do not confirm the existence of ids on other accounts.
self.audit.write("device.revoke.denied", login=login,
device_id=device_id,
client_ip=client_ip,
reason="not a device of this account")
return 404, {"error": "no such device on this account"}
token_name = record.get("token_name")
err = self.delete_forge_token(login, token_name)
if err:
self.audit.write("device.revoke.failed", login=login,
device_id=device_id, token_name=token_name,
client_ip=client_ip, reason=err)
# Do NOT mark it revoked: the token still works, and a
# registry claiming otherwise is worse than an honest error.
return 502, {"error": err}
when = datetime.now(timezone.utc).isoformat(timespec="seconds")
self.state.mark_revoked(sub, device_id, when)
self.audit.write("device.revoke", login=login, device_id=device_id,
token_name=token_name, client_ip=client_ip)
LOG.info("revoked device %s (%s) for %s", device_id, token_name, login)
return 200, {"revoked": device_id, "revoked_at": when}
def delete_forge_token(self, login, token_name):
"""Gitea refuses token-auth deletion of tokens (403); admin basic
auth + Sudo works (verified 204 on 1.27.2)."""
if not token_name:
return "device has no recorded token to revoke"
status, resp = http_json(
"DELETE",
f"{self.gitea}/api/v1/users/{login}/tokens/"
f"{urllib.parse.quote(token_name, safe='')}",
headers={"Authorization": _basic(self.cfg["admin_login"],
self.cfg["admin_password"]),
"Sudo": login})
if status in (204, 404):
# 404 = already gone. The caller wanted it dead; it is dead.
return None
return f"forge refused token deletion (HTTP {status}): {resp}"
def audit_read(self, body, client_ip=None):
login = self.whoami(body.get("token"))
if not login:
return 401, {"error": "invalid or revoked token"}
try:
limit = min(int(body.get("limit") or 100), 500)
except (TypeError, ValueError):
limit = 100
return 200, {"login": login,
"events": self.audit.read_for(login, limit=limit),
"note": "granthi-link events only; git pushes and pulls "
"go straight to the forge and are recorded "
"there, not here"}
def repos(self, body, client_ip=None):
token = body.get("token")
name = body.get("name")
if not token or not name:
@@ -719,10 +964,21 @@ class Handler(BaseHTTPRequestHandler):
return self._send(400, {"error": "invalid JSON body"})
if not isinstance(body, dict):
return self._send(400, {"error": "body must be a JSON object"})
# Recomputed rather than reused from the rate-limit block above:
# that block is skipped entirely when no limiter is configured, and
# an audit trail must not have holes because limiting was off.
peer = client_ip(self, self.service.trust_forwarded_for,
self.service.trusted_proxies)
if self.path == "/v1/link":
status, resp = self.service.link(body)
status, resp = self.service.link(body, peer)
elif self.path == "/v1/repos":
status, resp = self.service.repos(body)
status, resp = self.service.repos(body, peer)
elif self.path == "/v1/devices":
status, resp = self.service.devices(body, peer)
elif self.path == "/v1/devices/revoke":
status, resp = self.service.revoke_device(body, peer)
elif self.path == "/v1/audit":
status, resp = self.service.audit_read(body, peer)
else:
return self._send(404, {"error": "not found"})
self._send(status, resp)