Files
scout-website/app/identity.py
T
thethreemagi 32e1c67a6f P2: nearby units CRUD, unit meeting times, site settings - the first admin writes
Nearby rows bump verified_at on every save and deactivate rather than delete.
Unit edits are meeting fields only, gated by unit:write_own scoped to the unit
in the URL. Settings are a typed key registry falling back to code defaults;
find-a-unit now reads its source name and URL from it. link_url and contact
reject attribute-breakout characters and the nearby renderer escapes quotes.
Covered by tests/smoke_admin.py, 53 checks in-process.
2026-09-04 14:10:29 -04:00

861 lines
30 KiB
Python

"""
identity.py - units, people, roles, invites and sessions for greenlanescouts73.org
This is the identity layer the leader console (scout-control) and the member
document gate both sit on. It owns tables in scout73.db and, like store.py,
this app remains the only writer to them.
Three decisions are load-bearing and should not be quietly undone.
UNITS ARE A TABLE, NOT A STRING. A slug typed into six places is a slug that
will be typed wrong in one of them. Meeting nights live on the unit row rather
than in settings, because they are facts about a unit, and they are stored
structured (weekday + 24h time) rather than as display sentences, because the
site composes them five different ways.
ROLES SPLIT TWO WAYS. `leader` and `member` are per unit, in memberships.
`owner` and `admin` are site-wide, in people.global_role. If admin were a
membership row, granting it would mean one insert per unit, and the day a third
unit is added every existing admin would silently lose sight of it. Effective
capability is the union of the global set and the per-unit sets.
NOTHING IS HARD DELETED. People are disabled, invites are revoked or consumed,
sessions are revoked. Who had access, and when, has to stay answerable.
Stdlib only, as with store.py - scrypt ships with Python, so this adds no
image dependencies.
"""
import base64
import datetime
import hashlib
import hmac
import json
import os
import secrets
import sqlite3
import uuid
from pathlib import Path
DB_PATH = Path(os.environ.get("STORE_DB", "/data/scout73.db"))
SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "https://greenlanescouts73.org").rstrip("/")
ADMIN_BOOTSTRAP_EMAIL = os.environ.get("ADMIN_BOOTSTRAP_EMAIL", "").strip().lower()
SESSION_ABSOLUTE_DAYS = 30
SESSION_IDLE_HOURS = 12
INVITE_TTL_DAYS = 14
# Login throttle. Counted from auth_events, so it survives a restart - an
# in-memory counter resets to zero on every deploy, which is not a throttle.
LOGIN_WINDOW_MINUTES = 15
LOGIN_MAX_FAILURES = 8
GLOBAL_ROLES = ("owner", "admin")
UNIT_ROLES = ("leader", "member")
# ---------------------------------------------------------------------------
# Capabilities
# ---------------------------------------------------------------------------
# One dictionary, consulted by one function. Scattered `if role == "admin"`
# checks are how a permission model rots: there has to be a single place to
# read to know who can do what.
#
# email:* are reserved and unused. The mail server is not connected yet, and
# keys minted before it lands should not need re-scoping afterwards.
CAPS = {
"member": {
"account:self",
"documents:read_members",
},
"leader": {
"account:self",
"documents:read_members",
"announcements:write",
"leads:read",
"nearby:write",
"calendar:write",
"unit:write_own",
"apikeys:own",
"email:draft",
},
"admin": {
"account:self",
"documents:read_members",
"announcements:write",
"leads:read",
"nearby:write",
"calendar:write",
"unit:write_own",
"units:write",
"settings:write",
"apikeys:own",
"people:invite_leader",
"people:invite_admin",
"email:draft",
"email:send",
},
}
CAPS["owner"] = CAPS["admin"] | {"people:manage", "secrets:rotate"}
SCHEMA = """
CREATE TABLE IF NOT EXISTS units (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
short_name TEXT NOT NULL,
unit_type TEXT NOT NULL,
unit_number TEXT NOT NULL,
meets_weekday INTEGER,
meets_time TEXT,
meets_at TEXT,
active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 100,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS people (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
full_name TEXT,
preferred_name TEXT,
phone TEXT,
password_hash TEXT,
global_role TEXT,
bsa_member_id TEXT,
ypt_completed_on TEXT,
registered_adult INTEGER NOT NULL DEFAULT 0,
contact_pref TEXT,
created_at TEXT NOT NULL,
created_by TEXT,
last_login_at TEXT,
disabled_at TEXT,
disabled_reason TEXT
);
CREATE TABLE IF NOT EXISTS memberships (
person_id TEXT NOT NULL REFERENCES people(id),
unit_id TEXT NOT NULL REFERENCES units(id),
role TEXT NOT NULL,
title TEXT,
created_at TEXT NOT NULL,
created_by TEXT,
PRIMARY KEY (person_id, unit_id)
);
CREATE TABLE IF NOT EXISTS invites (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
email TEXT NOT NULL COLLATE NOCASE,
global_role TEXT,
units TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
created_by TEXT,
expires_at TEXT NOT NULL,
consumed_at TEXT,
person_id TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_invites_email ON invites(email, consumed_at, revoked_at);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
person_id TEXT NOT NULL REFERENCES people(id),
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
ip TEXT,
user_agent TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_person ON sessions(person_id, revoked_at);
CREATE TABLE IF NOT EXISTS auth_events (
id TEXT PRIMARY KEY,
at TEXT NOT NULL,
kind TEXT NOT NULL,
person_id TEXT,
actor_id TEXT,
email TEXT,
detail TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_auth_events_at ON auth_events(at DESC);
CREATE INDEX IF NOT EXISTS idx_auth_events_kind ON auth_events(kind, email, at DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT
);
"""
# Seeded at boot, idempotent by slug. Values lifted from the site constants in
# app.py so the two cannot disagree on day one. app.py keeps its constants
# until P2 moves the rendering over.
SEED_UNITS = [
dict(slug="pack73", display_name="Cub Scout Pack 73", short_name="Pack 73",
unit_type="pack", unit_number="73", meets_weekday=2, meets_time="18:00",
meets_at="St. Luke's Lutheran Church, Zieglerville, PA", sort_order=10),
dict(slug="troop73", display_name="Scouts BSA Troop 73", short_name="Troop 73",
unit_type="troop", unit_number="73", meets_weekday=2, meets_time="19:30",
meets_at="St. Luke's Lutheran Church, Zieglerville, PA", sort_order=20),
]
class IdentityError(Exception):
"""Carries the HTTP status the route should return, so rules live here."""
def __init__(self, status, detail):
super().__init__(detail)
self.status = status
self.detail = detail
def _now():
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
def _plus(**kw):
return (datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(**kw)).isoformat(timespec="seconds")
def connect():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB_PATH, timeout=10)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys=ON")
return con
def init():
"""Create the schema and seed units. Safe on every boot."""
con = connect()
try:
con.executescript(SCHEMA)
for u in SEED_UNITS:
con.execute(
"INSERT INTO units (id, slug, display_name, short_name, unit_type,"
" unit_number, meets_weekday, meets_time, meets_at, active, sort_order, updated_at)"
" VALUES (?,?,?,?,?,?,?,?,?,1,?,?)"
" ON CONFLICT(slug) DO NOTHING",
(str(uuid.uuid4()), u["slug"], u["display_name"], u["short_name"],
u["unit_type"], u["unit_number"], u["meets_weekday"], u["meets_time"],
u["meets_at"], u["sort_order"], _now()))
con.commit()
finally:
con.close()
# ---------------------------------------------------------------------------
# Passwords
# ---------------------------------------------------------------------------
SCRYPT_N, SCRYPT_R, SCRYPT_P = 2 ** 14, 8, 1
def hash_password(password):
if not password or len(password) < 12:
raise IdentityError(422, "password must be at least 12 characters")
salt = secrets.token_bytes(16)
dk = hashlib.scrypt(password.encode(), salt=salt, n=SCRYPT_N, r=SCRYPT_R,
p=SCRYPT_P, dklen=32)
return "scrypt$%d$%d$%d$%s$%s" % (
SCRYPT_N, SCRYPT_R, SCRYPT_P,
base64.b64encode(salt).decode(), base64.b64encode(dk).decode())
def verify_password(password, stored):
"""Constant-time check. False on anything malformed rather than raising -
a corrupt hash must read as a failed login, never as a pass."""
try:
scheme, n, r, p, salt_b64, dk_b64 = stored.split("$")
if scheme != "scrypt":
return False
dk = hashlib.scrypt(password.encode(), salt=base64.b64decode(salt_b64),
n=int(n), r=int(r), p=int(p), dklen=32)
return hmac.compare_digest(dk, base64.b64decode(dk_b64))
except Exception:
return False
def _hash_token(tok):
return hashlib.sha256(tok.encode()).hexdigest()
# ---------------------------------------------------------------------------
# Audit
# ---------------------------------------------------------------------------
def log_event(kind, person_id=None, actor_id=None, email=None, detail=None, ip=None, con=None):
own = con is None
con = con or connect()
try:
con.execute(
"INSERT INTO auth_events (id, at, kind, person_id, actor_id, email, detail, ip)"
" VALUES (?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), _now(), kind, person_id, actor_id,
(email or "").lower() or None, detail, ip))
if own:
con.commit()
finally:
if own:
con.close()
# ---------------------------------------------------------------------------
# Units and people
# ---------------------------------------------------------------------------
def list_units(include_inactive=False):
con = connect()
try:
sql = "SELECT * FROM units"
if not include_inactive:
sql += " WHERE active = 1"
sql += " ORDER BY sort_order, slug"
return [dict(r) for r in con.execute(sql)]
finally:
con.close()
def get_unit(slug_or_id):
con = connect()
try:
r = con.execute("SELECT * FROM units WHERE slug=? OR id=?",
(slug_or_id, slug_or_id)).fetchone()
return dict(r) if r else None
finally:
con.close()
# The only unit fields the admin API lets a leader change. Renaming a unit or
# changing its type is a rechartering event, not a Tuesday edit, and stays a
# code change.
UNIT_MEETS_FIELDS = ("meets_weekday", "meets_time", "meets_at")
def update_unit_meets(slug_or_id, fields):
"""Change when and where a unit meets.
meets_weekday is ISO (Monday=1 .. Sunday=7) and meets_time is 24h "HH:MM",
matching how the seed rows store them; the site composes the display
sentence, so a display string is never accepted here. Any field may be set
to null - a unit between meeting places is a real state.
"""
unit = get_unit(slug_or_id)
if not unit:
raise IdentityError(404, "no such unit")
if not fields:
raise IdentityError(422, "nothing to update")
unknown = sorted(set(fields) - set(UNIT_MEETS_FIELDS))
if unknown:
raise IdentityError(422, "only meeting fields are editable here: %s"
% (", ".join(UNIT_MEETS_FIELDS)))
out = dict(fields)
if "meets_weekday" in out and out["meets_weekday"] is not None:
v = out["meets_weekday"]
if isinstance(v, bool) or not isinstance(v, int) or not 1 <= v <= 7:
raise IdentityError(422, "meets_weekday is ISO: 1 (Monday) to 7 (Sunday), or null")
if "meets_time" in out and out["meets_time"] is not None:
try:
datetime.datetime.strptime(str(out["meets_time"]), "%H:%M")
except ValueError:
raise IdentityError(422, 'meets_time must be 24h "HH:MM", or null')
out["meets_time"] = str(out["meets_time"])
if "meets_at" in out and out["meets_at"] is not None:
out["meets_at"] = str(out["meets_at"]).strip() or None
out["updated_at"] = _now()
con = connect()
try:
con.execute("UPDATE units SET %s WHERE id=?"
% ", ".join("%s=?" % k for k in out),
list(out.values()) + [unit["id"]])
con.commit()
finally:
con.close()
return get_unit(unit["id"])
def _person_row(con, r):
if not r:
return None
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.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))
return p
def get_person(person_id):
con = connect()
try:
return _person_row(con, con.execute(
"SELECT * FROM people WHERE id=?", (person_id,)).fetchone())
finally:
con.close()
def get_person_by_email(email):
con = connect()
try:
return _person_row(con, con.execute(
"SELECT * FROM people WHERE email=?", ((email or "").lower(),)).fetchone())
finally:
con.close()
def people_count():
con = connect()
try:
return con.execute("SELECT COUNT(*) c FROM people").fetchone()["c"]
finally:
con.close()
def effective_caps(person):
"""Union of the global role's capabilities and every membership's.
Union, not precedence: an admin who is also a den leader should not lose
anything by holding both, and a leader in one unit is not thereby a leader
in another - that part is answered by can(), which takes a unit.
"""
caps = set()
if person.get("disabled_at"):
return caps
if person.get("global_role"):
caps |= CAPS.get(person["global_role"], set())
for m in person.get("memberships", []):
caps |= CAPS.get(m["role"], set())
return caps
def can(person, capability, unit_id=None):
"""Does this person hold `capability`, optionally within a specific unit?
A global role satisfies a unit-scoped check for EVERY unit, including units
created after the role was granted. That is the entire reason global_role
is a column rather than a membership row.
"""
if not person or person.get("disabled_at"):
return False
if person.get("global_role") and capability in CAPS.get(person["global_role"], set()):
return True
for m in person.get("memberships", []):
if unit_id and m["unit_id"] != unit_id:
continue
if capability in CAPS.get(m["role"], set()):
return True
return False
# ---------------------------------------------------------------------------
# Invites
# ---------------------------------------------------------------------------
#
# Only the sha256 of the token is stored. A database read, a backup on the NAS
# or a stray SELECT must not hand over live invitations.
#
# Issuing a new invite for an address revokes the prior unconsumed one, which
# is what "the URL can be recreated until it is used" means in practice. Single
# use is enforced by setting consumed_at in the SAME transaction that creates
# the person, so a double submit cannot mint two accounts.
def create_invite(email, global_role=None, units=None, created_by=None, ttl_days=INVITE_TTL_DAYS):
"""Revoke any live invite for this address, mint a new one, return (row, token).
The raw token is returned exactly once and never stored.
"""
email = (email or "").strip().lower()
if "@" not in email:
raise IdentityError(422, "a valid email address is required")
if global_role and global_role not in GLOBAL_ROLES:
raise IdentityError(422, "global_role must be one of %s" % (GLOBAL_ROLES,))
units = units or []
for u in units:
if u.get("role") not in UNIT_ROLES:
raise IdentityError(422, "unit role must be one of %s" % (UNIT_ROLES,))
if not get_unit(u.get("unit_id") or ""):
raise IdentityError(422, "unknown unit %r" % (u.get("unit_id"),))
if not global_role and not units:
raise IdentityError(422, "an invite needs a global role, a unit membership, or both")
tok = secrets.token_urlsafe(32)
iid = str(uuid.uuid4())
con = connect()
try:
con.execute("UPDATE invites SET revoked_at=? WHERE email=? AND consumed_at IS NULL"
" AND revoked_at IS NULL", (_now(), email))
con.execute(
"INSERT INTO invites (id, token_hash, email, global_role, units, created_at,"
" created_by, expires_at, consumed_at, person_id, revoked_at)"
" VALUES (?,?,?,?,?,?,?,?,NULL,NULL,NULL)",
(iid, _hash_token(tok), email, global_role,
json.dumps(units, ensure_ascii=False), _now(), created_by,
_plus(days=ttl_days)))
log_event("invite.created", actor_id=created_by, email=email,
detail="role=%s units=%d" % (global_role or "-", len(units)), con=con)
con.commit()
row = dict(con.execute("SELECT * FROM invites WHERE id=?", (iid,)).fetchone())
finally:
con.close()
return row, tok
def invite_url(token):
return "%s/invite/%s" % (SITE_BASE_URL, token)
def live_invite_for(email):
"""The current unconsumed, unrevoked, unexpired invite for an address, if any.
Used by bootstrap so a container restart does not invalidate a link somebody
is already holding.
"""
con = connect()
try:
r = con.execute(
"SELECT * FROM invites WHERE email=? AND consumed_at IS NULL"
" AND revoked_at IS NULL AND expires_at > ?"
" ORDER BY created_at DESC LIMIT 1", ((email or "").lower(), _now())).fetchone()
return dict(r) if r else None
finally:
con.close()
def peek_invite(token):
"""Read an invite by raw token without consuming it. None if unusable.
Expired, revoked and consumed all return None on purpose. Telling the
difference tells a stranger which addresses are real.
"""
con = connect()
try:
r = con.execute(
"SELECT * FROM invites WHERE token_hash=? AND consumed_at IS NULL"
" AND revoked_at IS NULL AND expires_at > ?",
(_hash_token(token), _now())).fetchone()
return dict(r) if r else None
finally:
con.close()
def consume_invite(token, full_name, password, preferred_name=None, phone=None, ip=None):
"""Create the person and burn the invite in one transaction.
Everything below happens or nothing does. A half-applied invite would leave
an account with no memberships and a token that still looks live.
"""
full_name = (full_name or "").strip()
if not full_name:
raise IdentityError(422, "name is required")
pw_hash = hash_password(password)
con = connect()
try:
con.execute("BEGIN IMMEDIATE")
r = con.execute(
"SELECT * FROM invites WHERE token_hash=? AND consumed_at IS NULL"
" AND revoked_at IS NULL AND expires_at > ?",
(_hash_token(token), _now())).fetchone()
if not r:
con.rollback()
raise IdentityError(410, "this invitation is no longer valid")
inv = dict(r)
if con.execute("SELECT 1 FROM people WHERE email=?", (inv["email"],)).fetchone():
con.rollback()
raise IdentityError(409, "an account already exists for this address")
pid = str(uuid.uuid4())
con.execute(
"INSERT INTO people (id, email, full_name, preferred_name, phone, password_hash,"
" global_role, registered_adult, created_at, created_by)"
" VALUES (?,?,?,?,?,?,?,0,?,?)",
(pid, inv["email"], full_name, (preferred_name or "").strip() or None,
(phone or "").strip() or None, pw_hash, inv["global_role"], _now(),
inv["created_by"]))
for u in json.loads(inv["units"] or "[]"):
con.execute(
"INSERT INTO memberships (person_id, unit_id, role, title, created_at, created_by)"
" VALUES (?,?,?,?,?,?)",
(pid, u["unit_id"], u["role"], u.get("title"), _now(), inv["created_by"]))
con.execute("UPDATE invites SET consumed_at=?, person_id=? WHERE id=?",
(_now(), pid, inv["id"]))
log_event("invite.consumed", person_id=pid, email=inv["email"], ip=ip, con=con)
con.commit()
finally:
con.close()
return get_person(pid)
# ---------------------------------------------------------------------------
# Sessions
# ---------------------------------------------------------------------------
#
# Rows, not signed cookies. A leader stepping down has to be revocable now
# rather than at token expiry, and "sign out everywhere" has to be possible.
def start_session(person_id, ip=None, user_agent=None):
tok = secrets.token_urlsafe(32)
con = connect()
try:
con.execute(
"INSERT INTO sessions (id, token_hash, person_id, created_at, expires_at,"
" last_seen_at, ip, user_agent, revoked_at) VALUES (?,?,?,?,?,?,?,?,NULL)",
(str(uuid.uuid4()), _hash_token(tok), person_id, _now(),
_plus(days=SESSION_ABSOLUTE_DAYS), _now(), ip, (user_agent or "")[:200]))
con.execute("UPDATE people SET last_login_at=? WHERE id=?", (_now(), person_id))
con.commit()
finally:
con.close()
return tok
def session_person(token):
"""The person behind a session cookie, or None.
Enforces both bounds: an absolute expiry and an idle timeout. Touches
last_seen_at on success, so the idle clock tracks use rather than login.
"""
if not token:
return None
con = connect()
try:
r = con.execute(
"SELECT * FROM sessions WHERE token_hash=? AND revoked_at IS NULL",
(_hash_token(token),)).fetchone()
if not r:
return None
now = _now()
if r["expires_at"] <= now:
return None
idle_cutoff = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(hours=SESSION_IDLE_HOURS)).isoformat(timespec="seconds")
if r["last_seen_at"] <= idle_cutoff:
return None
p = _person_row(con, con.execute(
"SELECT * FROM people WHERE id=?", (r["person_id"],)).fetchone())
if not p or p.get("disabled_at"):
return None
con.execute("UPDATE sessions SET last_seen_at=? WHERE id=?", (now, r["id"]))
con.commit()
return p
finally:
con.close()
def end_session(token):
con = connect()
try:
con.execute("UPDATE sessions SET revoked_at=? WHERE token_hash=? AND revoked_at IS NULL",
(_now(), _hash_token(token)))
con.commit()
finally:
con.close()
def end_all_sessions(person_id):
con = connect()
try:
cur = con.execute("UPDATE sessions SET revoked_at=? WHERE person_id=? AND revoked_at IS NULL",
(_now(), person_id))
con.commit()
return cur.rowcount
finally:
con.close()
# ---------------------------------------------------------------------------
# Login
# ---------------------------------------------------------------------------
def recent_failures(email):
cutoff = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(minutes=LOGIN_WINDOW_MINUTES)).isoformat(timespec="seconds")
con = connect()
try:
return con.execute(
"SELECT COUNT(*) c FROM auth_events WHERE kind='login.failed' AND email=? AND at > ?",
((email or "").lower(), cutoff)).fetchone()["c"]
finally:
con.close()
def authenticate(email, password, ip=None, user_agent=None):
"""Returns (person, session_token). Raises IdentityError on any failure.
One message for every failure mode. Distinguishing "no such account" from
"wrong password" enumerates the address list of a volunteer organisation.
"""
email = (email or "").strip().lower()
if recent_failures(email) >= LOGIN_MAX_FAILURES:
log_event("login.throttled", email=email, ip=ip)
raise IdentityError(429, "too many attempts. Wait %d minutes and try again."
% LOGIN_WINDOW_MINUTES)
con = connect()
try:
r = con.execute("SELECT * FROM people WHERE email=?", (email,)).fetchone()
stored = r["password_hash"] if r else None
finally:
con.close()
ok = bool(stored) and verify_password(password or "", stored)
if not ok or (r and r["disabled_at"]):
log_event("login.failed", person_id=(r["id"] if r else None), email=email, ip=ip,
detail="disabled" if (r and r["disabled_at"]) else "bad credentials")
raise IdentityError(401, "that email and password do not match an account")
tok = start_session(r["id"], ip=ip, user_agent=user_agent)
log_event("login.ok", person_id=r["id"], email=email, ip=ip)
return get_person(r["id"]), tok
# ---------------------------------------------------------------------------
# Bootstrap
# ---------------------------------------------------------------------------
#
# The first owner has nobody to invite them, so the seed is a boot action and
# never a UI one. It goes inert the moment any person exists, and stays in the
# code as a disaster path rather than being deleted.
#
# A live invite is REUSED across restarts. Minting a fresh token on every boot
# would invalidate the link the recipient is already holding, every deploy.
def bootstrap():
"""Returns (url, minted) or (None, False). Never raises: a boot path that
can take the site down over a misconfigured email address is worse than
one that logs and carries on."""
try:
if people_count() > 0:
return None, False
if not ADMIN_BOOTSTRAP_EMAIL:
print("identity: no people and ADMIN_BOOTSTRAP_EMAIL is unset, "
"nobody can sign in", flush=True)
return None, False
live = live_invite_for(ADMIN_BOOTSTRAP_EMAIL)
if live:
print("identity: owner invite already outstanding for %s, expires %s"
% (ADMIN_BOOTSTRAP_EMAIL, live["expires_at"]), flush=True)
return None, False
_, tok = create_invite(ADMIN_BOOTSTRAP_EMAIL, global_role="owner",
created_by="bootstrap")
url = invite_url(tok)
print("identity: BOOTSTRAP OWNER INVITE for %s -> %s"
% (ADMIN_BOOTSTRAP_EMAIL, url), flush=True)
return url, True
except Exception as e:
print("identity: bootstrap failed: %s" % e, flush=True)
return None, False
# ---------------------------------------------------------------------------
# Site settings
# ---------------------------------------------------------------------------
#
# Typed key-value pairs for the handful of site-wide values that change more
# often than the code does. SETTINGS_KEYS is the whole contract: a key not in
# it cannot be written, and a key absent from the table - or holding a value
# its checker no longer accepts - falls back to the code default. A mangled
# row can make the site stale, never make it crash.
#
# Setting a value to null clears the row, which IS the fallback: there is no
# stored-but-empty state to reason about.
def _setting_text(v):
v = str(v).strip()
if not v:
raise IdentityError(422, "value must not be blank; send null to clear to the default")
return v
def _setting_https_url(v):
v = _setting_text(v)
if not v.startswith("https://") or any(c in v for c in " \"'<>"):
raise IdentityError(422, "value must be a plain https:// URL")
return v
# key -> (code default, checker)
SETTINGS_KEYS = {
"nearby_source_name": ("Continental District unit list", _setting_text),
"nearby_source_url": ("https://tinyurl.com/ContinentalScouts", _setting_https_url),
}
def get_setting(key):
"""The effective value: the stored one if present and still valid, else
the code default. Unknown keys are a programming error and raise."""
default, check = SETTINGS_KEYS[key]
con = connect()
try:
r = con.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
finally:
con.close()
if not r:
return default
try:
return check(r["value"])
except IdentityError:
return default
def all_settings():
"""Every known key with its effective value, for the settings screen.
Rows for keys the registry no longer knows are not shown - they are dead
weight, not settings."""
con = connect()
try:
stored = {r["key"]: dict(r) for r in con.execute("SELECT * FROM settings")}
finally:
con.close()
out = []
for key, (default, _check) in SETTINGS_KEYS.items():
row = stored.get(key)
out.append({
"key": key,
"value": get_setting(key),
"default": default,
"is_set": row is not None,
"updated_at": row["updated_at"] if row else None,
"updated_by": row["updated_by"] if row else None,
})
return out
def set_setting(key, value, actor=None):
"""Write one setting, or clear it back to the default with value=null."""
if key not in SETTINGS_KEYS:
raise IdentityError(422, "unknown setting %r. Known keys: %s"
% (key, ", ".join(sorted(SETTINGS_KEYS))))
default, check = SETTINGS_KEYS[key]
con = connect()
try:
if value is None:
con.execute("DELETE FROM settings WHERE key=?", (key,))
else:
value = check(value)
con.execute(
"INSERT INTO settings (key, value, updated_at, updated_by)"
" VALUES (?,?,?,?) ON CONFLICT(key) DO UPDATE SET"
" value=excluded.value, updated_at=excluded.updated_at,"
" updated_by=excluded.updated_by",
(key, value, _now(), actor))
con.commit()
finally:
con.close()
return [s for s in all_settings() if s["key"] == key][0]