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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user