use db::conn::sea_orm::{ ActiveModelTrait, EntityTrait, ExprTrait, QueryFilter, Set, TransactionTrait, }; use db::user::users as user_entity; use uuid::Uuid; use crate::error::{UserError, UserResult}; use crate::features::UserService; use super::types::{RevokeSuspensionCmd, SuspendCmd, SuspensionView}; pub async fn create( svc: &UserService, caller: Uuid, target: Uuid, cmd: SuspendCmd, ) -> UserResult { let ends_at = match cmd.ends_at.as_deref() { Some(s) => Some( time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) .map_err(|e| UserError::BadRequest(format!("invalid ends_at: {e}")))?, ), None => None, }; if matches!(cmd.suspension_type, super::types::SuspensionType::Temporary) && ends_at.is_none() { return Err(UserError::BadRequest( "temporary suspension requires ends_at".into(), )); } let writer = svc.db.writer(); let txn = writer.begin().await?; if user_entity::Entity::find_by_id(target) .one(&txn) .await? .is_none() { return Err(UserError::NotFound(format!("user {target}"))); } let now = time::OffsetDateTime::now_utc(); let active: db::user::user_suspensions::ActiveModel = db::user::user_suspensions::ActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(target) .set_suspension_type(cmd.suspension_type) .set_reason(cmd.reason) .set_suspended_by(Some(caller)) .set_starts_at(now) .set_ends_at(ends_at) .set_revoked_at(None) .set_revoked_by(None) .set_created_at(now) .set_updated_at(now) .into(); let saved = active.insert(&txn).await?; let user: user_entity::Model = user_entity::Entity::find_by_id(target) .one(&txn) .await? .ok_or_else(|| UserError::NotFound(format!("user {target}")))?; let mut user_active: user_entity::ActiveModel = user.into(); user_active.is_restricted = Set(true); user_active.updated_at = Set(now); user_active.update(&txn).await?; // B4: revoke all refresh sessions atomically with the suspension so a // suspended user cannot refresh to keep the session alive. jwt::session::delete_sessions_by_user(&txn, target) .await .map_err(|e| UserError::Internal(format!("failed to revoke sessions: {e}")))?; txn.commit().await?; // VULN-002/A3: a suspension must take effect immediately — deny-list the // user's live access tokens so they stop validating without waiting for // expiry. (Refresh-session revocation is handled alongside the refresh // gate; see the refresh use-case.) let ttl = std::cmp::max(svc.jwt.settings.access_ttl.whole_seconds(), 1) as u64; rate_limit::AccessTokenBlacklist::new(svc.kv.clone()) .revoke_user(target, ttl) .await?; Ok(SuspensionView::from(saved)) } pub async fn revoke( svc: &UserService, caller: Uuid, suspension_id: Uuid, cmd: RevokeSuspensionCmd, ) -> UserResult<()> { let writer = svc.db.writer(); let txn = writer.begin().await?; let row = db::user::user_suspensions::Entity::find_by_id(suspension_id) .one(&txn) .await? .ok_or_else(|| UserError::NotFound(format!("suspension {suspension_id}")))?; if row.revoked_at.is_some() { return Err(UserError::BadRequest("already revoked".into())); } let user_id = row.user_id; let now = time::OffsetDateTime::now_utc(); let mut am: db::user::user_suspensions::ActiveModel = row.into(); am.revoked_at = Set(Some(now)); am.revoked_by = Set(Some(caller)); if cmd.reason.is_some() { am.reason = Set(cmd.reason); } am.updated_at = Set(now); am.update(&txn).await?; let still_active: Option = db::user::user_suspensions::Entity::find() .filter(db::user::user_suspensions::COLUMN.user_id.eq(user_id)) .filter(db::user::user_suspensions::COLUMN.revoked_at.is_null()) .filter( db::user::user_suspensions::COLUMN.ends_at .is_null() .or(db::user::user_suspensions::COLUMN.ends_at.gt(now)), ) .one(&txn) .await?; if still_active.is_none() && let Some(user) = user_entity::Entity::find_by_id(user_id).one(&txn).await? { let mut user_active: user_entity::ActiveModel = user.into(); user_active.is_restricted = Set(false); user_active.updated_at = Set(now); user_active.update(&txn).await?; } txn.commit().await?; Ok(()) } pub async fn list_user( svc: &UserService, target: Uuid, include_revoked: bool, ) -> UserResult> { let db = svc.db.reader(); let mut sel = db::user::user_suspensions::Entity::find() .filter(db::user::user_suspensions::COLUMN.user_id.eq(target)); if !include_revoked { sel = sel.filter(db::user::user_suspensions::COLUMN.revoked_at.is_null()); } let rows = sel.all(&db).await?; Ok(rows.into_iter().map(SuspensionView::from).collect()) } pub async fn list_all(svc: &UserService, include_revoked: bool) -> UserResult> { let db = svc.db.reader(); let mut sel = db::user::user_suspensions::Entity::find(); if !include_revoked { sel = sel.filter(db::user::user_suspensions::COLUMN.revoked_at.is_null()); } let rows = sel.all(&db).await?; Ok(rows.into_iter().map(SuspensionView::from).collect()) }