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
+18 -5
View File
@@ -710,8 +710,10 @@ def doc_row(d):
f'<span class="docmeta">{meta}</span></a>')
@app.get("/documents", response_class=HTMLResponse)
def documents_index():
groups = documents.listing()
def documents_index(request: Request):
viewer = auth.current_person(request)
units = documents.units_for(viewer)
groups = documents.listing(units)
if groups:
cards = []
for cat, rows in groups:
@@ -721,6 +723,14 @@ def documents_index():
inner = "".join(cards)
else:
inner = '<div class="noev">Nothing posted here yet. Forms and handouts will land on this page as the program year gets going.</div>'
if units:
signin_note = ('<div class="wrap" style="padding-top:14px"><div class="note">'
'Signed in, so anything posted for families is included above. '
'<a href="/account">Your account</a></div></div>')
else:
signin_note = ('<div class="wrap" style="padding-top:14px"><div class="note">'
'Some handouts are posted for registered families only. '
'<a href="/login">Sign in</a> if you have an account.</div></div>')
body = f"""
<div class="calhead"><div class="wrap">
<div class="eyebrow gold">Pack &amp; Troop 73</div>
@@ -730,12 +740,15 @@ def documents_index():
<section style="padding:52px 0 70px"><div class="wrap">{inner}</div></section>
<section style="padding:0 0 80px"><div class="wrap"><div class="note">
Can't find something, or need a form in another format? Message us on Facebook or catch a leader on a {MEETING_DAY} night.
</div></div></section>"""
</div></div>{signin_note}</section>"""
return page("Forms & Documents · Pack & Troop 73", body, "docs")
@app.get("/documents/{slug}")
def document_file(slug: str):
d = documents.find(slug)
def document_file(slug: str, request: Request):
# A members document the viewer cannot reach is indistinguishable from one
# that does not exist. That is deliberate: 404, never 403, because a 403
# confirms the document to someone holding nothing but a guessed slug.
d = documents.find(slug, documents.units_for(auth.current_person(request)))
if d is None:
body = """
<div class="calhead"><div class="wrap">