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.
This commit is contained in:
2026-09-04 12:09:19 -04:00
parent 8ddcccf293
commit 8c8dab12e3
5 changed files with 262 additions and 44 deletions
+1 -1
View File
@@ -339,7 +339,7 @@ def _person_row(con, r):
p = dict(r)
p.pop("password_hash", None)
p["memberships"] = [dict(m) for m in con.execute(
"SELECT m.unit_id, m.role, m.title, u.slug, u.short_name, u.display_name"
"SELECT m.unit_id, m.role, m.title, u.slug, u.unit_type, u.short_name, u.display_name"
" FROM memberships m JOIN units u ON u.id = m.unit_id"
" WHERE m.person_id = ? ORDER BY u.sort_order", (p["id"],))]
p["capabilities"] = sorted(effective_caps(p))