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:
+76
-13
@@ -23,9 +23,19 @@ visibility:
|
||||
that need a durable link before member login exists. This is
|
||||
obscurity, not access control: treat an unlisted link as
|
||||
forwardable, because it is.
|
||||
"members" reserved for the login that does not exist yet. Until it does,
|
||||
these are hidden AND unservable, returning 404 rather than 403,
|
||||
because a 403 advertises a document we cannot actually gate yet.
|
||||
"members" served only to a signed-in member of the right unit, and listed
|
||||
only for them. Everyone else gets 404, never 403: a 403
|
||||
advertises the existence of a document to someone who cannot
|
||||
have it, and the slug is the only thing they would need.
|
||||
|
||||
Unit scoping. A document's `unit` is "pack", "troop" or "both". A viewer is
|
||||
resolved to the set of unit types they belong to, and "both" is readable by any
|
||||
member of any unit. An owner or admin reads everything, including units created
|
||||
after their role was granted.
|
||||
|
||||
This module deliberately does NOT import identity. It reads a person as a plain
|
||||
dict, so the coupling is a data shape rather than a module dependency and the
|
||||
gate stays testable on its own.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
@@ -137,14 +147,62 @@ def manifest():
|
||||
return _cache["value"]
|
||||
|
||||
|
||||
def visible(doc):
|
||||
"""The single gate on SERVING. Member login plugs in here and nowhere else."""
|
||||
return doc.get("visibility") in ("public", "unlisted") and doc["path"].is_file()
|
||||
# Every unit type that can appear on a document. A membership in a unit type
|
||||
# outside this set (a Venturing crew, say) grants no document access until the
|
||||
# manifest vocabulary is widened to match - failing closed is correct here.
|
||||
DOC_UNITS = ("pack", "troop")
|
||||
|
||||
|
||||
def listed(doc):
|
||||
"""The separate, weaker question of whether it appears on the index."""
|
||||
return doc.get("visibility") == "public" and visible(doc)
|
||||
def units_for(person):
|
||||
"""The document unit tokens a viewer may read. Empty set for anonymous.
|
||||
|
||||
An owner or admin gets everything, including a unit type added later. That
|
||||
is the whole reason global_role is a column rather than a membership row.
|
||||
"""
|
||||
if not person or person.get("disabled_at"):
|
||||
return frozenset()
|
||||
if person.get("global_role") in ("owner", "admin"):
|
||||
return frozenset(DOC_UNITS)
|
||||
return frozenset(
|
||||
m.get("unit_type") for m in person.get("memberships", [])
|
||||
if m.get("unit_type") in DOC_UNITS)
|
||||
|
||||
|
||||
def _member_ok(doc, units):
|
||||
"""Does this viewer's unit set reach this members-only document?
|
||||
|
||||
"both" means the document concerns both units, so any member reaches it.
|
||||
It does not mean "requires membership of both".
|
||||
"""
|
||||
if not units:
|
||||
return False
|
||||
return doc.get("unit") == "both" or doc.get("unit") in units
|
||||
|
||||
|
||||
def visible(doc, units=None):
|
||||
"""The single gate on SERVING. Login attaches here and nowhere else."""
|
||||
if not doc["path"].is_file():
|
||||
return False
|
||||
vis = doc.get("visibility")
|
||||
if vis in ("public", "unlisted"):
|
||||
return True
|
||||
if vis == "members":
|
||||
return _member_ok(doc, units or frozenset())
|
||||
return False
|
||||
|
||||
|
||||
def listed(doc, units=None):
|
||||
"""The separate, weaker question of whether it appears on the index.
|
||||
|
||||
Kept separate from visible() on purpose. Collapsing the two is how
|
||||
"unlisted" quietly becomes public, or "members" quietly becomes servable.
|
||||
"""
|
||||
vis = doc.get("visibility")
|
||||
if vis == "public":
|
||||
return visible(doc, units)
|
||||
if vis == "members":
|
||||
return visible(doc, units)
|
||||
return False
|
||||
|
||||
|
||||
def noindex(doc):
|
||||
@@ -152,10 +210,10 @@ def noindex(doc):
|
||||
return doc.get("visibility") == "unlisted"
|
||||
|
||||
|
||||
def listing():
|
||||
def listing(units=None):
|
||||
"""Listed documents grouped into their categories, in manifest order."""
|
||||
m = manifest()
|
||||
docs = [d for d in m["documents"] if listed(d)]
|
||||
docs = [d for d in m["documents"] if listed(d, units)]
|
||||
known = {c["id"] for c in m["categories"]}
|
||||
groups = []
|
||||
for cat in m["categories"]:
|
||||
@@ -168,13 +226,18 @@ def listing():
|
||||
return groups
|
||||
|
||||
|
||||
def find(slug):
|
||||
def find(slug, units=None):
|
||||
"""The document at this slug, or None if the viewer cannot have it.
|
||||
|
||||
The gate is applied HERE rather than by the caller, so there is exactly one
|
||||
path from a slug to a file and no route can forget to check.
|
||||
"""
|
||||
slug = (slug or "").strip().lower()
|
||||
if not SLUG_RE.match(slug):
|
||||
return None
|
||||
for d in manifest()["documents"]:
|
||||
if d["slug"] == slug:
|
||||
return d if visible(d) else None
|
||||
return d if visible(d, units) else None
|
||||
return None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user