use crate::errors::{CoreError, CoreResult};
use crate::hibp::{self, HibpClient, HibpEnforcement};
use crate::password::{check_password_policy, hash_password};
use crate::time::SharedClock;
use sui_id_shared::ids::UserId;
use sui_id_store::models::{CredentialRow, HibpMode, UserRow};
use sui_id_store::repos::{
audit, auth_codes, credentials, refresh_tokens, sessions, user_totp,
user_webauthn_credentials, users,
};
use sui_id_store::Database;
use super::{audit_ok, audit_with_note, require_admin};
pub struct CreateUserSpec<'a> {
pub username: &'a str,
pub password: &'a str,
pub display_name: Option<&'a str>,
pub email: Option<&'a str>,
pub is_admin: bool,
pub min_password_len: usize,
}
pub async fn create_user(
db: &Database,
clock: &SharedClock,
hibp_client: Option<&dyn HibpClient>,
hibp_mode: sui_id_store::models::HibpMode,
actor: UserId,
spec: CreateUserSpec<'_>,
) -> CoreResult<UserRow> {
require_admin(db, actor).await?;
if spec.username.trim().is_empty() {
return Err(CoreError::BadRequest("username must not be empty".into()));
}
check_password_policy(spec.password, spec.min_password_len)?;
let hibp_result = hibp::enforce_hibp(hibp_mode, hibp_client, spec.password).await;
let hibp_warned = matches!(hibp_result, HibpEnforcement::AllowedWithWarning { .. });
let now = clock.now();
let row = UserRow {
id: UserId::new(),
username: spec.username.to_owned(),
display_name: spec.display_name.map(str::to_owned),
email: spec
.email
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned),
email_normalized: spec
.email
.map(str::trim)
.filter(|s| !s.is_empty())
.map(sui_id_shared::normalize_email),
email_verified_at: None,
preferred_lang: None,
is_admin: spec.is_admin,
role: if spec.is_admin { sui_id_store::models::Role::Admin } else { sui_id_store::models::Role::User },
last_login_at: None,
is_disabled: false,
is_deleted: false,
user_uuid: uuid::Uuid::new_v4(),
created_at: now,
updated_at: now,
failed_login_count: 0,
locked_until: None,
};
users::create(db, &row).await.map_err(|e| match e {
sui_id_store::StoreError::Conflict => CoreError::Conflict("username already in use".into()),
other => CoreError::from(other),
})?;
let hash = hash_password(spec.password)?;
credentials::upsert(
db,
&CredentialRow {
user_id: row.id,
password_hash: hash,
must_change: false,
updated_at: now,
},
).await?;
let action = if hibp_warned { "user.create_warned_hibp" } else { "user.create" };
audit_ok(db, actor, action, Some(row.id.to_string())).await;
Ok(row)
}
pub async fn list_users(db: &Database, actor: UserId) -> CoreResult<Vec<UserRow>> {
require_admin(db, actor).await?;
Ok(users::list(db).await?)
}
pub async fn set_user_disabled(
db: &Database,
actor: UserId,
target: UserId,
disabled: bool,
reason: Option<String>,
) -> CoreResult<()> {
require_admin(db, actor).await?;
if actor == target && disabled {
return Err(CoreError::BadRequest(
"cannot disable your own account; have another administrator do it".into(),
));
}
users::set_disabled(db, target, disabled).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
if disabled {
sessions::revoke_all_for_user(db, target).await?;
refresh_tokens::revoke_all_for_user(db, target).await?;
auth_codes::invalidate_all_for_user(db, target).await?;
}
audit_with_note(
db,
actor,
if disabled { "user.disable" } else { "user.enable" },
Some(target.to_string()),
if disabled { reason } else { None },
).await;
Ok(())
}
pub async fn delete_user(
db: &Database,
actor: UserId,
target: UserId,
reason: Option<String>,
) -> CoreResult<()> {
require_admin(db, actor).await?;
if actor == target {
return Err(CoreError::BadRequest(
"cannot delete your own account".into(),
));
}
users::soft_delete(db, target).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
sessions::revoke_all_for_user(db, target).await?;
refresh_tokens::revoke_all_for_user(db, target).await?;
auth_codes::invalidate_all_for_user(db, target).await?;
audit_with_note(db, actor, "user.delete", Some(target.to_string()), reason).await;
Ok(())
}
pub struct MfaResetReport {
pub totp_removed: bool,
pub passkeys_removed: usize,
}
pub async fn admin_reset_mfa(
db: &Database,
actor: UserId,
target: UserId,
reason: Option<String>,
) -> CoreResult<MfaResetReport> {
require_admin(db, actor).await?;
let _user = users::get(db, target).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
let totp_removed = match user_totp::delete(db, target).await {
Ok(()) => true,
Err(sui_id_store::StoreError::NotFound) => false,
Err(e) => return Err(CoreError::from(e)),
};
let creds = user_webauthn_credentials::list_for_user(db, target).await?;
let mut passkeys_removed = 0;
for c in creds {
user_webauthn_credentials::delete(db, c.id, target).await?;
passkeys_removed += 1;
}
let sys_note = format!(
"totp={} passkeys={}",
if totp_removed { "removed" } else { "absent" },
passkeys_removed
);
let note = match reason {
Some(r) => format!("{sys_note} reason={r}"),
None => sys_note,
};
let _ = audit::append(
db,
&sui_id_store::models::AuditLogRow {
at: chrono::Utc::now(),
actor: Some(actor),
action: "mfa.admin_reset".into(),
target: Some(target.to_string()),
result: "ok".into(),
note: Some(note),
},
).await;
Ok(MfaResetReport {
totp_removed,
passkeys_removed,
})
}
pub async fn reset_user_password(
db: &Database,
clock: &SharedClock,
hibp_client: Option<&dyn HibpClient>,
hibp_mode: HibpMode,
actor: UserId,
target: UserId,
new_password: &str,
min_password_len: usize,
) -> CoreResult<()> {
require_admin(db, actor).await?;
check_password_policy(new_password, min_password_len)?;
if matches!(
hibp::enforce_hibp(hibp_mode, hibp_client, new_password).await,
HibpEnforcement::Blocked { .. }
) {
return Err(CoreError::BadRequest(
"New password found in known data breaches. Please choose a different password.".into(),
));
}
let hash = hash_password(new_password)?;
let now = clock.now();
credentials::upsert(
db,
&CredentialRow {
user_id: target,
password_hash: hash,
must_change: false,
updated_at: now,
},
).await?;
sessions::revoke_all_for_user(db, target).await?;
refresh_tokens::revoke_all_for_user(db, target).await?;
auth_codes::invalidate_all_for_user(db, target).await?;
audit_ok(db, actor, "user.reset_password", Some(target.to_string())).await;
Ok(())
}