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.
|
# 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):
|
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."""
|
2026-09-04 lands here with who did it and a one-line diff."""
|
||||||
_auth(request, x_admin_token, "history:read")
|
_auth(request, x_admin_token, "history:read")
|
||||||
return {"events": identity.list_events(limit=limit, kind_prefix=kind, before=before)}
|
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}
|
||||||
|
|||||||
+139
-5
@@ -254,14 +254,148 @@ def account(request: Request):
|
|||||||
if not rows:
|
if not rows:
|
||||||
rows.append('<div class="rrow"><span class="v">No roles assigned yet.</span></div>')
|
rows.append('<div class="rrow"><span class="v">No roles assigned yet.</span></div>')
|
||||||
|
|
||||||
|
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('<div class="rrow"><span class="k" style="min-width:110px">Site-wide</span>'
|
||||||
|
'<span class="v">%s</span></div>'
|
||||||
|
% _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('<div class="rrow"><span class="k" style="min-width:110px">%s</span>'
|
||||||
|
'<span class="v">%s%s</span></div>'
|
||||||
|
% (_esc(m["short_name"]), _esc(ROLE_LABEL.get(m["role"], m["role"])), title))
|
||||||
|
if not rows:
|
||||||
|
rows.append('<div class="rrow"><span class="v">No roles assigned yet.</span></div>')
|
||||||
name = person.get("preferred_name") or person.get("full_name") or person["email"]
|
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 = '<div class="autherr">%s</div>' % _esc(error) if error and error_form == "details" else ""
|
||||||
|
err_p = '<div class="autherr">%s</div>' % _esc(error) if error and error_form == "password" else ""
|
||||||
body = _shell(
|
body = _shell(
|
||||||
"Your account", _esc(person["email"]),
|
"Your account", _esc(person["email"]),
|
||||||
f"""<div class="mcard" style="padding:18px 20px;margin:0 0 18px">
|
f"""{'<div class="success">%s</div>' % _esc(flash) if flash else ''}<div class="mcard" style="padding:18px 20px;margin:0 0 18px">
|
||||||
{''.join(rows)}
|
{''.join(rows)}
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/logout"><button class="cta" type="submit">Sign out</button></form>
|
<form method="post" action="/account/details" class="mcard" style="padding:18px 20px;margin:0 0 18px">
|
||||||
<p class="authhint">Changing your own details is not built yet. Ask an administrator.</p>""")
|
<h2 class="sec" style="font-size:1.1rem;margin:0 0 4px">Your details</h2>{err_d}
|
||||||
return HTMLResponse(_render("Your account", body.replace(
|
<label for="full_name">Full name</label>
|
||||||
|
<input id="full_name" name="full_name" required value="{_esc(person.get('full_name'))}">
|
||||||
|
<label for="preferred_name">Preferred name <span style="font-weight:400;color:#6B7280">(optional)</span></label>
|
||||||
|
<input id="preferred_name" name="preferred_name" value="{_esc(person.get('preferred_name'))}">
|
||||||
|
<label for="phone">Mobile <span style="font-weight:400;color:#6B7280">(optional)</span></label>
|
||||||
|
<input id="phone" name="phone" type="tel" value="{_esc(person.get('phone'))}">
|
||||||
|
<button class="cta" type="submit" style="margin-top:16px">Save details</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/account/password" class="mcard" style="padding:18px 20px;margin:0 0 18px">
|
||||||
|
<h2 class="sec" style="font-size:1.1rem;margin:0 0 4px">Change password</h2>{err_p}
|
||||||
|
<label for="current">Current password</label>
|
||||||
|
<input id="current" name="current" type="password" autocomplete="current-password" required>
|
||||||
|
<label for="new">New password</label>
|
||||||
|
<input id="new" name="new" type="password" autocomplete="new-password" required minlength="12">
|
||||||
|
<label for="confirm">Confirm new password</label>
|
||||||
|
<input id="confirm" name="confirm" type="password" autocomplete="new-password" required minlength="12">
|
||||||
|
<button class="cta" type="submit" style="margin-top:16px">Change password</button>
|
||||||
|
<p class="authhint">At least 12 characters. Changing it signs out your other devices.</p>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/logout"><button class="cta" type="submit" style="background:#1E2F52;color:#fff">Sign out</button></form>
|
||||||
|
<p class="authhint">Your email address and roles are set by an administrator.</p>""")
|
||||||
|
return body.replace(
|
||||||
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Your account</h1>",
|
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Your account</h1>",
|
||||||
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Hello, %s</h1>" % _esc(name))))
|
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Hello, %s</h1>" % _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 <strong>%s</strong>." % _esc(person["email"]),
|
||||||
|
f"""<form method="post" action="/reset/{_esc(token)}">
|
||||||
|
<label for="password">New password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="new-password" required minlength="12">
|
||||||
|
<label for="confirm">Confirm password</label>
|
||||||
|
<input id="confirm" name="confirm" type="password" autocomplete="new-password" required minlength="12">
|
||||||
|
<button class="cta" type="submit">Set password and sign in</button>
|
||||||
|
</form>
|
||||||
|
<p class="authhint">At least 12 characters. Every device signed in as you will be signed out.</p>""",
|
||||||
|
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
|
||||||
|
|||||||
+299
@@ -195,6 +195,22 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
updated_by TEXT
|
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
|
-- 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
|
-- 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
|
-- 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)]
|
return [dict(r) for r in con.execute(sql, vals)]
|
||||||
finally:
|
finally:
|
||||||
con.close()
|
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()
|
||||||
|
|||||||
@@ -215,6 +215,57 @@ kinds = {r["kind"] for r in con.execute("SELECT DISTINCT kind FROM auth_events")
|
|||||||
con.close()
|
con.close()
|
||||||
check("auth_events records mint and revoke", "apikey.minted" in kinds and "apikey.revoked" in kinds)
|
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")
|
print("\nmeeting words")
|
||||||
D = dict(MEETING_DAY="Tuesday", MEETING_DAYS="Tuesdays", MEETING_DAY_ABBR="Tue", PACK_TIME="6:00 PM",
|
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")
|
TROOP_TIME="7:30 PM", PACK_CLOCK="6:00", TROOP_CLOCK="7:30")
|
||||||
|
|||||||
Reference in New Issue
Block a user