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
+15
View File
@@ -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("<tr><td><code>") == len(reg))
print("\n%d passed, %d failed" % (PASS, FAIL))
sys.exit(1 if FAIL else 0)