From aaa77be14d9e849e520255af05f223e0a6c91102 Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Fri, 4 Sep 2026 18:18:39 -0400 Subject: [PATCH] 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. --- app/admin_api.py | 110 ++++++++++++++- app/auth.py | 144 ++++++++++++++++++- app/identity.py | 299 ++++++++++++++++++++++++++++++++++++++++ tests/smoke_identity.py | 51 +++++++ 4 files changed, 598 insertions(+), 6 deletions(-) diff --git a/app/admin_api.py b/app/admin_api.py index 5af3d77..45ac32c 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -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. # ---------------------------------------------------------------------------- -_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): @@ -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.""" _auth(request, x_admin_token, "history:read") 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} diff --git a/app/auth.py b/app/auth.py index 2cede5a..51073ac 100644 --- a/app/auth.py +++ b/app/auth.py @@ -254,14 +254,148 @@ def account(request: Request): if not rows: rows.append('
No roles assigned yet.
') + 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('
Site-wide' + '%s
' + % _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('
%s' + '%s%s
' + % (_esc(m["short_name"]), _esc(ROLE_LABEL.get(m["role"], m["role"])), title)) + if not rows: + rows.append('
No roles assigned yet.
') 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 = '
%s
' % _esc(error) if error and error_form == "details" else "" + err_p = '
%s
' % _esc(error) if error and error_form == "password" else "" body = _shell( "Your account", _esc(person["email"]), - f"""
+ f"""{'
%s
' % _esc(flash) if flash else ''}
{''.join(rows)}
-
-

Changing your own details is not built yet. Ask an administrator.

""") - return HTMLResponse(_render("Your account", body.replace( +
+

Your details

{err_d} + + + + + + + +
+
+

Change password

{err_p} + + + + + + + +

At least 12 characters. Changing it signs out your other devices.

+
+
+

Your email address and roles are set by an administrator.

""") + return body.replace( "

Your account

", - "

Hello, %s

" % _esc(name)))) + "

Hello, %s

" % _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 %s." % _esc(person["email"]), + f"""
+ + + + + +
+

At least 12 characters. Every device signed in as you will be signed out.

""", + 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 diff --git a/app/identity.py b/app/identity.py index 835179f..55c9005 100644 --- a/app/identity.py +++ b/app/identity.py @@ -195,6 +195,22 @@ CREATE TABLE IF NOT EXISTS settings ( 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 -- 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 @@ -1121,3 +1137,286 @@ def list_events(limit=200, kind_prefix=None, before=None): return [dict(r) for r in con.execute(sql, vals)] finally: 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() diff --git a/tests/smoke_identity.py b/tests/smoke_identity.py index f78b00d..f8505ff 100644 --- a/tests/smoke_identity.py +++ b/tests/smoke_identity.py @@ -215,6 +215,57 @@ kinds = {r["kind"] for r in con.execute("SELECT DISTINCT kind FROM auth_events") con.close() 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") 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")