Phase 4 — P4-03/04: RBAC + user management #14

Merged
steve merged 30 commits from p4-03-04-rbac-user-mgmt into main 2026-05-05 11:01:44 +01:00
Owner

Closes P4-03 (RBAC enforcement at API layer) and P4-04 (user management UI). 30 commits, branched from main and rebased clean.

What's in

Three-role hierarchy (admin > operator > viewer) enforced via chi route-group middleware (`requireRole`). Admin is the fail-closed default — any unbanded route lands there. Agent endpoints (`/ws/agent`, `/api/agents/*`) stay on the bearer-token chain, untouched. Sessions re-validate `disabled_at` per request so admin-driven changes (disable, force-logout) land immediately.

Setup-token flow replaces temp passwords. Admin clicks `+ Add user`, picks username + email + role, server returns a one-time setup link valid for 1 hour (sha256-hashed at rest, raw shown to admin once). User clicks the link → sets a password (≥12 chars) → drops a session → lands on `/`. `Regenerate setup link` issues a new token, replacing the old via INSERT OR REPLACE. Expired tokens swept on the alert engine's 60s tick.

Disable-only lifecycle — soft delete via `disabled_at`. Last-admin guard rejects "disable last admin" and "demote last admin to non-admin" both server-side and UI-hinted. Disabled-username collision on Add User redirects to the existing user's edit page with an amber "Username already exists (disabled) — Re-enable / Pick different name" banner so the operator gets the right action in two clicks.

Self-service password change at `/settings/account` available to any role. Skips current-password check when `must_change_password` is set.

Sortable users list — Username / Email / Role / Last login are clickable headers with the audit-style ↑/↓ glyph; NULL emails / never-logged-in users sort to the bottom regardless of direction. Status column stays non-sortable (compound state). `Last login` populates on both `/api/auth/login` and `/setup` POST.

Schema

Two column-level migrations (CLAUDE.md preference):

  • 0017 `users` extensions: `email TEXT`, `disabled_at TEXT`, `must_change_password INTEGER NOT NULL DEFAULT 0`, plus a UNIQUE INDEX on `LOWER(username)` (lowercase-normalised in Go on every CreateUser).
  • 0018 `user_setup_tokens` (user_id PK, token_hash, expires_at, created_at, created_by FK).

Audit actions

`user.created`, `user.updated`, `user.disabled`, `user.enabled`, `user.password_changed`, `user.setup_completed`, `user.setup_token.regenerated`, `user.force_logout`. All target_kind=`user`.

Sweep verified (smoke env)

  • Admin adds operator `op-test` → 1h setup link issued
  • Curl-as-new-user fetches `/setup?token=…` (200, username rendered) → POSTs password → 303 to `/` with session cookie set
  • Operator authenticated: 200 on `/`, 200 on `/settings/account`, 403 on /settings/users
  • Admin sets `disabled_at` mid-session → operator's next request is 401 + sessions table empty for that user
  • Audit log shows `user.created` + `user.setup_completed` for the cycle
  • Try to re-add disabled `steve` → amber re-enable banner appears with primary action
  • Sort headers on `/settings/users` reorder rows correctly; NULL last-login users stay at the bottom

Out of scope (deferred — see spec)

OIDC (P4-05), email-the-link via SMTP, hard-delete, password complexity / rotation policy, lockout on failed login.

Test plan

  • `go test ./...` (passes cleanly post-rebase)
  • Live admin → operator setup-link end-to-end on the smoke env
  • Mid-session disable kicks operator immediately (401)
  • Last-admin guard rejects self-disable / self-demote (409)
  • Self-service password change (operator, viewer)
  • Sortable user list, NULL-tail behaviour
  • Disabled-username re-enable banner

Refs

  • spec: `docs/superpowers/specs/2026-05-05-p4-03-04-rbac-user-mgmt-design.md`
  • plan: `docs/superpowers/plans/2026-05-05-p4-03-04-rbac-user-mgmt.md`
