SCRUM-206 — admin-auth: staff user management API¶
Plan ref: AA-7 (docs/11-admin-plane-plan.md). Stacked on SCRUM-205.
Access¶
gateway_dev already gates /api/admin/users* at admin (SCRUM-200). admin-auth
re-checks every request: bearer verified in-process, caller loaded from the DB
by sub, must be active and hold admin in the DB — so a demotion or disable
takes effect immediately, even while the caller's old token is still valid.
Routes¶
| Route | Behaviour |
|---|---|
GET /api/admin/users |
{id,email,name,roles,is_root,status,mfa_enrolled,last_login_at,created_at} — never hashes or TOTP secrets |
POST /api/admin/users {email,name,role} |
invite: 201 {invite_id,email,role,invite_url,expires_at}; admin role only by root (403); existing user 409 already_exists; pending invite 409 invite_pending |
GET /api/admin/users/invites |
pending invites and resets (no tokens) |
DELETE /api/admin/users/invites/{id} |
revoke → 204; unknown / used / revoked / expired → 404 |
PATCH /api/admin/users/{id} {roles?,status?} |
root target → 403 root_protected; self → 403 self_modification; adding/removing admin, or changing an admin's status, needs root → 403; disabling revokes every session of the target |
POST /api/admin/users/{id}/reset |
reset link (replaces earlier pending resets); root → 403 root_protected; admin target needs root |
Links: 32 random bytes (base64url), only the sha256 stored, TTL
ADMIN_AUTH_INVITE_TTL (72h, 1–168h), URL
ADMIN_AUTH_PUBLIC_URL + /admin/onboard#token=… — the token is in the URL
fragment, which browsers never send to a server, so it cannot appear in
gateway/nginx access logs or Referer headers. Every mutation writes one audit row
(user.invite, invite.revoke, user.update with before/after, user.reset_link)
in the same transaction.
How to verify¶
cd services/admin_auth
export ADMIN_AUTH_TEST_DATABASE_URL='postgres://USER:PASS@127.0.0.1:5433/admin_auth_test?sslmode=disable'
go vet ./... && go test -race -count=2 ./...
go test -count=1 -v -run Admin ./internal/server/
| Test | Proves |
|---|---|
TestAdminUsersAuth |
no token 401; viewer / live_ops 403; disabled admin 401 |
TestAdminListUsersExcludesSecrets |
no hash / secret fields in the JSON |
TestAdminInviteCreatesAndHashesTheToken |
shape; DB holds sha256(fragment token) only; URL uses the fragment |
TestAdminInviteValidationAndConflicts |
400s; 409 existing user; 409 pending invite; non-root → admin 403; root → admin 201 |
TestAdminInviteRevoke |
204 then 404 |
TestAdminPatchMatrix |
admin demotes live_ops→viewer 200; admin promotes to admin 403; root promotes 200; admin disables an admin 403; root disables an admin 200 + sessions revoked; anyone → root 403 root_protected; self 403; empty body 400 |
TestAdminResetLink |
creates; replaces a pending reset; root 403; admin target needs root |
Results at time of writing¶
go vet,go test -race -count=2(7 packages) against Postgres 16: pass; no root row left behind.
How it was built¶
DeepSeek run scoped (Landlock) to services/admin_auth (360 s, ~55k output
tokens). Claude review: the DB-sourced authorisation in the guard and the PATCH rule
set checked against the spec; suite run twice. No changes needed.