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
+299
View File
@@ -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()