d5939db
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<SuspensionView> {
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::Model> =
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<Vec<SuspensionView>> {
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<Vec<SuspensionView>> {
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())
}