Files
scout-website/app/admin_api.py
T
thethreemagi 8c8dab12e3 P1: gate members documents per unit, session auth on the admin API
documents.visible() now takes the viewer's unit set. A members document is
served and listed only to a signed-in member of the matching unit; 'both'
reaches any member; owner and admin reach everything including unit types
added later. Everyone else gets 404, never 403.

The gate moved INSIDE find(), so there is one path from a slug to a file and
no route can forget to check. listed() and visible() stay separate functions.

admin_api takes a session first and falls back to X-Admin-Token as break
glass. Still fails closed: no session and no ADMIN_TOKEN is 503. Capability,
not role, decides per route. announcements.created_by now comes from the
session and ignores any value in the request body.

tests/smoke_documents.py, 24 checks, including the invariant that the index
can never list something serving would refuse.
2026-09-04 12:09:19 -04:00

182 lines
7.5 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 os
from fastapi import APIRouter, Body, Header, HTTPException, Query, Request
import auth
import identity
import store
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip()
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _auth(request, token, capability):
"""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.
"""
person = auth.current_person(request)
if person:
if not identity.can(person, capability):
raise HTTPException(403, "your account does not have %s" % capability)
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)