- Status: done
- Date: 2026-05-24
- Specs touched:
AUTH.md(# Sessions, # Lifetime, # Invalidation, # Roles),USERS_AND_GROUPS.md(# Elevation in the UI)
Closes point 4 of the auth slice (0006 "What's next"). All four Tier-A auth items have now landed.
Sessionstruct gainsExpiresAt time.TimeandElevatedUntil time.Time.sessionstable DDL updated to includeexpires_at INTEGER NOT NULL DEFAULT 0andelevated_until INTEGER NOT NULL DEFAULT 0.- Idempotent ALTER TABLE migration: probes
PRAGMA table_info(sessions)and only adds the column if absent, so the migration is safe on both fresh and existing databases. - Backfill: existing rows with
expires_at = 0are updated tocreated_at + 90 daysimmediately after migration. CreateSessionandGetSessionupdated to persist/scan both new columns.SetElevatedUntil(token string, until time.Time) error— marks a session elevated; called by the elevate handler.ListSessionsForUser(userID string) ([]Session, error)— returns all sessions for a user ordered bycreated_at; used in tests.
Three new constants:
SessionIdleWindow = 30 * 24 * time.Hour— session is invalid if not seen within this window.SessionHardCap = 90 * 24 * time.Hour— absolute expiry set at issue time.ElevationWindow = 5 * time.Minute— how long a session stays elevated afterPOST /auth/elevate.
Issue() now sets ExpiresAt = now + SessionHardCap.
Validate() enforces both limits before looking up the user:
- Idle check:
now > last_seen_at + SessionIdleWindow→ delete row, returnErrInvalidSession. - Hard cap:
now > expires_at(andexpires_at != zero) → delete row, returnErrInvalidSession.
Identity gains a private elevated bool field, computed by Validate at the Manager's clock so tests that fake the clock get correct results. IsElevated() reads this field.
Elevate(token string) error — new method; sets ElevatedUntil = clock() + ElevationWindow via the store.
SessionStore interface gains SetElevatedUntil.
Two new constants:
ActionElevateSuccess = "auth.elevate.success"ActionElevateFailure = "auth.elevate.failure"
Both follow the elevation-class rule: failure rows emitted at every observable failure path.
POST /api/v1/auth/elevate (auth-required, not public):
- Body:
{password}. - Calls
host.VerifyPassword; on failure: auditauth.elevate.failure, return 401. - On success:
auth.Elevate(token), auditauth.elevate.success, return{elevated_until: <unix>}.
requireElevated(ctx) error — new helper (next to requireAdmin). Returns huma.NewError(403, "elevation_required") when the session is not elevated. Callers wire it after requireAdmin so members get admin_required, not elevation_required.
requireElevated added after requireAdmin in four handlers:
createUser(POST /api/v1/users)updateUserRole(PATCH /api/v1/users/:id)deleteUser(DELETE /api/v1/users/:id)resetUserPassword(POST /api/v1/users/:id/password)
listUsers (GET /api/v1/users) is not elevation-gated — it's read-only.
changeMyPassword (POST /api/v1/me/password) is not elevation-gated — per spec, self-service is exempt (user just proved their current password).
changeMyPassword now calls DeleteSessionsForUser after a successful SetPassword, per AUTH.md # Invalidation. This was flagged as a gap in 0008 and 0009.
TestValidateRejectsIdleExpiredSession— session invalid afterSessionIdleWindow; row deleted.TestValidateRejectsHardCapExpiredSession— session invalid afterSessionHardCap; row deleted.TestValidateStillValidBeforeExpiry— session valid just under the idle window.TestIssueSetExpiresAt—ExpiresAt = now + SessionHardCappersisted on issue.TestElevateAndIsElevated—ElevatesetsElevatedUntil;IsElevated()true immediately after.TestIsElevatedFalseAfterWindowExpires—IsElevated()false once window elapses.TestIsElevatedFalseWithoutElevate—IsElevated()false on fresh session.
TestElevateHappyPath— 200 +elevated_until+auth.elevate.successaudit row.TestElevateWrongPasswordFails— 401 +auth.elevate.failureaudit row.TestElevateRequiresAuth— 401 without session.TestSessionIdleExpiry— manually rewindslast_seen_atvia store, then/mereturns 401.TestUserCRUDRequiresElevation— all four elevated endpoints return 403 before elevation;POST /userssucceeds after.
All 18 existing user-CRUD tests that exercised the admin happy-path now call h.elevate(password) immediately after h.setupAdmin. The broken-host tests additionally seed a real bcrypt hash in the fake host-agent's password map so verify-password (used by elevate) succeeds.
AUTH.md# Lifetime — 30-day rolling idle + 90-day hard cap realized.AUTH.md# Invalidation — session deleted on expiry (not just rejected); self-service password change now revokes all sessions.USERS_AND_GROUPS.md# Elevation in the UI — 5-minute window,POST /auth/elevateendpoint,requireElevatedguard on user-management mutations.- CLAUDE.md "elevation-class mutations audit success and failure" —
auth.elevate.failureemitted on wrong password;auth.elevate.successon success.
- Recovery code for promoted admins —
PATCH /api/v1/users/:id(role change to admin) does not generate a recovery code. Still deferred. - argon2id vs. bcrypt — recovery hash uses bcrypt, same as before. Hardening pass deferred.
- Rate-limiting on
/loginand/elevate— still deferred. - "Sign out everywhere" —
DELETE /api/v1/sessionsor similar not yet built; only implicit revocation on password change/recovery.
- Recovery code generation on admin promotion.
- Rate-limiting on
/loginand/elevate. - Real PAM in host-agent (swap bcrypt map for
pam_authenticate). - Per-protocol opt-in (SSH/SMB) as service allowlists per account.