use export::pagination::{Page, PageQuery}; use models::admin::admin_user::{self, AdminRole}; use sea_orm::PaginatorTrait; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set}; use serde::{Deserialize, Serialize}; use serde_json::json; use session::Session; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::AdminError; use crate::services::audit::{AuditCtx, AuditRecord}; use crate::services::service::AdminService; use crate::share::roles::AdminActor; use crate::share::session::{apply_admin_login, clear_admin}; const LOGIN_ATTEMPT_MAX: u64 = 5; const LOGIN_ATTEMPT_WINDOW_SECS: u64 = 300; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AdminLoginCmd { pub username: String, pub password: String, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AdminDto { pub id: Uuid, pub username: String, pub display_name: String, pub role: AdminRole, pub is_active: bool, pub must_change_password: bool, pub last_login_at: Option, pub last_login_ip: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AdminLoginResp { pub admin: AdminDto, pub must_change_password: bool, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ChangePasswordCmd { pub current_password: String, pub new_password: String, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateAdminCmd { pub username: String, pub display_name: Option, pub password: String, pub role: AdminRole, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateAdminCmd { #[serde(default)] pub display_name: Option, #[serde(default)] pub role: Option, #[serde(default)] pub is_active: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ResetAdminPasswordCmd { pub new_password: String, } fn admin_dto(row: &admin_user::Model) -> AdminDto { AdminDto { id: row.id, username: row.username.clone(), display_name: row.display_name.clone(), role: row.role, is_active: row.is_active, must_change_password: row.must_change_password, last_login_at: row.last_login_at, last_login_ip: row.last_login_ip.clone(), created_at: row.created_at, updated_at: row.updated_at, } } fn actor_from(row: &admin_user::Model) -> AdminActor { AdminActor { id: row.id, username: row.username.clone(), display_name: row.display_name.clone(), role: row.role, must_change_password: row.must_change_password, } } fn dummy_hash() -> &'static str { static HASH: std::sync::OnceLock = std::sync::OnceLock::new(); HASH.get_or_init(|| { auth::share::crypto::hash_password("admin-dummy-password").unwrap_or_default() }) } fn normalize_username(raw: &str) -> String { raw.trim().to_ascii_lowercase() } fn validate_username(username: &str) -> Result<(), AdminError> { let len = username.chars().count(); if !(3..=32).contains(&len) { return Err(AdminError::BadRequest( "username must be 3 to 32 characters".to_string(), )); } if !username .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') { return Err(AdminError::BadRequest( "username may contain letters, digits, underscore, dash and dot only".to_string(), )); } Ok(()) } impl AdminService { async fn other_active_super_exists(&self, excluded: Uuid) -> Result { let count = admin_user::Entity::find() .filter(admin_user::Column::Role.eq(AdminRole::SuperAdmin)) .filter(admin_user::Column::IsActive.eq(true)) .filter(admin_user::Column::Id.ne(excluded)) .count(self.db().read()) .await?; Ok(count > 0) } async fn ensure_super_admin_remains(&self, target: Uuid) -> Result<(), AdminError> { if self.other_active_super_exists(target).await? { Ok(()) } else { Err(AdminError::Conflict( "at least one active super admin is required".to_string(), )) } } pub async fn admin_login( &self, session: &Session, cmd: AdminLoginCmd, ctx: &AuditCtx, ) -> Result { let username = normalize_username(&cmd.username); if username.is_empty() || cmd.password.is_empty() { return Err(AdminError::BadRequest( "username and password are required".to_string(), )); } let attempts_key = format!("admin:login_attempts:{username}"); if !auth::share::rate::attempts_track( self.redis(), &attempts_key, LOGIN_ATTEMPT_MAX, LOGIN_ATTEMPT_WINDOW_SECS, ) .await? { return Err(AdminError::RateLimited( "too many login attempts, try again later".to_string(), )); } let row = admin_user::Entity::find() .filter(admin_user::Column::LowerName.eq(&username)) .one(self.db().read()) .await?; let Some(row) = row else { let _ = auth::share::crypto::verify_password(&cmd.password, dummy_hash()); self.audit( None, AuditRecord { action: "admin.login_failed", target: None, detail: json!({ "username": username }), }, ctx, ) .await?; return Err(AdminError::InvalidCredentials); }; if !row.is_active { let _ = auth::share::crypto::verify_password(&cmd.password, dummy_hash()); self.audit( None, AuditRecord { action: "admin.login_failed", target: Some(("admin_user", row.id.to_string())), detail: json!({ "username": username, "reason": "inactive" }), }, ctx, ) .await?; return Err(AdminError::InvalidCredentials); } if !auth::share::crypto::verify_password(&cmd.password, &row.passwd_hash) { self.audit( None, AuditRecord { action: "admin.login_failed", target: Some(("admin_user", row.id.to_string())), detail: json!({ "username": username }), }, ctx, ) .await?; return Err(AdminError::InvalidCredentials); } auth::share::rate::attempts_reset(self.redis(), &attempts_key).await?; let now = OffsetDateTime::now_utc(); let mut am: admin_user::ActiveModel = row.into(); am.last_login_at = Set(Some(now)); am.last_login_ip = Set(ctx.ip.clone()); am.updated_at = Set(now); let row = am.update(self.db().write()).await?; apply_admin_login(session, &row); let actor = actor_from(&row); self.audit( Some(&actor), AuditRecord { action: "admin.login", target: Some(("admin_user", row.id.to_string())), detail: json!({ "username": row.username }), }, ctx, ) .await?; Ok(AdminLoginResp { admin: admin_dto(&row), must_change_password: row.must_change_password, }) } pub async fn admin_logout( &self, session: &Session, actor: Option<&AdminActor>, ctx: &AuditCtx, ) -> Result<(), AdminError> { clear_admin(session); if let Some(actor) = actor { self.audit( Some(actor), AuditRecord { action: "admin.logout", target: Some(("admin_user", actor.id.to_string())), detail: json!({}), }, ctx, ) .await?; } Ok(()) } pub async fn admin_me(&self, actor: &AdminActor) -> Result { let row = admin_user::Entity::find_by_id(actor.id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; Ok(admin_dto(&row)) } pub async fn change_admin_password( &self, session: &Session, actor: &AdminActor, cmd: ChangePasswordCmd, ctx: &AuditCtx, ) -> Result<(), AdminError> { let row = admin_user::Entity::find_by_id(actor.id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; if !auth::share::crypto::verify_password(&cmd.current_password, &row.passwd_hash) { return Err(AdminError::BadRequest( "current password is incorrect".to_string(), )); } auth::share::crypto::validate_password( &cmd.new_password, &self.config().auth.password_policy, )?; let now = OffsetDateTime::now_utc(); let hash = auth::share::crypto::hash_password(&cmd.new_password)?; let mut am: admin_user::ActiveModel = row.into(); am.passwd_hash = Set(hash); am.must_change_password = Set(false); am.passwd_version = Set(now.unix_timestamp()); am.updated_at = Set(now); let row = am.update(self.db().write()).await?; apply_admin_login(session, &row); self.audit( Some(actor), AuditRecord { action: "admin.password_changed", target: Some(("admin_user", row.id.to_string())), detail: json!({}), }, ctx, ) .await?; Ok(()) } pub async fn list_admins( &self, actor: &AdminActor, page: PageQuery, ) -> Result, AdminError> { actor.require(AdminRole::SuperAdmin)?; let (limit, offset) = page.page(); let select = admin_user::Entity::find().order_by_asc(admin_user::Column::CreatedAt); let total = select.clone().count(self.db().read()).await? as i64; let rows = select .limit(limit) .offset(offset) .all(self.db().read()) .await?; Ok(Page { items: rows.iter().map(admin_dto).collect(), total, limit, offset, }) } pub async fn create_admin( &self, actor: &AdminActor, cmd: CreateAdminCmd, ctx: &AuditCtx, ) -> Result { actor.require(AdminRole::SuperAdmin)?; let username = normalize_username(&cmd.username); validate_username(&username)?; auth::share::crypto::validate_password(&cmd.password, &self.config().auth.password_policy)?; let existing = admin_user::Entity::find() .filter(admin_user::Column::LowerName.eq(&username)) .one(self.db().read()) .await?; if existing.is_some() { return Err(AdminError::Conflict( "admin username already exists".to_string(), )); } let now = OffsetDateTime::now_utc(); let hash = auth::share::crypto::hash_password(&cmd.password)?; let display_name = cmd .display_name .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| username.clone()); let row = admin_user::ActiveModel { id: Set(Uuid::now_v7()), username: Set(username.clone()), lower_name: Set(username), display_name: Set(display_name), passwd_hash: Set(hash), role: Set(cmd.role), is_active: Set(true), must_change_password: Set(false), passwd_version: Set(now.unix_timestamp()), last_login_at: Set(None), last_login_ip: Set(None), created_at: Set(now), updated_at: Set(now), } .insert(self.db().write()) .await?; self.audit( Some(actor), AuditRecord { action: "admin.created", target: Some(("admin_user", row.id.to_string())), detail: json!({ "username": row.username, "role": row.role }), }, ctx, ) .await?; Ok(admin_dto(&row)) } pub async fn get_admin(&self, actor: &AdminActor, id: Uuid) -> Result { actor.require(AdminRole::SuperAdmin)?; let row = admin_user::Entity::find_by_id(id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; Ok(admin_dto(&row)) } pub async fn update_admin( &self, actor: &AdminActor, id: Uuid, cmd: UpdateAdminCmd, ctx: &AuditCtx, ) -> Result { actor.require(AdminRole::SuperAdmin)?; let row = admin_user::Entity::find_by_id(id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; let mut changed: Vec = Vec::new(); let mut am: admin_user::ActiveModel = row.clone().into(); if let Some(display_name) = cmd.display_name { let display_name = display_name.trim().to_string(); if display_name.is_empty() { return Err(AdminError::BadRequest( "display_name must not be empty".to_string(), )); } am.display_name = Set(display_name); changed.push("display_name".to_string()); } if let Some(role) = cmd.role && role != row.role { if id == actor.id { return Err(AdminError::Conflict( "cannot change your own role".to_string(), )); } if row.role == AdminRole::SuperAdmin { self.ensure_super_admin_remains(id).await?; } am.role = Set(role); changed.push("role".to_string()); } if let Some(is_active) = cmd.is_active && is_active != row.is_active { if !is_active { if id == actor.id { return Err(AdminError::Conflict( "cannot deactivate your own account".to_string(), )); } if row.role == AdminRole::SuperAdmin { self.ensure_super_admin_remains(id).await?; } } am.is_active = Set(is_active); changed.push("is_active".to_string()); } if changed.is_empty() { return Ok(admin_dto(&row)); } am.updated_at = Set(OffsetDateTime::now_utc()); let row = am.update(self.db().write()).await?; self.audit( Some(actor), AuditRecord { action: "admin.updated", target: Some(("admin_user", row.id.to_string())), detail: json!({ "changed": changed, "role": row.role, "is_active": row.is_active }), }, ctx, ) .await?; Ok(admin_dto(&row)) } pub async fn delete_admin( &self, actor: &AdminActor, id: Uuid, ctx: &AuditCtx, ) -> Result<(), AdminError> { actor.require(AdminRole::SuperAdmin)?; if id == actor.id { return Err(AdminError::Conflict( "cannot delete your own account".to_string(), )); } let row = admin_user::Entity::find_by_id(id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; if row.role == AdminRole::SuperAdmin && row.is_active { self.ensure_super_admin_remains(id).await?; } admin_user::Entity::delete_by_id(id) .exec(self.db().write()) .await?; self.audit( Some(actor), AuditRecord { action: "admin.deleted", target: Some(("admin_user", id.to_string())), detail: json!({ "username": row.username, "role": row.role }), }, ctx, ) .await?; Ok(()) } pub async fn reset_admin_password( &self, actor: &AdminActor, id: Uuid, cmd: ResetAdminPasswordCmd, ctx: &AuditCtx, ) -> Result<(), AdminError> { actor.require(AdminRole::SuperAdmin)?; auth::share::crypto::validate_password(&cmd.new_password, &self.config().auth.password_policy)?; let row = admin_user::Entity::find_by_id(id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; let now = OffsetDateTime::now_utc(); let hash = auth::share::crypto::hash_password(&cmd.new_password)?; let mut am: admin_user::ActiveModel = row.into(); am.passwd_hash = Set(hash); am.must_change_password = Set(true); am.passwd_version = Set(now.unix_timestamp()); am.updated_at = Set(now); am.update(self.db().write()).await?; self.audit( Some(actor), AuditRecord { action: "admin.password_reset", target: Some(("admin_user", id.to_string())), detail: json!({}), }, ctx, ) .await?; Ok(()) } pub async fn seed_admin_if_empty(&self) -> Result, AdminError> { let count = admin_user::Entity::find().count(self.db().read()).await?; if count > 0 { return Ok(None); } let (Some(username), Some(password)) = ( self.admin_config().seed_username(), self.admin_config().seed_password(), ) else { tracing::warn!( "admin_user table is empty and [admin] seed_username/seed_password is not configured" ); return Ok(None); }; let username = normalize_username(username); validate_username(&username)?; auth::share::crypto::validate_password(password, &self.config().auth.password_policy)?; let now = OffsetDateTime::now_utc(); let hash = auth::share::crypto::hash_password(password)?; let row = admin_user::ActiveModel { id: Set(Uuid::now_v7()), username: Set(username.clone()), lower_name: Set(username.clone()), display_name: Set(username), passwd_hash: Set(hash), role: Set(AdminRole::SuperAdmin), is_active: Set(true), must_change_password: Set(true), passwd_version: Set(now.unix_timestamp()), last_login_at: Set(None), last_login_ip: Set(None), created_at: Set(now), updated_at: Set(now), } .insert(self.db().write()) .await?; tracing::info!( admin_id = %row.id, username = %row.username, "seeded initial super admin; password change required on first login" ); Ok(Some(row.id)) } } impl AdminService { pub async fn revoke_admin_sessions( &self, actor: &AdminActor, id: Uuid, ctx: &AuditCtx, ) -> Result<(), AdminError> { actor.require(AdminRole::Operator)?; if id != actor.id { actor.require(AdminRole::SuperAdmin)?; } let row = admin_user::Entity::find_by_id(id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; let now = OffsetDateTime::now_utc(); let next_version = row.passwd_version + 1; let mut am: admin_user::ActiveModel = row.into(); am.passwd_version = Set(next_version); am.updated_at = Set(now); am.update(self.db().write()).await?; self.audit( Some(actor), AuditRecord { action: "admin.sessions_revoked", target: Some(("admin_user", id.to_string())), detail: json!({}), }, ctx, ) .await?; Ok(()) } }