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
+51
View File
@@ -215,6 +215,57 @@ kinds = {r["kind"] for r in con.execute("SELECT DISTINCT kind FROM auth_events")
con.close()
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")
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")