Closes **P4-03** (RBAC enforcement at API layer) and **P4-04** (user management UI). 30 commits, branched from main and rebased clean. ## What's in **Three-role hierarchy** (admin > operator > viewer) enforced via chi route-group middleware (\`requireRole\`). Admin is the fail-closed default — any unbanded route lands there. Agent endpoints (\`/ws/agent\`, \`/api/agents/*\`) stay on the bearer-token chain, untouched. Sessions re-validate \`disabled_at\` per request so admin-driven changes (disable, force-logout) land immediately. **Setup-token flow** replaces temp passwords. Admin clicks \`+ Add user\`, picks username + email + role, server returns a one-time setup link valid for **1 hour** (sha256-hashed at rest, raw shown to admin once). User clicks the link → sets a password (≥12 chars) → drops a session → lands on \`/\`. \`Regenerate setup link\` issues a new token, replacing the old via INSERT OR REPLACE. Expired tokens swept on the alert engine's 60s tick. **Disable-only lifecycle** — soft delete via \`disabled_at\`. Last-admin guard rejects \"disable last admin\" and \"demote last admin to non-admin\" both server-side and UI-hinted. **Disabled-username collision** on Add User redirects to the existing user's edit page with an amber \"Username already exists (disabled) — Re-enable / Pick different name\" banner so the operator gets the right action in two clicks. **Self-service password change** at \`/settings/account\` available to any role. Skips current-password check when \`must_change_password\` is set. **Sortable users list** — Username / Email / Role / Last login are clickable headers with the audit-style ↑/↓ glyph; NULL emails / never-logged-in users sort to the bottom regardless of direction. Status column stays non-sortable (compound state). \`Last login\` populates on both \`/api/auth/login\` and \`/setup\` POST. ## Schema Two column-level migrations (CLAUDE.md preference): - **0017** \`users\` extensions: \`email TEXT\`, \`disabled_at TEXT\`, \`must_change_password INTEGER NOT NULL DEFAULT 0\`, plus a UNIQUE INDEX on \`LOWER(username)\` (lowercase-normalised in Go on every CreateUser). - **0018** \`user_setup_tokens\` (user_id PK, token_hash, expires_at, created_at, created_by FK). ## Audit actions \`user.created\`, \`user.updated\`, \`user.disabled\`, \`user.enabled\`, \`user.password_changed\`, \`user.setup_completed\`, \`user.setup_token.regenerated\`, \`user.force_logout\`. All target_kind=\`user\`. ## Sweep verified (smoke env) - Admin adds operator \`op-test\` → 1h setup link issued - Curl-as-new-user fetches \`/setup?token=…\` (200, username rendered) → POSTs password → 303 to \`/\` with session cookie set - Operator authenticated: 200 on \`/\`, 200 on \`/settings/account\`, **403 on /settings/users** - Admin sets \`disabled_at\` mid-session → operator's next request is **401** + sessions table empty for that user - Audit log shows \`user.created\` + \`user.setup_completed\` for the cycle - Try to re-add disabled \`steve\` → amber re-enable banner appears with primary action - Sort headers on \`/settings/users\` reorder rows correctly; NULL last-login users stay at the bottom ## Out of scope (deferred — see spec) OIDC (P4-05), email-the-link via SMTP, hard-delete, password complexity / rotation policy, lockout on failed login. ## Test plan - [x] \`go test ./...\` (passes cleanly post-rebase) - [x] Live admin → operator setup-link end-to-end on the smoke env - [x] Mid-session disable kicks operator immediately (401) - [x] Last-admin guard rejects self-disable / self-demote (409) - [x] Self-service password change (operator, viewer) - [x] Sortable user list, NULL-tail behaviour - [x] Disabled-username re-enable banner ## Refs - spec: \`docs/superpowers/specs/2026-05-05-p4-03-04-rbac-user-mgmt-design.md\` - plan: \`docs/superpowers/plans/2026-05-05-p4-03-04-rbac-user-mgmt.md\`
steve added 30 commits 2026-05-05 10:59:41 +01:00
Brainstormed shape locked: chi route-group middleware, fail-closed
admin default; setup-token flow with 1h single-use tokens
(sha256-hashed at rest, raw shown to admin once); disable-only user
lifecycle with last-admin guard; self-service /settings/account
password change for every role; email field on users (metadata
v1); session re-validation on every authenticated request so
disable / role change land immediately.

Locked decisions captured in §Role taxonomy, §Schema changes,
§Setup-token flow, §RBAC enforcement, §Last-admin self-protection.
Deferred items in §Out of scope (OIDC, SMTP email-the-link,
hard delete, lockout).

Migrations 0017 (users extensions) + 0018 (user_setup_tokens)
both column-level ALTERs per CLAUDE.md preference.
Bite-sized TDD tasks across 7 slices (A schema, B middleware,
C session re-validation, D setup-token flow, E user CRUD API,
F UI, G wiring + sweep). Each task is one commit with concrete
code blocks and test cases — no placeholders.

Refs spec at docs/superpowers/specs/2026-05-05-p4-03-04-rbac-user-mgmt-design.md.
Routes are now structured into Public / Viewer / Operator / Admin bands
using requireRole middleware. Job log stream and download moved into the
Viewer band. healthz moved from New() into routes() with the other
public endpoints.
Replaces the 501 stub with the full handler: validates the token and
password, hashes and stores the password, deletes the setup token,
mints an 8-hour session cookie, appends a user.setup_completed audit
entry, and redirects to /. Adds TestSetupPostHappyPath covering the
full round-trip including normal-login verification after setup.
Adds handleUIUserNewGet, handleUIUserNewPost, handleUIUserSetupLinkGet
to ui_users.go; creates web/templates/pages/user_edit.html (multi-mode
new/edit/setup-link); wires three routes in the admin band of server.go.
Adds GET/POST handlers for /settings/account in the viewer band
(any authenticated user), account.html template with current-password
field suppressed when must_change_password is set, and audits the
change via AppendAudit.
Live Playwright + curl sweep on the smoke env exercised the full
user-management lifecycle:

  admin add user → setup link generated → curl-as-new-user fetches
  /setup (200, username on page) → POSTs password → 303 to / with
  Set-Cookie → 200 on dashboard, 200 on /settings/account,
  **403 on /settings/users** (admin-only) → admin disables → next
  request is **401** + session row count drops to 0 → audit log
  reflects user.created + user.setup_completed.

Three-role middleware enforces band gates; admin is fail-closed
default. Setup tokens are sha256-hashed at rest with 1h expiry;
expired tokens are swept on the alert engine's 60s tick. Last-admin
guard rejects disable + demote of the only enabled admin. Self-
service password change at /settings/account is reachable by every
role.
ui(users): banner explaining the disabled-username re-enable flow
CI / Test (rest) (pull_request) Successful in 29s
CI / Lint (pull_request) Successful in 32s
CI / Test (server-http) (pull_request) Successful in 1m9s
CI / Test (store) (pull_request) Successful in 1m13s
CI / Build (windows/amd64) (pull_request) Successful in 23s
CI / Build (linux/amd64) (pull_request) Successful in 21s
CI / Build (linux/arm64) (pull_request) Successful in 37s
dfff6d1ef9
steve merged commit 00b926b0a3 into main 2026-05-05 11:01:44 +01:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/restic-manager#14