think
16px
820px

Internal Auth Hardening — Design

Date: 2026-07-06 · Status: Approved (brainstorm)
Module: CORE platform (no license gate). Scope confirmed by user: registration toggle,
admin create-user, admin password-reset, per-account lockout, require-TOTP toggle,
configurable password length + complexity — all admin-configurable.

Goal

Complete local (password) auth to enterprise posture. SSO/LDAP stays the recommended
primary for enterprise deployments; local auth is the floor an on-prem/air-gapped product
must stand on (no-IdP sites, break-glass, IdP outages). Today: open self-registration,
no admin onboarding, no password reset, no per-account lockout, hardcoded min-8 password —
and TOTP is enrollable but never checked at login (latent gap; fixed here, otherwise
the require-TOTP toggle is theater).

auth_settings singleton (migration 00088)

One admin-editable row (id=1 CHECK), the rate-limit/backup settings pattern: SettingsStore
Load/Save (Load falls back to Defaults), live-applied via atomic.Pointer (no restart),
edited from a new Admin → Security section.

column default floor/notes
allow_self_registration bool false deliberate posture change (see below)
require_totp bool false local-password users only
lockout_threshold int 10 ≥ 3 (can't be 1 — admins fat-finger too)
lockout_minutes int 15 ≥ 1
password_min_length int 8 ≥ 8, ≤ 72 (bcrypt input cap)
password_require_upper bool false
password_require_lower bool false
password_require_digit bool false
password_require_special bool false

Defaults preserve today's behavior except allow_self_registration=false — that flip is
the point of item 1. The demo is unaffected (director uses dev-login; nothing in the demo
self-registers). API: GET/PUT /api/v1/admin/auth-settings (perm users.admin), PUT
validates floors → 400 auth.settings.floor. OpenAPI + gen:api.

1. Registration toggle

POST /auth/register checks the live setting: disabled → 403 auth.register.disabled
(generic message). Enabled → unchanged, but new passwords go through the policy validator.

2. Admin create-user

POST /api/v1/admin/users (perm users.admin) {email, display_name, password, must_change_password (default true)} → provider local, policy-validated password,
409 on duplicate email. New users.password_must_change bool column (same migration).

Must-change enforcement is server-side, not just UI: the session middleware rejects a
flagged user's requests with 403 auth.password.change_required except an allowlist
(GET /me, POST /me/password, POST /auth/logout). UpdatePassword clears the flag.
Web: on that error (or a must_change_password flag in /me) the SPA shows a forced
change-password screen. The flag is only ever set by admin create/reset — the demo director
(provider dev, empty hash) is never flagged.

3. Admin password-reset

PUT /api/v1/admin/users/{id}/password (perm users.admin) {password} → rejects
directory-managed users (IsDirectoryManaged → 400 auth.password.managed_externally),
policy-validates, sets hash + password_must_change=true + LogoutAll (a reset invalidates
existing sessions). This is the air-gapped-correct reset: no email dependency. (Optional
email self-reset is out of scope this round.) Admin UI shows the typed password once
(same shown-once pattern as the SCIM token). e2e must never reset the demo director.

4. Per-account lockout

New users.failed_logins int default 0 + users.locked_until timestamptz null (same
migration). Local password path only (VerifyPassword): wrong password → increment; at
lockout_thresholdlocked_until = now() + lockout_minutes, counter reset; success →
counter reset. A locked account gets the SAME generic invalid-credentials error (no
enumeration, no lockout oracle) — but the attempt is audited and the admin Users table shows
a "locked" state with an Unlock row action (DELETE /admin/users/{id}/lockout, clears
both columns). Lockout never applies to directory-managed users (LDAP/IdP owns their auth)
and never to the empty-hash demo convention (dev users).

5. TOTP at login + require-TOTP toggle

Fix the latent gap first: PasswordLogin for a user with confirmed TOTP no longer
issues a session — it returns {totp_required: true, pending_token} (a short-lived ~5min
single-use token, stored server-side like a session with kind=pending-totp). New
POST /auth/totp/verify {pending_token, code} → verifies → issues the real session
(rate-limited like the other auth routes; bad code → generic error, pending token survives
until expiry). Directory/OIDC logins are untouched (the IdP owns MFA); dev-login untouched.

require_totp=true additionally means: a local-password user without TOTP gets a
restricted session flagged totp_enroll_required — same middleware pattern as must-change,
allowlist (GET /me, POST /auth/totp/enroll, POST /auth/totp/confirm,
POST /auth/logout); after confirm the restriction lifts (web shows a forced enrollment
screen). Toggling it off lifts restrictions on next request.

6. Password policy

One validator in the auth domain — ValidatePassword(pw, policy) — used by register, admin
create, admin reset, and self change-password (replacing the hardcoded 8): min length +
the four complexity toggles ("special" = any non-alphanumeric). Errors are specific for the
password holder (400 auth.password.policy with a message naming the unmet rule — this is
not an enumeration surface). Directory-managed users never hit it (no local passwords).

Admin UI

  • New "Security" item under People & access in the Admin side-nav (one NAV_GROUPS
    entry): SecurityTab with the registration toggle, require-TOTP toggle, lockout
    threshold/minutes NumberInputs (floored), password min-length NumberInput + four
    complexity checkboxes, Save (PUT, surfaces floor errors). Same editor pattern as
    Observability/Backups tabs.
  • Users section additions: a "Create user" button (modal: email, name, password,
    must-change checkbox), and per-row/detail actions Reset password (modal, policy-validated,
    shown-once) and Unlock (visible only when locked). en/id.

Error handling

All login-path failures stay generic (wrong password / locked / disabled / no such user are
indistinguishable to the caller). Settings floors reject with clear admin-facing messages.
A malformed/missing settings row falls back to Defaults (never fail-open on registration:
the fallback default is registration OFF). Pending-TOTP tokens are single-use and expire.

Testing (never go test on the live DB; NEVER docker compose down -v)

Build/vet + tsc/vite; curl e2e on the deployed stack with SCRATCH users only (never the
director): register 403 by default → toggle on → registers → policy rejects weak password;
admin create (must-change) → login → non-allowlisted call 403 change_required → change →
works; admin reset → old session dead + must-change again; lockout: N wrong passwords →
correct password now ALSO gets generic error until lockout_minutes pass (or admin Unlock) →
unlock works; TOTP: enroll+confirm scratch user → login returns totp_required + pending →
wrong code fails generic → right code → session; require_totp on → no-TOTP user restricted
to enroll allowlist; settings floors reject; demo intact (director dev-login + 5 modules,
LDAP/SCIM untouched). Cleanup scratch users + reset auth_settings to defaults.

Out of scope (YAGNI)

Email self-service password reset (needs SMTP assumptions; admin reset covers on-prem),
WebAuthn/passkeys, password history/expiry rotation policies (NIST discourages), SMS/email
OTP, per-role policy overrides, CAPTCHA, session-device management UI.