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:
+299
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user