Commit Graph
5 Commits
Author SHA1 Message Date
thethreemagi aaa77be14d 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.
2026-09-04 18:18:39 -04:00
thethreemagi 4b5b2a3667 P3: API keys, bearer auth on the admin API, whoami, generated /api/docs
A key is the person who minted it, narrowed to the scopes they chose. Only
the sha256 is stored; the full key is returned once. Scopes must be a subset
of the owner's capabilities at mint time and are enforced again at use time
inside identity.can(), the one place that decides, so a key never outlives
its owner's demotion and disabling a person disables their keys with no
separate flag. A key cannot carry apikeys:own or the owner powers, so it
cannot mint keys. Revoked rows stay; a foreign key id is 404, never 403.

Bearer keys are honoured ONLY on /api/admin. The rest of the site reads
sessions alone, so a scoped key never widens into a browser identity.
X-Admin-Token remains break glass and, having no person, cannot own a key.

/api/docs is generated from the router on every request: path, methods and
docstring from the route objects, and the capability read out of each
handler's own _auth() call so it cannot drift from the check. Gated on a new
api:docs capability (leader and above). GET /api/admin/whoami answers who the
API thinks you are and what you can do.

Tests: smoke_identity 66 -> 92, smoke_admin 53 -> 58 (registry has a
capability for every route, docs page renders every route). Driven end to
end on a throwaway site with a DB copy: mint, whoami via key, scoped 200s
and a 403 that names the narrowing, key-mints-key 403, garbage key 401,
admin token on /keys 403, key on /account is not a session, revoke then
401, second revoke 409.
2026-09-04 17:31:05 -04:00
thethreemagi 0b337f1f19 meeting words come from the units table; the constants are now defaults
MEETING_DAY, MEETING_DAYS, MEETING_DAY_ABBR, PACK_TIME, TROOP_TIME,
PACK_CLOCK and TROOP_CLOCK were the last thing a leader could not change
without a commit. identity.meeting_words() derives all seven from the units
rows; app.py refreshes the module globals from it on a 30-second TTL in a
middleware, and the page templates - f-strings that read those globals when a
route runs - are untouched. A row that cannot supply a word keeps the
default, so a half-filled unit degrades to today's copy rather than a blank.

The day words come from the pack row: the site's copy assumes both units
meet the same night, and if that changes the copy needs rewriting, not a
bigger constant. Proven on a throwaway container with a DB copy: patching
pack73 to Thursday 18:15 changed the homepage, cubs, troop and meta
description; patching back restored them. tests/smoke_identity.py 59 -> 66.
2026-09-04 16:48:06 -04:00
thethreemagi 5c7a8dd785 login: honour next on both exits, same-origin only
The console's 401 redirect carries next=/leaders/ and /login dropped it, so a
leader arriving cold landed on /account. next now rides the form as a hidden
field and is applied after a successful POST and when an already-signed-in
person hits /login. identity.safe_next() admits only a relative path with a
single leading slash - no scheme, no //, no backslash, no control characters -
so the login page cannot become an open redirect. Ten checks added to
tests/smoke_identity.py; the suite is 59 + 24 + 53, all green.
2026-09-04 16:28:58 -04:00
thethreemagi bed93072cb P0: identity layer - units, people, roles, invites, sessions
Adds identity.py (schema, capability map, scrypt passwords, invites,
sessions, login throttle, boot seed) and auth.py (login, invite
acceptance, account page). app.py gains two imports and one wiring
block at EOF; no existing behaviour changes.

Units are a table seeded from the site constants. Roles split: leader
and member per unit in memberships, owner and admin site-wide in
people.global_role, so a unit added later cannot under-grant an admin.

tests/smoke_identity.py covers the rules that are invisible when wrong:
single-use invites, reissue revoking the prior link, expiry, idle and
absolute session bounds, throttling, and the capability split. 49 checks.
2026-09-04 11:29:32 -04:00