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.
This commit is contained in:
2026-09-04 17:31:05 -04:00
parent 0b337f1f19
commit 4b5b2a3667
5 changed files with 405 additions and 2 deletions
+172 -2
View File
@@ -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(
"<tr><td><code>%s</code></td><td><code>%s</code></td><td><code>%s</code></td>"
"<td>%s</td><td>%s</td></tr>"
% (e(" ".join(d["methods"])), e(d["path"]), e(d["capability"] or "-"),
e(", ".join(d["params"])) or "-", e(d["doc"]).replace("\n", "<br>"))
for d in describe_routes())
mine = ""
if person:
mine = ("<p>You are <code>%s</code>%s with: <code>%s</code>.</p>"
% (e(person["email"]),
" via key <code>%s</code>" % e(person["key_prefix"]) if person.get("key_scopes") is not None else "",
e(", ".join(person["capabilities"]))))
body = ("<!doctype html><html><head><meta charset=utf-8><meta name=robots content=noindex>"
"<meta name=viewport content=\"width=device-width,initial-scale=1\">"
"<title>greenlanescouts73.org admin API</title><style>"
"body{font:15px/1.5 system-ui,sans-serif;margin:0;padding:24px;color:#1c2430;background:#f4f5f7}"
"table{border-collapse:collapse;background:#fff;width:100%%}th,td{text-align:left;vertical-align:top;"
"padding:8px 10px;border-top:1px solid #eef0f3;font-size:14px}th{font-size:12.5px;color:#5b6472}"
"code{font-size:13px}h1{font-size:20px}.n{max-width:900px}</style></head><body><div class=n>"
"<h1>greenlanescouts73.org admin API</h1>"
"<p>Generated from the route registry on every request; there is no hand-written copy to drift.</p>"
"<p><b>Auth</b>, tried in this order: the site session cookie; "
"<code>Authorization: Bearer gls73_...</code> (an API key, scoped to the capabilities its owner chose - "
"mint one at <a href=\"/leaders/keys\">/leaders/keys</a>); <code>X-Admin-Token</code> (break glass, "
"operators only). A signed-in caller without the capability gets 403; an anonymous one gets 401. "
"Bodies are JSON. Start with <code>GET /api/admin/whoami</code>.</p>%s"
"<table><tr><th>Method</th><th>Path</th><th>Needs</th><th>Query / path params</th><th>Notes</th></tr>%s</table>"
"</div></body></html>" % (mine, rows))
return HTMLResponse(body)