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.
# ----------------------------------------------------------------------------
_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}