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%sYou are %s%s with: %s.
%s" % e(person["key_prefix"]) if person.get("key_scopes") is not None else "",
+ e(", ".join(person["capabilities"]))))
+ body = (""
+ ""
+ "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.
| Method | Path | Needs | Query / path params | Notes |
|---|
") == 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")