Files
scout-website/app/admin_api.py
T
thethreemagi 4b5b2a3667 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.
2026-09-04 17:31:05 -04:00

466 lines
20 KiB
Python

"""
admin_api.py - read API over the internal record store.
This is the seam the future scout admin panel plugs into. The panel talks HTTP
to these endpoints; it never opens the SQLite file directly. That keeps the
panel deployable anywhere (separate container, separate host) and keeps this
app the only writer to its own database.
Leads are read-only, and that is deliberate. The old PATCH route set
status/assigned_to/notes on a lead - columns that were removed because they
were a guess at an outreach process nobody has designed. When that process
exists it gets its own table and its own write routes; until then there is
nothing on a lead to mutate.
Announcements ARE writable, and are the deliberate exception to that. The
read-only rule exists because a lead has nothing mutable on it. An
announcement is defined by being mutable and expiring - it is posted, it
shows, it comes down. Writing it is the entire feature. The caps that keep
the banner from becoming a mess live in store.py, not here, so the panel and
any future client inherit them rather than reimplementing them.
Auth, two ways, session first.
A signed-in person holding the required capability is allowed. Otherwise the
X-Admin-Token header must match ADMIN_TOKEN, which is retained as BREAK GLASS:
if the identity layer is broken there has to be a way in that does not depend
on the identity layer.
Still FAILS CLOSED. With no session and no ADMIN_TOKEN set, every route returns
503. These endpoints expose parent names, emails and phone numbers for minors'
families, so an unconfigured deployment must not serve them. Adding sessions
widened who may read; it did not soften what happens when nothing is
configured.
Capability, not role, decides. Lead routes need leads:read, announcement routes
need announcements:write, and both are answered by identity.can() against the
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
import store
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.
Order matters: session first, so a normal signed-in leader never depends on
the shared token, and the token stays a fallback rather than the everyday
path.
unit_id scopes a membership capability to one unit: a pack leader holds
unit:write_own, but only within the pack. Global roles pass a scoped check
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 = _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 "")
+ (" (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")
if not token or not hmac.compare_digest(token, ADMIN_TOKEN):
raise HTTPException(401, "sign in, or send a valid X-Admin-Token")
return "admin-token"
@router.get("/summary")
def get_summary(request: Request, x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "leads:read")
return store.summary()
@router.get("/leads")
def get_leads(request: Request, since: str = None, q: str = None,
limit: int = Query(100, ge=1, le=500), offset: int = 0,
x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "leads:read")
return {"leads": store.list_leads(since=since, q=q, limit=limit, offset=offset)}
@router.get("/leads/{record_id}")
def get_one(request: Request, record_id: str, x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "leads:read")
rec = store.get_lead(record_id)
if not rec:
raise HTTPException(404, "no such lead")
return rec
@router.get("/mirrors/failed")
def failed(request: Request, target: str = "google_sheet", x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "leads:read")
return {"target": target, "leads": store.failed_mirror_records(target)}
@router.post("/mirrors/retry")
def retry(request: Request, target: str = "google_sheet", x_admin_token: str = Header(None)):
"""Replay leads whose copy to an external target failed. Idempotent-ish:
a lead already marked ok is never retried."""
_auth(request, x_admin_token, "leads:read")
if target != "google_sheet":
raise HTTPException(422, "only google_sheet retry is implemented")
import app as main_app
done, failed_ids = 0, []
for rec in store.failed_mirror_records(target, limit=200):
try:
main_app.sheet_append(rec["payload"])
store.set_mirror(rec["id"], target, True)
done += 1
except Exception as e:
store.set_mirror(rec["id"], target, False, e)
failed_ids.append(rec["id"])
return {"retried_ok": done, "still_failing": failed_ids}
# ----------------------------------------------------------------------------
# Announcements - the one writable object here. See the module docstring.
# ----------------------------------------------------------------------------
def _reject(e):
"""Turn a store guardrail into its HTTP answer, carrying the detail so the
caller is told what to do rather than just refused."""
payload = {"error": e.detail}
payload.update(e.extra)
return HTTPException(e.status, payload)
@router.get("/announcements")
def list_announcements(request: Request, include_expired: bool = False,
limit: int = Query(100, ge=1, le=500),
x_admin_token: str = Header(None)):
"""Every announcement with its computed state: live, scheduled, expired,
revoked, or over_cap. State is returned rather than left to be inferred
from what the homepage happens to render."""
_auth(request, x_admin_token, "announcements:write")
return {"announcements": store.list_announcements(
include_expired=include_expired, limit=limit)}
@router.post("/announcements", status_code=201)
def create_announcement(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Post a notice. ends_at is required.
422 if the message is over the character cap or the window is invalid.
409 if the live cap is already reached, listing what is up so you can
decide what to revoke."""
_actor = _auth(request, x_admin_token, "announcements:write")
try:
return store.create_announcement(
message=payload.get("message"),
ends_at=payload.get("ends_at"),
starts_at=payload.get("starts_at"),
level=payload.get("level", "info"),
link_url=payload.get("link_url"),
link_text=payload.get("link_text"),
# From the session, never from the body. A caller must not be able
# to attribute a public notice to somebody else. Stored as the
# email rather than the uuid: the column is display-facing, people
# are never hard deleted, and a uuid in a banner audit trail helps
# nobody read it.
created_by=_actor,
)
except store.AnnouncementRejected as e:
raise _reject(e)
@router.delete("/announcements/{announcement_id}")
def revoke_announcement(request: Request, announcement_id: str, x_admin_token: str = Header(None)):
"""Take one down early. Sets revoked_at; never deletes the row."""
_auth(request, x_admin_token, "announcements:write")
if not store.get_announcement(announcement_id):
raise HTTPException(404, "no such announcement")
if not store.revoke_announcement(announcement_id):
raise HTTPException(409, "already revoked")
return store.get_announcement(announcement_id)
# ----------------------------------------------------------------------------
# Nearby units - the /find-a-unit directory, and the first writable directory
# data. The verified_at and deactivate-not-delete rules live in store.py so
# any future client inherits them.
# ----------------------------------------------------------------------------
@router.get("/nearby")
def list_nearby(request: Request, include_inactive: bool = False,
x_admin_token: str = Header(None)):
"""The editing view, so deactivated rows are reachable. nearby:write
rather than a read capability: the public page IS the read surface, and
this list exists only to be edited."""
_auth(request, x_admin_token, "nearby:write")
return {"nearby_units": store.list_nearby(include_inactive=include_inactive)}
@router.post("/nearby", status_code=201)
def create_nearby(request: Request, payload: dict = Body(...),
x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "nearby:write")
try:
return store.create_nearby(payload)
except store.NearbyRejected as e:
raise _reject(e)
@router.patch("/nearby/{nearby_id}")
def update_nearby(request: Request, nearby_id: str, payload: dict = Body(...),
x_admin_token: str = Header(None)):
"""Partial update. Saving bumps verified_at to today unless the payload
carries an explicit date - see store.py for why saving is verifying."""
_auth(request, x_admin_token, "nearby:write")
try:
rec = store.update_nearby(nearby_id, payload)
except store.NearbyRejected as e:
raise _reject(e)
if not rec:
raise HTTPException(404, "no such nearby unit")
return rec
@router.delete("/nearby/{nearby_id}")
def deactivate_nearby(request: Request, nearby_id: str, x_admin_token: str = Header(None)):
"""Take a unit off the page. Sets active=0; never deletes the row."""
_auth(request, x_admin_token, "nearby:write")
if not store.get_nearby(nearby_id):
raise HTTPException(404, "no such nearby unit")
if not store.deactivate_nearby(nearby_id):
raise HTTPException(409, "already inactive")
return store.get_nearby(nearby_id)
# ----------------------------------------------------------------------------
# Our own units - meeting time and place only. unit:write_own is checked
# against the unit in the URL, so a pack leader edits the pack and not the
# troop; admins hold it globally and reach both.
# ----------------------------------------------------------------------------
@router.get("/units")
def list_units(request: Request, x_admin_token: str = Header(None)):
"""The units the caller may edit, which is what the screen this feeds
shows. A pack leader gets the pack; a global role or the break-glass
token gets everything. The answer IS the scope - no second filter for
the console to get wrong."""
_auth(request, x_admin_token, "unit:write_own")
units = identity.list_units(include_inactive=True)
person = auth.current_person(request)
if person:
units = [u for u in units if identity.can(person, "unit:write_own", u["id"])]
return {"units": units}
@router.patch("/units/{slug_or_id}")
def update_unit(request: Request, slug_or_id: str, payload: dict = Body(...),
x_admin_token: str = Header(None)):
unit = identity.get_unit(slug_or_id)
# Scope to the unit when it exists; an unknown slug still goes through
# _auth first, so probing paths answers 401/403 before it answers 404.
_auth(request, x_admin_token, "unit:write_own",
unit_id=unit["id"] if unit else None)
try:
return identity.update_unit_meets(slug_or_id, payload)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
# ----------------------------------------------------------------------------
# Site settings - the typed key registry lives in identity.SETTINGS_KEYS;
# unknown keys are rejected there, not silently stored.
# ----------------------------------------------------------------------------
@router.get("/settings")
def list_settings(request: Request, x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "settings:write")
return {"settings": identity.all_settings()}
@router.put("/settings/{key}")
def put_setting(request: Request, key: str, payload: dict = Body(...),
x_admin_token: str = Header(None)):
"""Set one value, or clear it back to the code default with value: null."""
actor = _auth(request, x_admin_token, "settings:write")
try:
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)