diff --git a/crates/manager-core/src/admin_users_api.rs b/crates/manager-core/src/admin_users_api.rs index d86bd2e..2c8d51d 100644 --- a/crates/manager-core/src/admin_users_api.rs +++ b/crates/manager-core/src/admin_users_api.rs @@ -233,11 +233,27 @@ async fn patch_admin( validate_password(new_password)?; let hash = hash_password(new_password).map_err(|e| AdminApiError::Hash(e.to_string()))?; latest = Some(state.users.update_password_hash(id, &hash).await?); - // Best practice: rotating your own password should still keep - // your session alive, so we don't wipe sessions here. (If we - // wanted "log everyone else out on password change", that'd be - // a `delete_for_user` + re-issue current session. Out of scope - // for the initial cut.) + // Audit 2026-06-11 H-E1 — a password change invalidates both + // credential surfaces for the target user (sessions + API keys), + // matching the CLI `reset-password` flow and the deactivation + // path below. A self-change therefore logs the caller out, and + // the dashboard handles the subsequent 401 by redirecting to the + // login screen. The previous behavior kept all sessions live, + // which meant a hijacked session that triggered a password + // change retained its grip after the rotation. + if let Err(err) = state.sessions.delete_for_user(id).await { + tracing::error!(?err, "failed to delete sessions on password change"); + } + match state.keys.expire_all_for_user(id).await { + Ok(n) => { + if n > 0 { + tracing::info!(user_id = %id, expired = n, "expired api keys on password change"); + } + } + Err(err) => { + tracing::error!(?err, "failed to expire api keys on password change"); + } + } } if let Some(email_patch) = input.email.as_ref() {