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:
+109
-1
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user