From 4b5b2a36674571ba26827aae4cc52337f29be0b1 Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Fri, 4 Sep 2026 17:31:05 -0400 Subject: [PATCH] P3: API keys, bearer auth on the admin API, whoami, generated /api/docs A key is the person who minted it, narrowed to the scopes they chose. Only the sha256 is stored; the full key is returned once. Scopes must be a subset of the owner's capabilities at mint time and are enforced again at use time inside identity.can(), the one place that decides, so a key never outlives its owner's demotion and disabling a person disables their keys with no separate flag. A key cannot carry apikeys:own or the owner powers, so it cannot mint keys. Revoked rows stay; a foreign key id is 404, never 403. Bearer keys are honoured ONLY on /api/admin. The rest of the site reads sessions alone, so a scoped key never widens into a browser identity. X-Admin-Token remains break glass and, having no person, cannot own a key. /api/docs is generated from the router on every request: path, methods and docstring from the route objects, and the capability read out of each handler's own _auth() call so it cannot drift from the check. Gated on a new api:docs capability (leader and above). GET /api/admin/whoami answers who the API thinks you are and what you can do. Tests: smoke_identity 66 -> 92, smoke_admin 53 -> 58 (registry has a capability for every route, docs page renders every route). Driven end to end on a throwaway site with a DB copy: mint, whoami via key, scoped 200s and a 403 that names the narrowing, key-mints-key 403, garbage key 401, admin token on /keys 403, key on /account is not a session, revoke then 401, second revoke 409. --- app/admin_api.py | 174 +++++++++++++++++++++++++++++++++++++++- app/app.py | 1 + app/identity.py | 170 +++++++++++++++++++++++++++++++++++++++ tests/smoke_admin.py | 15 ++++ tests/smoke_identity.py | 47 +++++++++++ 5 files changed, 405 insertions(+), 2 deletions(-) diff --git a/app/admin_api.py b/app/admin_api.py index 135d155..47e1413 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -38,9 +38,13 @@ one CAPS dictionary - never by a role comparison here. """ import hmac +import html as _html +import inspect import os +import re from fastapi import APIRouter, Body, Header, HTTPException, Query, Request +from fastapi.responses import HTMLResponse import auth import identity @@ -50,6 +54,26 @@ ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip() router = APIRouter(prefix="/api/admin", tags=["admin"]) +# /api/docs sits beside /api/admin, not under it: it describes the admin API +# and is gated like it, but a docs URL under the admin prefix would be one +# more path the LAN-only NPM block has to reason about. +docs_router = APIRouter(prefix="/api", tags=["docs"]) + + +def _person(request): + """Who is calling: a session first, then an API key, then nobody. + + Keys are honoured ONLY here, on the admin API. The rest of the site + (documents, account) is for people at a screen and reads sessions alone, + so a scoped key never widens into a browser identity.""" + p = auth.current_person(request) + if p: + return p + h = request.headers.get("authorization", "") + if h.lower().startswith("bearer "): + return identity.api_key_person(h[7:].strip()) + return None + def _auth(request, token, capability, unit_id=None): """Authorise, and return a label naming who acted, for created_by. @@ -63,11 +87,13 @@ def _auth(request, token, capability, unit_id=None): for every unit, and the break-glass token reaches everything - it exists for when identity is broken, so it cannot depend on identity's scoping. """ - person = auth.current_person(request) + person = _person(request) if person: if not identity.can(person, capability, unit_id): raise HTTPException(403, "your account does not have %s" % capability - + (" for this unit" if unit_id else "")) + + (" for this unit" if unit_id else "") + + (" (key %s is scoped narrower)" % person["key_prefix"] + if person.get("key_scopes") is not None else "")) return person["email"] if not ADMIN_TOKEN: raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN") @@ -293,3 +319,147 @@ def put_setting(request: Request, key: str, payload: dict = Body(...), return identity.set_setting(key, payload.get("value"), actor=actor) except identity.IdentityError as e: raise HTTPException(e.status, e.detail) + + +# ---------------------------------------------------------------------------- +# API keys (P3). Own keys only - apikeys:own. A key cannot reach these routes +# (apikeys:own is unscopable), and the break-glass token has no person to +# own a key, so both fall out naturally rather than by special case. +# ---------------------------------------------------------------------------- + +def _key_owner(request, token): + _auth(request, token, "apikeys:own") + person = _person(request) + if not person: + raise HTTPException(403, "keys belong to a signed-in person; the admin token cannot own one") + return person + + +@router.get("/keys") +def list_keys(request: Request, x_admin_token: str = Header(None)): + """Your keys, newest first, with state (active / expired / revoked) and + the scopes you may put on a new one. Key secrets are never returned.""" + person = _key_owner(request, x_admin_token) + return {"keys": identity.list_api_keys(person["id"]), + "scopable": identity.scopable_caps(person), + "max_days": identity.KEY_MAX_DAYS} + + +@router.post("/keys", status_code=201) +def create_key(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)): + """Mint a key. Body: label, scopes (list), expires_days (optional, 1-365). + The response carries `key` ONCE; it is not stored and cannot be shown again. + Send it as `Authorization: Bearer gls73_...` to /api/admin routes.""" + person = _key_owner(request, x_admin_token) + try: + full, row = identity.mint_api_key(person, payload.get("label"), payload.get("scopes"), + payload.get("expires_days")) + except identity.IdentityError as e: + raise HTTPException(e.status, e.detail) + row["key"] = full + return row + + +@router.delete("/keys/{key_id}") +def revoke_key(request: Request, key_id: str, x_admin_token: str = Header(None)): + """Revoke one of your keys. The row stays. A key that is not yours is + 404, never 403 - an id is not confirmed to exist.""" + person = _key_owner(request, x_admin_token) + try: + row = identity.revoke_api_key(person, key_id) + except identity.IdentityError as e: + raise HTTPException(e.status, e.detail) + if not row: + raise HTTPException(404, "no such key") + return row + + +@router.get("/whoami") +def whoami(request: Request, x_admin_token: str = Header(None)): + """Who the API thinks you are and what you can do - the first thing to + call with a new key.""" + person = _person(request) + if person: + return {"email": person["email"], "global_role": person.get("global_role"), + "capabilities": person["capabilities"], + "via": ("key " + person["key_prefix"]) if person.get("key_scopes") is not None else "session"} + if not ADMIN_TOKEN: + raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN") + if x_admin_token and hmac.compare_digest(x_admin_token, ADMIN_TOKEN): + return {"email": None, "via": "admin-token", "capabilities": ["*"]} + raise HTTPException(401, "sign in, send a Bearer key, or a valid X-Admin-Token") + + +# ---------------------------------------------------------------------------- +# /api/docs - generated from this router, never hand-written. Path, methods +# and docstring come from FastAPI's route objects; the capability is read +# out of each handler's own _auth() call, so it cannot drift from the check. +# ---------------------------------------------------------------------------- + +_CAP_RE = re.compile(r"""_auth\([^)]*?["']([a-z_]+:[a-z_]+)["']""") + + +def route_capability(endpoint): + try: + src = inspect.getsource(endpoint) + except (OSError, TypeError): + return None + m = _CAP_RE.search(src) + if m: + return m.group(1) + if "_key_owner(" in src: + return "apikeys:own" + return None + + +def describe_routes(): + """The registry, as data. Also what the smoke test checks.""" + out = [] + for r in router.routes: + methods = sorted(m for m in getattr(r, "methods", []) or [] if m not in ("HEAD", "OPTIONS")) + if not methods: + continue + params = [p.name for p in inspect.signature(r.endpoint).parameters.values() + if p.name not in ("request", "x_admin_token", "payload")] + out.append({"path": r.path, "methods": methods, + "capability": route_capability(r.endpoint), + "params": params, + "doc": inspect.getdoc(r.endpoint) or ""}) + return sorted(out, key=lambda d: (d["path"], d["methods"])) + + +@docs_router.get("/docs", response_class=HTMLResponse) +def api_docs(request: Request, x_admin_token: str = Header(None)): + """This page. Leader and above.""" + _auth(request, x_admin_token, "api:docs") + person = _person(request) + e = _html.escape + rows = "".join( + "%s%s%s" + "%s%s" + % (e(" ".join(d["methods"])), e(d["path"]), e(d["capability"] or "-"), + e(", ".join(d["params"])) or "-", e(d["doc"]).replace("\n", "
")) + for d in describe_routes()) + mine = "" + if person: + mine = ("

You are %s%s with: %s.

" + % (e(person["email"]), + " via key %s" % e(person["key_prefix"]) if person.get("key_scopes") is not None else "", + e(", ".join(person["capabilities"])))) + body = ("" + "" + "greenlanescouts73.org admin API
" + "

greenlanescouts73.org admin API

" + "

Generated from the route registry on every request; there is no hand-written copy to drift.

" + "

Auth, tried in this order: the site session cookie; " + "Authorization: Bearer gls73_... (an API key, scoped to the capabilities its owner chose - " + "mint one at /leaders/keys); X-Admin-Token (break glass, " + "operators only). A signed-in caller without the capability gets 403; an anonymous one gets 401. " + "Bodies are JSON. Start with GET /api/admin/whoami.

%s" + "%s
MethodPathNeedsQuery / path paramsNotes
" + "
" % (mine, rows)) + return HTMLResponse(body) diff --git a/app/app.py b/app/app.py index 412fa89..f3bc79e 100644 --- a/app/app.py +++ b/app/app.py @@ -103,6 +103,7 @@ async def _meeting_words_middleware(request, call_next): # Creates the schema if absent and backfills the legacy leads.jsonl once. store.init(backfill_jsonl=LEADS) app.include_router(admin_api.router) +app.include_router(admin_api.docs_router) MB = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'] MF = ['January','February','March','April','May','June','July','August','September','October','November','December'] diff --git a/app/identity.py b/app/identity.py index 55eeb67..dacb61d 100644 --- a/app/identity.py +++ b/app/identity.py @@ -78,6 +78,7 @@ CAPS = { "calendar:write", "unit:write_own", "apikeys:own", + "api:docs", "email:draft", }, "admin": { @@ -91,6 +92,7 @@ CAPS = { "units:write", "settings:write", "apikeys:own", + "api:docs", "people:invite_leader", "people:invite_admin", "email:draft", @@ -191,6 +193,25 @@ CREATE TABLE IF NOT EXISTS settings ( updated_at TEXT NOT NULL, updated_by TEXT ); + +-- API keys (P3). One row per key; only the sha256 of the key is stored, and +-- the visible prefix exists so a person can tell their keys apart. Scopes are +-- a JSON list and are a SUBSET of the owner's capabilities, checked at mint +-- time here and again at use time in can(), so a key never outlives its +-- owner's demotion. Revoked rows stay. +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + person_id TEXT NOT NULL REFERENCES people(id), + label TEXT NOT NULL, + prefix TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + scopes TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT, + expires_at TEXT, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_api_keys_person ON api_keys(person_id, revoked_at); """ # Seeded at boot, idempotent by slug. Values lifted from the site constants in @@ -485,6 +506,12 @@ def can(person, capability, unit_id=None): """ if not person or person.get("disabled_at"): return False + # A person reached through an API key is that person narrowed to the + # key's scopes. Checked here, in the one place that decides, so a route + # cannot forget it and a demoted owner's key loses what the owner lost. + scopes = person.get("key_scopes") + if scopes is not None and capability not in scopes: + return False if person.get("global_role") and capability in CAPS.get(person["global_role"], set()): return True for m in person.get("memberships", []): @@ -909,3 +936,146 @@ def set_setting(key, value, actor=None): finally: con.close() return [s for s in all_settings() if s["key"] == key][0] + + +# --------------------------------------------------------------------------- +# API keys (P3) +# --------------------------------------------------------------------------- +# +# For scripts, not for people signing in every Tuesday. A key is the person +# who minted it, narrowed to the scopes they chose. It cannot hold more than +# they hold, it cannot mint further keys, and it dies with them: disabling a +# person disables their keys through can(), with no separate flag to forget. + +KEY_PREFIX = "gls73_" +KEY_PREFIX_SHOWN = len(KEY_PREFIX) + 6 # "gls73_ab12cd" - enough to tell keys apart +KEY_MAX_DAYS = 365 + +# Scopes a key may never carry, whatever the owner holds. Minting keys from a +# key is a loop; the rest are owner powers that belong to a person at a screen. +KEY_UNSCOPABLE = {"account:self", "apikeys:own", "api:docs", "people:manage", "secrets:rotate", + "people:invite_leader", "people:invite_admin"} + + +def scopable_caps(person): + """The scopes this person may put on a key: what they hold, minus the + ones a key may never carry.""" + return sorted(effective_caps(person) - KEY_UNSCOPABLE) + + +def _key_row(r): + d = dict(r) + d.pop("key_hash", None) + d["scopes"] = json.loads(d["scopes"]) if isinstance(d.get("scopes"), str) else (d.get("scopes") or []) + now = _now() + if d.get("revoked_at"): + d["state"] = "revoked" + elif d.get("expires_at") and d["expires_at"] <= now: + d["state"] = "expired" + else: + d["state"] = "active" + return d + + +def mint_api_key(person, label, scopes, expires_days=None): + """Create a key for `person`. Returns (full_key, row). The full key is + returned exactly once and never stored.""" + if not person or person.get("disabled_at"): + raise IdentityError(403, "keys belong to a signed-in, enabled person") + if person.get("key_scopes") is not None: + raise IdentityError(403, "a key cannot mint keys") + label = (label or "").strip() + if not label or len(label) > 60: + raise IdentityError(422, "label is required, up to 60 characters") + if not isinstance(scopes, (list, tuple, set)) or not scopes: + raise IdentityError(422, "scopes is required: a non-empty list") + scopes = sorted({str(s).strip() for s in scopes if str(s).strip()}) + allowed = set(scopable_caps(person)) + bad = [s for s in scopes if s not in allowed] + if bad: + raise IdentityError(422, "scopes not available to you or not allowed on a key: %s. " + "Choose from %s" % (", ".join(bad), ", ".join(sorted(allowed)))) + expires_at = None + if expires_days not in (None, ""): + try: + days = int(expires_days) + except (TypeError, ValueError): + raise IdentityError(422, "expires_days must be a whole number of days") + if not 1 <= days <= KEY_MAX_DAYS: + raise IdentityError(422, "expires_days must be 1 to %d" % KEY_MAX_DAYS) + expires_at = (datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(days=days)).isoformat(timespec="seconds") + full = KEY_PREFIX + secrets.token_urlsafe(32) + kid = str(uuid.uuid4()) + con = connect() + try: + con.execute( + "INSERT INTO api_keys (id, person_id, label, prefix, key_hash, scopes, created_at," + " last_used_at, expires_at, revoked_at) VALUES (?,?,?,?,?,?,?,NULL,?,NULL)", + (kid, person["id"], label, full[:KEY_PREFIX_SHOWN], _hash_token(full), + json.dumps(scopes), _now(), expires_at)) + log_event("apikey.minted", person_id=person["id"], actor_id=person["id"], + email=person.get("email"), detail="%s [%s] %s" % (label, ", ".join(scopes), kid), con=con) + con.commit() + r = con.execute("SELECT * FROM api_keys WHERE id=?", (kid,)).fetchone() + finally: + con.close() + return full, _key_row(r) + + +def list_api_keys(person_id): + con = connect() + try: + return [_key_row(r) for r in con.execute( + "SELECT * FROM api_keys WHERE person_id=? ORDER BY created_at DESC", (person_id,))] + finally: + con.close() + + +def revoke_api_key(person, key_id): + """Revoke one of the person's OWN keys. None if it is not theirs (the + caller answers 404, never 403 - a foreign key id is not confirmed).""" + con = connect() + try: + r = con.execute("SELECT * FROM api_keys WHERE id=? AND person_id=?", + (key_id, person["id"])).fetchone() + if not r: + return None + if r["revoked_at"]: + raise IdentityError(409, "already revoked") + con.execute("UPDATE api_keys SET revoked_at=? WHERE id=?", (_now(), key_id)) + log_event("apikey.revoked", person_id=person["id"], actor_id=person["id"], + email=person.get("email"), detail="%s %s" % (r["label"], key_id), con=con) + con.commit() + return _key_row(con.execute("SELECT * FROM api_keys WHERE id=?", (key_id,)).fetchone()) + finally: + con.close() + + +def api_key_person(bearer): + """The person behind an Authorization: Bearer value, narrowed to the key's + scopes, or None. Touches last_used_at. Never raises: an unknown, revoked, + expired or foreign-prefixed value is simply not a person.""" + if not bearer or not str(bearer).startswith(KEY_PREFIX): + return None + con = connect() + try: + r = con.execute("SELECT * FROM api_keys WHERE key_hash=? AND revoked_at IS NULL", + (_hash_token(str(bearer)),)).fetchone() + if not r: + return None + now = _now() + if r["expires_at"] and r["expires_at"] <= now: + return None + p = _person_row(con, con.execute("SELECT * FROM people WHERE id=?", (r["person_id"],)).fetchone()) + if not p or p.get("disabled_at"): + return None + con.execute("UPDATE api_keys SET last_used_at=? WHERE id=?", (now, r["id"])) + con.commit() + p["key_scopes"] = set(json.loads(r["scopes"])) + p["key_id"] = r["id"] + p["key_prefix"] = r["prefix"] + p["capabilities"] = sorted(effective_caps(p) & p["key_scopes"]) + return p + finally: + con.close() diff --git a/tests/smoke_admin.py b/tests/smoke_admin.py index fbd3052..c8e4548 100644 --- a/tests/smoke_admin.py +++ b/tests/smoke_admin.py @@ -159,5 +159,20 @@ check("leader cannot touch settings", not I.can(leader, "settings:write")) check("admin spans units", I.can(admin, "unit:write_own", "u1") and I.can(admin, "unit:write_own", "u2")) check("admin holds settings:write", I.can(admin, "settings:write")) +print("\napi docs registry") +import admin_api as A +reg = A.describe_routes() +check("registry is non-trivial", len(reg) >= 18) +missing = [d for d in reg if not d["capability"] and d["path"] != "/api/admin/whoami"] +check("every route's capability is read from its own _auth call (except whoami): %s" + % ", ".join(d["path"] for d in missing), not missing) +check("key routes gate on apikeys:own", all(d["capability"] == "apikeys:own" for d in reg if "/keys" in d["path"])) +check("docs carry the handler docstring", all(d["doc"] for d in reg if "/keys" in d["path"])) +class _R: + headers = {"authorization": ""}; cookies = {} +os.environ["ADMIN_TOKEN"] = "t"; A.ADMIN_TOKEN = "t" +page = A.api_docs(_R(), "t").body.decode() +check("docs page renders every route", page.count("") == len(reg)) + print("\n%d passed, %d failed" % (PASS, FAIL)) sys.exit(1 if FAIL else 0) diff --git a/tests/smoke_identity.py b/tests/smoke_identity.py index 70c3951..f78b00d 100644 --- a/tests/smoke_identity.py +++ b/tests/smoke_identity.py @@ -168,6 +168,53 @@ con.close() for k in ("invite.created", "invite.consumed", "login.ok", "login.failed", "login.throttled"): check("auth_events records %s" % k, k in kinds) +print("\napi keys") +raises("label required", 422, I.mint_api_key, leader, "", ["leads:read"]) +raises("scopes required", 422, I.mint_api_key, leader, "script", []) +raises("scope the person does not hold", 422, I.mint_api_key, leader, "script", ["settings:write"]) +raises("unscopable scope refused even for an owner", 422, I.mint_api_key, owner, "script", ["apikeys:own"]) +raises("bad expiry", 422, I.mint_api_key, leader, "script", ["leads:read"], "soon") +raises("expiry over the cap", 422, I.mint_api_key, leader, "script", ["leads:read"], 9999) +full, row = I.mint_api_key(leader, "roundup script", ["leads:read", "nearby:write"], 30) +check("key has the prefix and is not stored", full.startswith("gls73_") and "key_hash" not in row + and row["prefix"] == full[:12] and row["state"] == "active" and row["expires_at"]) +check("scopes stored sorted", row["scopes"] == ["leads:read", "nearby:write"]) +kp = I.api_key_person(full) +check("key resolves to its owner, narrowed", kp and kp["email"] == "leader@example.test" + and kp["key_scopes"] == {"leads:read", "nearby:write"} and kp["key_prefix"] == row["prefix"]) +check("can() honours the narrowing", I.can(kp, "leads:read") and I.can(kp, "nearby:write") + and not I.can(kp, "announcements:write") and not I.can(kp, "apikeys:own")) +check("unit scope still applies through a key", I.can(kp, "nearby:write", pack["id"])) +check("capabilities list reflects the key", kp["capabilities"] == ["leads:read", "nearby:write"]) +check("last_used_at touched", I.list_api_keys(leader["id"])[0]["last_used_at"]) +raises("a key cannot mint keys", 403, I.mint_api_key, kp, "nested", ["leads:read"]) +check("unknown key is nobody", I.api_key_person("gls73_nope") is None) +check("foreign-prefixed value is nobody", I.api_key_person("tk_" + full[6:]) is None) +check("empty is nobody", I.api_key_person("") is None and I.api_key_person(None) is None) +check("not another person's to revoke", I.revoke_api_key(owner, row["id"]) is None) +rev = I.revoke_api_key(leader, row["id"]) +check("revoked by its owner", rev["state"] == "revoked" and rev["revoked_at"]) +check("revoked key is nobody", I.api_key_person(full) is None) +raises("second revoke", 409, I.revoke_api_key, leader, row["id"]) +full2, row2 = I.mint_api_key(leader, "no expiry", ["leads:read"]) +check("no expiry allowed", row2["expires_at"] is None and I.api_key_person(full2) is not None) +con = I.connect() +con.execute("UPDATE api_keys SET expires_at='2000-01-01T00:00:00+00:00' WHERE id=?", (row2["id"],)); con.commit(); con.close() +check("expired key is nobody, and lists as expired", I.api_key_person(full2) is None + and I.list_api_keys(leader["id"])[0]["state"] == "expired") +full3, row3 = I.mint_api_key(leader, "survives?", ["leads:read"]) +con = I.connect() +con.execute("UPDATE people SET disabled_at=? WHERE id=?", (I._now(), leader["id"])); con.commit(); con.close() +check("disabling the person kills the key", I.api_key_person(full3) is None) +con = I.connect() +con.execute("UPDATE people SET disabled_at=NULL WHERE id=?", (leader["id"],)); con.commit(); con.close() +check("scopable set for a leader excludes the unscopable", "apikeys:own" not in I.scopable_caps(leader) + and "leads:read" in I.scopable_caps(leader) and "settings:write" not in I.scopable_caps(leader)) +con = I.connect() +kinds = {r["kind"] for r in con.execute("SELECT DISTINCT kind FROM auth_events")} +con.close() +check("auth_events records mint and revoke", "apikey.minted" in kinds and "apikey.revoked" in kinds) + print("\nmeeting words") D = dict(MEETING_DAY="Tuesday", MEETING_DAYS="Tuesdays", MEETING_DAY_ABBR="Tue", PACK_TIME="6:00 PM", TROOP_TIME="7:30 PM", PACK_CLOCK="6:00", TROOP_CLOCK="7:30")