P0: identity layer - units, people, roles, invites, sessions

Adds identity.py (schema, capability map, scrypt passwords, invites,
sessions, login throttle, boot seed) and auth.py (login, invite
acceptance, account page). app.py gains two imports and one wiring
block at EOF; no existing behaviour changes.

Units are a table seeded from the site constants. Roles split: leader
and member per unit in memberships, owner and admin site-wide in
people.global_role, so a unit added later cannot under-grant an admin.

tests/smoke_identity.py covers the rules that are invisible when wrong:
single-use invites, reissue revoking the prior link, expiry, idle and
absolute session bounds, throttling, and the capability split. 49 checks.
This commit is contained in:
2026-09-04 11:29:32 -04:00
parent d6e594b1b6
commit bed93072cb
4 changed files with 1181 additions and 0 deletions
+45
View File
@@ -10,6 +10,8 @@ import store
import admin_api
import nearby
import documents
import identity
import auth
# ---------------------------------------------------------------------------
# Pack & Troop 73 - greenlanescouts73.org
@@ -1037,3 +1039,46 @@ def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str =
except Exception:
pass
return RedirectResponse(url="/join?sent=1", status_code=303)
# ---------------------------------------------------------------------------
# Identity (P0)
# ---------------------------------------------------------------------------
# Wired at the END of this module on purpose: auth.PAGE needs page(), which is
# defined above, and keeping the whole attachment in one block means the P0
# footprint inside this 1000-line file is one place to read and one place to
# revert.
def bootstrap_notify(url):
"""Push the first owner invite to ntfy. Logged either way - the container
log is the copy that survives a missed notification."""
if not NTFY_BASE:
return False
headers = {"Content-Type": "application/json"}
if NTFY_TOKEN:
headers["Authorization"] = "Bearer " + NTFY_TOKEN
try:
body = json.dumps({
"topic": NTFY_TOPIC,
"title": "greenlanescouts73.org owner invite",
"message": "First admin account. Single use, expires in %d days.\n%s"
% (identity.INVITE_TTL_DAYS, url),
"tags": ["key"],
"priority": 4,
}).encode()
rq = urllib.request.Request(NTFY_BASE, data=body, headers=headers)
with urllib.request.urlopen(rq, timeout=5) as resp:
return resp.status < 400
except Exception as e:
print("identity: bootstrap ntfy push failed: %s" % e, flush=True)
return False
identity.init()
auth.PAGE = page
app.include_router(auth.router)
_boot_url, _boot_minted = identity.bootstrap()
if _boot_minted and _boot_url:
bootstrap_notify(_boot_url)