Users
Users — Technical Spec
All in app/im2.py (no separate router). Template app/templates/users.html.
Routes
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /users | role == 'admin' (hard-coded, 403 otherwise) | select username, display_name, email, role, active from app_users order by role, display_name. Passes roles = ['admin','level1','level2','level3']. |
| POST | /api/users/refresh | admin | Reconciles app_users against slack_roster. |
| POST | /api/users/add | admin | display_name, email, role (default level3). |
| POST | /api/users/{username} | admin | role — the only editable column. |
| POST | /api/users/{username}/pin | admin | pin (blank = generate). Defined far down the file, after /healthz. |
Admin-only here is a fixed role test, not a role_action_perms lookup — by design (im2.py comment: "Two keys stay hard-coded admin-only on purpose (users, settings) so nobody can lock every admin out of the app"). The Users/Settings nav links in base.html are gated on user.role == 'admin' as well.
Tables
app_users(username pk, display_name, email, slack_id, role, active, pin_hash, phone, created_at).usernameis the email local part.phoneis read bynotify.notify_release().slack_roster(username pk, display_name, email, slack_id, synced_at)— truncate-and-reload nightly byscripts/roster_sync.py(Viktor cron; the app holds no Slack credentials).audit_log— user-admin writes usesku = 'USER:<name>',source='user_admin',field_nameinroster_sync/created/role.
Roster refresh (/api/users/refresh)
For each slack_roster row: insert missing users as role='level3', active=true; otherwise update app_users set display_name, email, active=true. Then any app_users row not in the roster and currently active is set active=false. Returns {added:[display_name…], deactivated:[username…], roster:N}. Never changes role.
PIN handling
hash_pin(): 8-byte hex salt, pbkdf2-hmac-sha256, 120,000 iterations, stored assalt$hex.check_pin():hmac.compare_digest. Missing/malformedpin_hash→ False (so a user with no PIN can never sign in).- Server validation:
len(pin) >= 4 and pin.isdigit(). No maximum (the UI prompt says 4–8). - Blank
pin→secrets.randbelow(900000) + 100000, i.e. 6 digits, returned once in{"ok":true,"pin":…}; a caller-supplied PIN returns"pin": null. - Setting a PIN does not invalidate existing session cookies (see the Login spec).
Known limits / defects
set_pin()writes itsaudit_logrow withsku = Noneand no username — the trail records that a PIN was set and by whom, but not for whom. One-line fix: passf"USER:{username}".set_pin()never checks the user exists.POST /api/users/nosuchperson/pinupdates 0 rows and returns{"ok": true}(/api/users/{username}role-set does check, with 404).set_role()has no self-guard; only the template disables the operator's own dropdown. An admin can demote themselves via a direct API call, and if they are the last admin the app has no admin (recovery = SQL)./api/users/refreshunconditionally setsactive=truefor every roster member, so a person deliberately switched off is silently re-enabled on the next refresh, and any manual (/api/users/add) user is deactivated by the next refresh because they are not inslack_roster. There is no per-row active toggle on this screen at all.users.htmlrenders inactive users identically to active ones —u.activeis selected but never displayed.scripts/schema.sqlstill hasapp_users.role CHECK (role IN ('viewer','warehouse','admin'))and noemail/slack_id/pin_hash/phonecolumns. The live database has been migrated past it; the DDL in this package would not support this screen if replayed clean.- Every POST here is form-encoded with cookie auth and no CSRF token.
samesite=laxon the session cookie is the only thing stopping a cross-site form post.
PIN reset audit (fixed 2026-09-06)
set_pin now writes the target username into audit_log.old_value and a new_value of (set for <username>; generated|typed by admin), alongside changed_by (the admin who did it). Before this the row recorded only that some PIN was set, which made a reset untraceable. The PIN itself is still never stored or logged in clear; a generated PIN is returned once in the response so the admin can pass it on.
Shared table sort (2026-09-07)
Column sorting is one implementation in templates/base.html (Dave, 2026-09-07: "all screens that have tables like this need sorts on the appropriate columns"). A table opts in with class="sorttable"; every thead th becomes sortable except those with class="nosort"/class="toggle", an empty heading, or a checkbox in the heading. Client-side only, over the rows already rendered. Details: tech/table_sorting.md.