people management, account self-service, password reset by link

Until now nobody could be invited without a script and a forgotten
password was a locked account. The identity layer already had invites,
sessions and the capability map; this wires the levers to them.

Admin API (people:invite_leader and up; the break-glass token cannot act
here, it has no person): list people with roles, memberships, live
sessions and keys, plus open invites and what YOU may grant; invite with
the URL returned once (no outbound mail yet, hand it over yourself);
revoke an invite; replace roles and memberships in full (owner only);
disable and enable (owner only, never yourself, never the last owner;
disabling ends sessions now and keys die through can()); mint a one-time
24-hour reset link (admin may reset anyone but an owner). Every one logs
who, whom and what.

Site: /account grows details and change-password forms (current password
required; the other devices are signed out, this one stays). /reset/{token}
sets a password, burns the link, ends every session and signs the person
in. Expired, used, revoked and never-existed read the same.

tests/smoke_identity.py 92 -> 123. Driven end to end on a throwaway with
a DB copy: invite, accept, account forms, reset link used then reused
(410), disable (session dies, login 401), enable, roles.
This commit is contained in:
2026-09-04 18:18:39 -04:00
parent 7287f0b515
commit aaa77be14d
4 changed files with 598 additions and 6 deletions
+109 -1
View File
@@ -470,7 +470,7 @@ def whoami(request: Request, x_admin_token: str = Header(None)):
# out of each handler's own _auth() call, so it cannot drift from the check. # out of each handler's own _auth() call, so it cannot drift from the check.
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
_CAP_RE = re.compile(r"""_auth\([^)]*?["']([a-z_]+:[a-z_]+)["']""") _CAP_RE = re.compile(r"""_(?:auth|people_actor)\([^)]*?["']([a-z_]+:[a-z_]+)["']""")
def route_capability(endpoint): def route_capability(endpoint):
@@ -652,3 +652,111 @@ def get_history(request: Request, limit: int = Query(200, ge=1, le=500), kind: s
2026-09-04 lands here with who did it and a one-line diff.""" 2026-09-04 lands here with who did it and a one-line diff."""
_auth(request, x_admin_token, "history:read") _auth(request, x_admin_token, "history:read")
return {"events": identity.list_events(limit=limit, kind_prefix=kind, before=before)} return {"events": identity.list_events(limit=limit, kind_prefix=kind, before=before)}
# ----------------------------------------------------------------------------
# People. Invite, roles, disable/enable, password reset links. The rules
# (never zero owners, never disable yourself, grant only what you hold) live
# in identity.py; these routes only translate to HTTP. A session or key is
# required: the break-glass token has no person to act as.
# ----------------------------------------------------------------------------
def _people_actor(request, token, capability):
_auth(request, token, capability)
person = _person(request)
if not person:
raise HTTPException(403, "people management needs a signed-in person; the admin token cannot act here")
return person
@router.get("/people")
def list_people(request: Request, x_admin_token: str = Header(None)):
"""Everyone with an account, their roles and memberships, active
sessions and keys; plus open invites; plus what YOU may grant."""
actor = _people_actor(request, x_admin_token, "people:invite_leader")
return {"people": identity.list_people(), "invites": identity.list_open_invites(),
"units": identity.list_units(include_inactive=True),
"grantable": identity.grantable_roles(actor), "me": actor["id"]}
@router.post("/people/invite", status_code=201)
def invite(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Mint an invite. Body: email, global_role (optional), units
([{unit_id, role, title}]). Returns the invite URL ONCE; there is no
outbound mail yet, so hand it over yourself. Reissuing for the same
address revokes the earlier link. 14-day expiry."""
actor = _people_actor(request, x_admin_token, "people:invite_leader")
try:
row, url = identity.invite_person(actor, payload.get("email"), payload.get("global_role") or None,
payload.get("units") or [])
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
row.pop("token_hash", None)
row["url"] = url
return row
@router.delete("/people/invite/{invite_id}")
def revoke_invite(request: Request, invite_id: str, x_admin_token: str = Header(None)):
actor = _people_actor(request, x_admin_token, "people:invite_leader")
row = identity.revoke_invite(actor, invite_id)
if not row:
raise HTTPException(404, "no such open invite")
row.pop("token_hash", None)
return row
@router.put("/people/{person_id}/roles")
def put_roles(request: Request, person_id: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Replace a person's global role and unit memberships. Body:
global_role (owner|admin|null), units ([{unit_id, role, title}], the full
list). Owner only. Refuses to leave zero owners."""
actor = _people_actor(request, x_admin_token, "people:manage")
try:
rec = identity.set_roles(actor, person_id, payload.get("global_role") or None, payload.get("units") or [])
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
if not rec:
raise HTTPException(404, "no such person")
return rec
@router.post("/people/{person_id}/disable")
def disable(request: Request, person_id: str, payload: dict = Body(None), x_admin_token: str = Header(None)):
"""Disable a person now: sessions end, keys stop, documents close. The
row stays. Owner only; not yourself; not the last owner."""
actor = _people_actor(request, x_admin_token, "people:manage")
try:
rec = identity.disable_person(actor, person_id, (payload or {}).get("reason"))
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
if not rec:
raise HTTPException(404, "no such person")
return rec
@router.post("/people/{person_id}/enable")
def enable(request: Request, person_id: str, x_admin_token: str = Header(None)):
actor = _people_actor(request, x_admin_token, "people:manage")
try:
rec = identity.enable_person(actor, person_id)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
if not rec:
raise HTTPException(404, "no such person")
return rec
@router.post("/people/{person_id}/reset", status_code=201)
def reset_link(request: Request, person_id: str, x_admin_token: str = Header(None)):
"""Mint a one-time password-reset link (24 h), returned ONCE. Their
current password keeps working until the link is used; using it ends
every session they have. Admin may reset anyone but an owner."""
actor = _people_actor(request, x_admin_token, "people:invite_admin")
try:
url = identity.create_reset(actor, person_id)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
if not url:
raise HTTPException(404, "no such person")
return {"url": url, "expires_hours": identity.RESET_TTL_HOURS}
+139 -5
View File
@@ -254,14 +254,148 @@ def account(request: Request):
if not rows: if not rows:
rows.append('<div class="rrow"><span class="v">No roles assigned yet.</span></div>') rows.append('<div class="rrow"><span class="v">No roles assigned yet.</span></div>')
return HTMLResponse(_render("Your account", _account_page(person, request.query_params.get("done"))))
def _account_page(person, done=None, error=None, error_form=None):
rows = []
if person.get("global_role"):
rows.append('<div class="rrow"><span class="k" style="min-width:110px">Site-wide</span>'
'<span class="v">%s</span></div>'
% _esc(ROLE_LABEL.get(person["global_role"], person["global_role"])))
for m in person["memberships"]:
title = " · %s" % _esc(m["title"]) if m["title"] else ""
rows.append('<div class="rrow"><span class="k" style="min-width:110px">%s</span>'
'<span class="v">%s%s</span></div>'
% (_esc(m["short_name"]), _esc(ROLE_LABEL.get(m["role"], m["role"])), title))
if not rows:
rows.append('<div class="rrow"><span class="v">No roles assigned yet.</span></div>')
name = person.get("preferred_name") or person.get("full_name") or person["email"] name = person.get("preferred_name") or person.get("full_name") or person["email"]
flash = {"details": "Details saved.", "password": "Password changed. Your other devices were signed out."}.get(done or "")
err_d = '<div class="autherr">%s</div>' % _esc(error) if error and error_form == "details" else ""
err_p = '<div class="autherr">%s</div>' % _esc(error) if error and error_form == "password" else ""
body = _shell( body = _shell(
"Your account", _esc(person["email"]), "Your account", _esc(person["email"]),
f"""<div class="mcard" style="padding:18px 20px;margin:0 0 18px"> f"""{'<div class="success">%s</div>' % _esc(flash) if flash else ''}<div class="mcard" style="padding:18px 20px;margin:0 0 18px">
{''.join(rows)} {''.join(rows)}
</div> </div>
<form method="post" action="/logout"><button class="cta" type="submit">Sign out</button></form> <form method="post" action="/account/details" class="mcard" style="padding:18px 20px;margin:0 0 18px">
<p class="authhint">Changing your own details is not built yet. Ask an administrator.</p>""") <h2 class="sec" style="font-size:1.1rem;margin:0 0 4px">Your details</h2>{err_d}
return HTMLResponse(_render("Your account", body.replace( <label for="full_name">Full name</label>
<input id="full_name" name="full_name" required value="{_esc(person.get('full_name'))}">
<label for="preferred_name">Preferred name <span style="font-weight:400;color:#6B7280">(optional)</span></label>
<input id="preferred_name" name="preferred_name" value="{_esc(person.get('preferred_name'))}">
<label for="phone">Mobile <span style="font-weight:400;color:#6B7280">(optional)</span></label>
<input id="phone" name="phone" type="tel" value="{_esc(person.get('phone'))}">
<button class="cta" type="submit" style="margin-top:16px">Save details</button>
</form>
<form method="post" action="/account/password" class="mcard" style="padding:18px 20px;margin:0 0 18px">
<h2 class="sec" style="font-size:1.1rem;margin:0 0 4px">Change password</h2>{err_p}
<label for="current">Current password</label>
<input id="current" name="current" type="password" autocomplete="current-password" required>
<label for="new">New password</label>
<input id="new" name="new" type="password" autocomplete="new-password" required minlength="12">
<label for="confirm">Confirm new password</label>
<input id="confirm" name="confirm" type="password" autocomplete="new-password" required minlength="12">
<button class="cta" type="submit" style="margin-top:16px">Change password</button>
<p class="authhint">At least 12 characters. Changing it signs out your other devices.</p>
</form>
<form method="post" action="/logout"><button class="cta" type="submit" style="background:#1E2F52;color:#fff">Sign out</button></form>
<p class="authhint">Your email address and roles are set by an administrator.</p>""")
return body.replace(
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Your account</h1>", "<h1 class=\"sec\" style=\"margin:0 0 6px\">Your account</h1>",
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Hello, %s</h1>" % _esc(name)))) "<h1 class=\"sec\" style=\"margin:0 0 6px\">Hello, %s</h1>" % _esc(name))
@router.post("/account/details")
def account_details(request: Request, full_name: str = Form(""), preferred_name: str = Form(""),
phone: str = Form("")):
person = current_person(request)
if not person:
return RedirectResponse(url="/login", status_code=303)
try:
identity.update_own_details(person["id"], full_name, preferred_name, phone)
except identity.IdentityError as e:
return HTMLResponse(_render("Your account", _account_page(person, error=e.detail, error_form="details")),
status_code=e.status)
return RedirectResponse(url="/account?done=details", status_code=303)
@router.post("/account/password")
def account_password(request: Request, current: str = Form(""), new: str = Form(""), confirm: str = Form("")):
person = current_person(request)
if not person:
return RedirectResponse(url="/login", status_code=303)
if new != confirm:
return HTMLResponse(_render("Your account", _account_page(person, error="Those two passwords do not match.",
error_form="password")), status_code=422)
try:
identity.change_password(person["id"], current, new, ip=_client_ip(request))
except identity.IdentityError as e:
return HTMLResponse(_render("Your account", _account_page(person, error=e.detail, error_form="password")),
status_code=e.status)
# End the other sessions, keep this one: the person is still here.
tok = request.cookies.get(COOKIE)
con = identity.connect()
try:
con.execute("UPDATE sessions SET revoked_at=? WHERE person_id=? AND revoked_at IS NULL AND token_hash<>?",
(identity._now(), person["id"], identity._hash_token(tok or "")))
con.commit()
finally:
con.close()
return RedirectResponse(url="/account?done=password", status_code=303)
# ---------------------------------------------------------------------------
# Password reset by link. The link is minted by an admin on the console and
# handed over out of band; there is no outbound mail. Same posture as
# invites: expired, used, revoked and never-existed all read the same.
# ---------------------------------------------------------------------------
DEAD_RESET = ("This reset link is no longer valid. It may have been used already, replaced by "
"a newer one, or expired. Ask an administrator for a fresh link.")
def _reset_form(token, person, error=None):
return _shell(
"Choose a new password", "For <strong>%s</strong>." % _esc(person["email"]),
f"""<form method="post" action="/reset/{_esc(token)}">
<label for="password">New password</label>
<input id="password" name="password" type="password" autocomplete="new-password" required minlength="12">
<label for="confirm">Confirm password</label>
<input id="confirm" name="confirm" type="password" autocomplete="new-password" required minlength="12">
<button class="cta" type="submit">Set password and sign in</button>
</form>
<p class="authhint">At least 12 characters. Every device signed in as you will be signed out.</p>""",
error)
@router.get("/reset/{token}", response_class=HTMLResponse)
def reset_form(request: Request, token: str):
person = identity.peek_reset(token)
if not person:
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
return HTMLResponse(_render("Choose a new password", _reset_form(token, person)))
@router.post("/reset/{token}")
def reset_accept(request: Request, token: str, password: str = Form(""), confirm: str = Form("")):
person = identity.peek_reset(token)
if not person:
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
if password != confirm:
return HTMLResponse(_render("Choose a new password",
_reset_form(token, person, "Those two passwords do not match.")), status_code=422)
try:
person = identity.consume_reset(token, password, ip=_client_ip(request))
except identity.IdentityError as e:
if e.status == 410:
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
return HTMLResponse(_render("Choose a new password", _reset_form(token, person, e.detail)),
status_code=e.status)
tok = identity.start_session(person["id"], ip=_client_ip(request),
user_agent=request.headers.get("user-agent"))
resp = RedirectResponse(url="/account", status_code=303)
resp.set_cookie(COOKIE, tok, max_age=identity.SESSION_ABSOLUTE_DAYS * 86400,
httponly=True, secure=COOKIE_SECURE, samesite="lax", path="/")
return resp
+299
View File
@@ -195,6 +195,22 @@ CREATE TABLE IF NOT EXISTS settings (
updated_by TEXT updated_by TEXT
); );
-- Password resets. Admin-issued one-time links (there is no outbound mail
-- yet, so the admin hands the link over however they talk to the person).
-- Same shape as invites: only the sha256 of the token is stored, single
-- use, short expiry, reissue revokes the prior link.
CREATE TABLE IF NOT EXISTS password_resets (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
person_id TEXT NOT NULL REFERENCES people(id),
created_at TEXT NOT NULL,
created_by TEXT,
expires_at TEXT NOT NULL,
consumed_at TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_password_resets_person ON password_resets(person_id, consumed_at, revoked_at);
-- API keys (P3). One row per key; only the sha256 of the key is stored, and -- API keys (P3). One row per key; only the sha256 of the key is stored, and
-- the visible prefix exists so a person can tell their keys apart. Scopes are -- the visible prefix exists so a person can tell their keys apart. Scopes are
-- a JSON list and are a SUBSET of the owner's capabilities, checked at mint -- a JSON list and are a SUBSET of the owner's capabilities, checked at mint
@@ -1121,3 +1137,286 @@ def list_events(limit=200, kind_prefix=None, before=None):
return [dict(r) for r in con.execute(sql, vals)] return [dict(r) for r in con.execute(sql, vals)]
finally: finally:
con.close() con.close()
# ---------------------------------------------------------------------------
# People management (post-P4, 2026-09-04). Who exists, what they hold, and
# the levers: invite, change roles, disable, enable, reset a password.
# Rules that must hold whatever the caller does live here, not in routes:
# never zero owners, never disable yourself, an admin cannot grant what they
# do not hold.
# ---------------------------------------------------------------------------
RESET_TTL_HOURS = 24
def list_people(include_disabled=True):
con = connect()
try:
sql = "SELECT * FROM people"
if not include_disabled:
sql += " WHERE disabled_at IS NULL"
sql += " ORDER BY COALESCE(global_role, 'z'), email"
rows = [_person_row(con, r) for r in con.execute(sql)]
for p in rows:
p["active_sessions"] = con.execute(
"SELECT count(*) FROM sessions WHERE person_id=? AND revoked_at IS NULL AND expires_at > ?",
(p["id"], _now())).fetchone()[0]
p["active_keys"] = con.execute(
"SELECT count(*) FROM api_keys WHERE person_id=? AND revoked_at IS NULL", (p["id"],)).fetchone()[0]
return rows
finally:
con.close()
def list_open_invites():
"""Unconsumed, unrevoked invites, expired ones included and marked, so an
admin can see who has not finished signing up."""
con = connect()
try:
now = _now()
out = []
for r in con.execute("SELECT * FROM invites WHERE consumed_at IS NULL AND revoked_at IS NULL"
" ORDER BY created_at DESC"):
d = dict(r); d.pop("token_hash", None)
d["units"] = json.loads(d.get("units") or "[]")
d["expired"] = d["expires_at"] <= now
out.append(d)
return out
finally:
con.close()
def _owner_count(con, excluding=None):
sql = "SELECT count(*) FROM people WHERE global_role='owner' AND disabled_at IS NULL"
vals = ()
if excluding:
sql += " AND id<>?"; vals = (excluding,)
return con.execute(sql, vals).fetchone()[0]
def grantable_roles(actor):
"""What this actor may hand out. people:manage (owner) grants anything;
people:invite_admin grants admin and below; people:invite_leader grants
unit roles only."""
if can(actor, "people:manage"):
return {"global": ["owner", "admin"], "unit": list(UNIT_ROLES)}
if can(actor, "people:invite_admin"):
return {"global": ["admin"], "unit": list(UNIT_ROLES)}
if can(actor, "people:invite_leader"):
return {"global": [], "unit": list(UNIT_ROLES)}
return {"global": [], "unit": []}
def _check_grant(actor, global_role, units):
g = grantable_roles(actor)
if global_role and global_role not in g["global"]:
raise IdentityError(403, "you cannot grant the %s role" % global_role)
for u in units or []:
if u.get("role") not in g["unit"]:
raise IdentityError(403, "you cannot grant the unit role %r" % u.get("role"))
def invite_person(actor, email, global_role=None, units=None):
"""create_invite, gated on what the actor may grant. Returns (row, url)."""
_check_grant(actor, global_role, units)
if get_person_by_email(email):
raise IdentityError(409, "that address already has an account; reset its password or change its roles instead")
row, tok = create_invite(email, global_role=global_role, units=units, created_by=actor["id"])
return row, invite_url(tok)
def revoke_invite(actor, invite_id):
con = connect()
try:
r = con.execute("SELECT * FROM invites WHERE id=? AND consumed_at IS NULL AND revoked_at IS NULL",
(invite_id,)).fetchone()
if not r:
return None
con.execute("UPDATE invites SET revoked_at=? WHERE id=?", (_now(), invite_id))
log_event("invite.revoked", actor_id=actor["id"], email=r["email"], con=con)
con.commit()
return dict(con.execute("SELECT * FROM invites WHERE id=?", (invite_id,)).fetchone())
finally:
con.close()
def set_roles(actor, person_id, global_role=None, units=None):
"""Replace a person's global role and unit memberships in one transaction.
`units` is the full desired list [{unit_id, role, title}]; absent
memberships are removed. Requires people:manage. Never leaves zero owners."""
if not can(actor, "people:manage"):
raise IdentityError(403, "changing roles needs people:manage")
if global_role and global_role not in GLOBAL_ROLES:
raise IdentityError(422, "global_role must be one of %s, or null" % (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"),))
con = connect()
try:
before = _person_row(con, con.execute("SELECT * FROM people WHERE id=?", (person_id,)).fetchone())
if not before:
return None
if before.get("global_role") == "owner" and global_role != "owner" and _owner_count(con, excluding=person_id) == 0:
raise IdentityError(409, "that is the last owner; make someone else owner first")
con.execute("UPDATE people SET global_role=? WHERE id=?", (global_role or None, person_id))
con.execute("DELETE FROM memberships WHERE person_id=?", (person_id,))
for u in units:
con.execute("INSERT INTO memberships (person_id, unit_id, role, title, created_at, created_by)"
" VALUES (?,?,?,?,?,?)", (person_id, u["unit_id"], u["role"], (u.get("title") or "").strip() or None,
_now(), actor["id"]))
after = _person_row(con, con.execute("SELECT * FROM people WHERE id=?", (person_id,)).fetchone())
log_event("person.roles", person_id=person_id, actor_id=actor["id"], email=before["email"],
detail="global %s -> %s; units %s -> %s" % (
before.get("global_role") or "-", global_role or "-",
", ".join("%s:%s" % (m["slug"], m["role"]) for m in before["memberships"]) or "-",
", ".join("%s:%s" % (m["slug"], m["role"]) for m in after["memberships"]) or "-"), con=con)
con.commit()
return after
finally:
con.close()
def disable_person(actor, person_id, reason=None):
"""Disable: sessions end now, keys stop through can(), documents close.
The row stays. Cannot disable yourself or the last owner."""
if not can(actor, "people:manage"):
raise IdentityError(403, "disabling a person needs people:manage")
if person_id == actor["id"]:
raise IdentityError(409, "you cannot disable yourself")
con = connect()
try:
p = _person_row(con, con.execute("SELECT * FROM people WHERE id=?", (person_id,)).fetchone())
if not p:
return None
if p.get("disabled_at"):
raise IdentityError(409, "already disabled")
if p.get("global_role") == "owner" and _owner_count(con, excluding=person_id) == 0:
raise IdentityError(409, "that is the last owner")
con.execute("UPDATE people SET disabled_at=?, disabled_reason=? WHERE id=?",
(_now(), (reason or "").strip() or None, person_id))
con.execute("UPDATE sessions SET revoked_at=? WHERE person_id=? AND revoked_at IS NULL", (_now(), person_id))
log_event("person.disabled", person_id=person_id, actor_id=actor["id"], email=p["email"],
detail=(reason or "").strip() or None, con=con)
con.commit()
return _person_row(con, con.execute("SELECT * FROM people WHERE id=?", (person_id,)).fetchone())
finally:
con.close()
def enable_person(actor, person_id):
if not can(actor, "people:manage"):
raise IdentityError(403, "enabling a person needs people:manage")
con = connect()
try:
p = _person_row(con, con.execute("SELECT * FROM people WHERE id=?", (person_id,)).fetchone())
if not p:
return None
if not p.get("disabled_at"):
raise IdentityError(409, "not disabled")
con.execute("UPDATE people SET disabled_at=NULL, disabled_reason=NULL WHERE id=?", (person_id,))
log_event("person.enabled", person_id=person_id, actor_id=actor["id"], email=p["email"], con=con)
con.commit()
return _person_row(con, con.execute("SELECT * FROM people WHERE id=?", (person_id,)).fetchone())
finally:
con.close()
def create_reset(actor, person_id):
"""Mint a one-time password-reset link for a person. An owner may reset
anyone; an admin may reset anyone who is not an owner. The token is
returned once. Reissue revokes the prior link."""
con = connect()
try:
p = _person_row(con, con.execute("SELECT * FROM people WHERE id=?", (person_id,)).fetchone())
if not p:
return None
if p.get("disabled_at"):
raise IdentityError(409, "person is disabled; enable them first")
if p.get("global_role") == "owner" and not can(actor, "people:manage"):
raise IdentityError(403, "only an owner can reset an owner's password")
if not (can(actor, "people:manage") or can(actor, "people:invite_admin")):
raise IdentityError(403, "resetting a password needs people:invite_admin")
tok = secrets.token_urlsafe(32)
con.execute("UPDATE password_resets SET revoked_at=? WHERE person_id=? AND consumed_at IS NULL AND revoked_at IS NULL",
(_now(), person_id))
con.execute("INSERT INTO password_resets (id, token_hash, person_id, created_at, created_by, expires_at)"
" VALUES (?,?,?,?,?,?)", (str(uuid.uuid4()), _hash_token(tok), person_id, _now(), actor["id"],
_plus(hours=RESET_TTL_HOURS)))
log_event("password.reset_issued", person_id=person_id, actor_id=actor["id"], email=p["email"], con=con)
con.commit()
finally:
con.close()
return "%s/reset/%s" % (SITE_BASE_URL, tok)
def peek_reset(token):
"""The person behind a live reset token, or None. Expired, consumed,
revoked and never-existed are all None, deliberately."""
con = connect()
try:
r = con.execute("SELECT * FROM password_resets WHERE token_hash=? AND consumed_at IS NULL"
" AND revoked_at IS NULL AND expires_at > ?", (_hash_token(token or ""), _now())).fetchone()
if not r:
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
return p
finally:
con.close()
def consume_reset(token, new_password, ip=None):
"""Set the password, burn the token, end every session, in one
transaction. Returns the person."""
pw_hash = hash_password(new_password)
con = connect()
try:
r = con.execute("SELECT * FROM password_resets WHERE token_hash=? AND consumed_at IS NULL"
" AND revoked_at IS NULL AND expires_at > ?", (_hash_token(token or ""), _now())).fetchone()
if not r:
raise IdentityError(410, "this reset link is no longer valid")
p = con.execute("SELECT * FROM people WHERE id=?", (r["person_id"],)).fetchone()
if not p or p["disabled_at"]:
raise IdentityError(410, "this reset link is no longer valid")
con.execute("UPDATE people SET password_hash=? WHERE id=?", (pw_hash, p["id"]))
con.execute("UPDATE password_resets SET consumed_at=? WHERE id=?", (_now(), r["id"]))
con.execute("UPDATE sessions SET revoked_at=? WHERE person_id=? AND revoked_at IS NULL", (_now(), p["id"]))
log_event("password.reset", person_id=p["id"], email=p["email"], ip=ip, con=con)
con.commit()
return _person_row(con, con.execute("SELECT * FROM people WHERE id=?", (p["id"],)).fetchone())
finally:
con.close()
def change_password(person_id, current, new, ip=None):
"""Self-service. Needs the current password; ends the OTHER sessions."""
con = connect()
try:
r = con.execute("SELECT * FROM people WHERE id=?", (person_id,)).fetchone()
if not r or not verify_password(current or "", r["password_hash"] or ""):
raise IdentityError(403, "current password is wrong")
pw_hash = hash_password(new)
con.execute("UPDATE people SET password_hash=? WHERE id=?", (pw_hash, person_id))
log_event("password.changed", person_id=person_id, actor_id=person_id, email=r["email"], ip=ip, con=con)
con.commit()
finally:
con.close()
def update_own_details(person_id, full_name=None, preferred_name=None, phone=None):
full_name = (full_name or "").strip()
if not full_name:
raise IdentityError(422, "full name is required")
con = connect()
try:
con.execute("UPDATE people SET full_name=?, preferred_name=?, phone=? WHERE id=?",
(full_name[:80], (preferred_name or "").strip()[:40] or None, (phone or "").strip()[:30] or None, person_id))
con.commit()
return _person_row(con, con.execute("SELECT * FROM people WHERE id=?", (person_id,)).fetchone())
finally:
con.close()
+51
View File
@@ -215,6 +215,57 @@ kinds = {r["kind"] for r in con.execute("SELECT DISTINCT kind FROM auth_events")
con.close() con.close()
check("auth_events records mint and revoke", "apikey.minted" in kinds and "apikey.revoked" in kinds) check("auth_events records mint and revoke", "apikey.minted" in kinds and "apikey.revoked" in kinds)
print("\npeople management")
owner_p = I.get_person(owner["id"]); leader_p = I.get_person(leader["id"])
g = I.grantable_roles(owner_p); check("owner grants anything", g["global"] == ["owner", "admin"] and g["unit"] == ["leader", "member"])
check("leader grants nothing (invites are admin and above)", I.grantable_roles(leader_p) == {"global": [], "unit": []})
raises("leader cannot invite at all", 403, I.invite_person, leader_p, "new@example.test", None,
[{"unit_id": pack["id"], "role": "member"}])
raises("admin cannot grant owner", 403, I.invite_person, dict(owner_p, global_role="admin"), "new@example.test", "owner", [])
raises("existing address cannot be re-invited", 409, I.invite_person, owner_p, "leader@example.test", None,
[{"unit_id": pack["id"], "role": "member"}])
row, url = I.invite_person(dict(owner_p, global_role="admin"), "den2@example.test", None, [{"unit_id": pack["id"], "role": "leader", "title": "Bear Den"}])
check("invite url minted once", url.startswith(I.SITE_BASE_URL + "/invite/") and "token_hash" in row)
inv = [i for i in I.list_open_invites() if i["email"] == "den2@example.test"]
check("open invite listed, units decoded, not expired", len(inv) == 1 and inv[0]["units"][0]["title"] == "Bear Den" and not inv[0]["expired"])
check("revoke invite", I.revoke_invite(owner_p, inv[0]["id"])["revoked_at"] and not [i for i in I.list_open_invites() if i["email"] == "den2@example.test"])
raises("roles need people:manage", 403, I.set_roles, leader_p, leader["id"], "admin", [])
raises("last owner cannot be demoted", 409, I.set_roles, owner_p, owner["id"], "admin", [])
raises("cannot disable yourself", 409, I.disable_person, owner_p, owner["id"])
raises("last owner cannot be disabled", 409, I.disable_person, owner_p, owner["id"])
after = I.set_roles(owner_p, leader["id"], "admin", [{"unit_id": troop["id"], "role": "leader", "title": "SM"}])
check("roles replaced in full", after["global_role"] == "admin" and [m["slug"] for m in after["memberships"]] == ["troop73"])
after = I.set_roles(owner_p, leader["id"], None, [{"unit_id": pack["id"], "role": "leader"}])
check("and back", after["global_role"] is None and [m["slug"] for m in after["memberships"]] == ["pack73"])
ppl = I.list_people(); me = [p for p in ppl if p["id"] == leader["id"]][0]
check("list carries counts", "active_sessions" in me and "active_keys" in me and "password_hash" not in me)
raises("leader cannot issue a reset", 403, I.create_reset, leader_p, owner["id"])
raises("admin cannot reset an owner", 403, I.create_reset, dict(owner_p, global_role="admin", capabilities=[]), owner["id"])
url = I.create_reset(owner_p, leader["id"]); rtok = url.rsplit("/", 1)[-1]
check("reset link minted", "/reset/" in url and I.peek_reset(rtok)["id"] == leader["id"])
stale = I.create_reset(owner_p, leader["id"]).rsplit("/", 1)[-1]
check("reissue revokes the prior link", I.peek_reset(rtok) is None and I.peek_reset(stale) is not None)
s_before = I.start_session(leader["id"])
raises("short password on reset", 422, I.consume_reset, stale, "short")
I.consume_reset(stale, "a-brand-new-long-password")
check("reset sets the password and ends sessions", I.authenticate("leader@example.test", "a-brand-new-long-password")[0] is not None
and I.session_person(s_before) is None and I.peek_reset(stale) is None)
raises("used link is dead", 410, I.consume_reset, stale, "another-long-password-here")
raises("change password needs the current one", 403, I.change_password, leader["id"], "wrong-password-here", "yet-another-long-pw")
I.change_password(leader["id"], "a-brand-new-long-password", "yet-another-long-password")
check("self change works", I.authenticate("leader@example.test", "yet-another-long-password")[0] is not None)
raises("details need a name", 422, I.update_own_details, leader["id"], "")
check("details update", I.update_own_details(leader["id"], "Den Leader Two", "Deb", "555-0100")["preferred_name"] == "Deb")
d = I.disable_person(owner_p, leader["id"], "moved away")
check("disabled: row kept, reason, sessions gone, auth refused", d["disabled_at"] and d["disabled_reason"] == "moved away"
and I.session_person(I.start_session(leader["id"])) is None)
raises("reset for a disabled person", 409, I.create_reset, owner_p, leader["id"])
raises("disable twice", 409, I.disable_person, owner_p, leader["id"])
check("enabled again", I.enable_person(owner_p, leader["id"])["disabled_at"] is None)
kinds = {r["kind"] for r in I.list_events(limit=500)}
check("people actions in the log", {"invite.revoked", "person.roles", "person.disabled", "person.enabled",
"password.reset_issued", "password.reset", "password.changed"} <= kinds)
print("\nmeeting words") print("\nmeeting words")
D = dict(MEETING_DAY="Tuesday", MEETING_DAYS="Tuesdays", MEETING_DAY_ABBR="Tue", PACK_TIME="6:00 PM", D = dict(MEETING_DAY="Tuesday", MEETING_DAYS="Tuesdays", MEETING_DAY_ABBR="Tue", PACK_TIME="6:00 PM",
TROOP_TIME="7:30 PM", PACK_CLOCK="6:00", TROOP_CLOCK="7:30") TROOP_TIME="7:30 PM", PACK_CLOCK="6:00", TROOP_CLOCK="7:30")