Skip to content
Clone
main
160 lines 6.6 KB Rust
use chrono::Utc;
use sea_orm::sea_query::Expr;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set, TransactionTrait,
};
use crate::Accounts;
use crate::errors::{AuthError, AuthResult};
use crate::models::space::{SpaceStatus, space_members, spaces};
use crate::models::users::{user_mfa, user_mfa_backup_codes, user_sessions, user_ssh_keys, users};
impl Accounts {
/// Soft-delete the account and revoke every credential. The row (and its
/// numeric id) stays so historical references remain resolvable; a later
/// `purge_account` anonymizes the PII.
///
/// An account that still owns a non-archived organization is refused: the
/// owner must transfer or delete the organization first, otherwise the
/// organization would be left without an owner.
pub async fn request_deletion(
&self,
user_id: i64,
reason: &str,
request_id: &str,
) -> AuthResult<()> {
let txn = self.db().write().begin().await?;
let user = users::Entity::find_by_id(user_id)
.one(&txn)
.await?
.ok_or(AuthError::UserNotFound)?;
if user.deletion_requested_at.is_some() || user.deleted_at.is_some() {
return Err(AuthError::DeletionAlreadyRequested);
}
let owned = spaces::Entity::find()
.filter(spaces::Column::OwnerUserId.eq(user_id))
.filter(spaces::Column::DeletedAt.is_null())
.filter(spaces::Column::Status.ne(SpaceStatus::Archived))
.count(&txn)
.await?;
if owned > 0 {
return Err(AuthError::OrganizationOwnerRequired);
}
let now = Utc::now();
let version = user.version;
let mut active: users::ActiveModel = user.into();
// 宽限期:账号保持可用,用户可登录并取消;到期后 purge 才撤销凭据。
// 这里不能推进 security_version,否则当前会话会立即失效、无法取消。
active.deletion_requested_at = Set(Some(now));
active.version = Set(version.saturating_add(1));
active.update(&txn).await?;
txn.commit().await?;
tracing::info!(%user_id, %request_id, reason, "account deletion requested");
Ok(())
}
pub async fn cancel_deletion(&self, user_id: i64, request_id: &str) -> AuthResult<()> {
let txn = self.db().write().begin().await?;
let user = users::Entity::find_by_id(user_id)
.one(&txn)
.await?
.ok_or(AuthError::UserNotFound)?;
if user.deletion_requested_at.is_none() || user.deleted_at.is_some() {
return Err(AuthError::DeletionNotRequested);
}
let version = user.version;
let mut active: users::ActiveModel = user.into();
active.deletion_requested_at = Set(None);
active.version = Set(version.saturating_add(1));
active.update(&txn).await?;
txn.commit().await?;
tracing::info!(%user_id, %request_id, "account deletion cancelled");
Ok(())
}
/// Anonymize PII while keeping the numeric id (and thus every historical
/// author reference) intact. Called after the grace period.
pub async fn purge_deleted_account(&self, user_id: i64, request_id: &str) -> AuthResult<()> {
let txn = self.db().write().begin().await?;
let user = users::Entity::find_by_id(user_id)
.one(&txn)
.await?
.ok_or(AuthError::UserNotFound)?;
if user.deletion_requested_at.is_none() || user.deleted_at.is_some() {
return Err(AuthError::DeletionNotRequested);
}
let now = Utc::now();
revoke_credentials(&txn, user_id, now).await?;
space_members::Entity::update_many()
.col_expr(space_members::Column::LeftAt, Expr::value(now))
.filter(space_members::Column::UserId.eq(user_id))
.filter(space_members::Column::LeftAt.is_null())
.exec(&txn)
.await?;
let username = format!("deleted_{user_id}");
let lowername = username.to_lowercase();
let email = format!("{username}@invalid");
let security_version = user.security_version;
let version = user.version;
let mut active: users::ActiveModel = user.into();
active.username = Set(username);
active.lowername = Set(lowername);
active.displayname = Set(None);
active.email = Set(email);
active.public_show_email = Set(false);
active.birth_date = Set(None);
active.password_hash = Set(format!("!purged-{}", uuid::Uuid::now_v7()));
active.password_updated_at = Set(Some(now));
active.password_must_change = Set(false);
active.deleted_at = Set(Some(now));
active.allow_use = Set(false);
active.forbid_login = Set(true);
active.forbid_login_reason = Set(Some("account purged".to_string()));
active.security_version = Set(security_version.saturating_add(1));
active.version = Set(version.saturating_add(1));
active.update(&txn).await?;
txn.commit().await?;
if let Err(error) =
crate::share::auth_store::drop_user_session_states(self.cache(), user_id, None).await
{
tracing::warn!(%error, %user_id, "failed to drop purged account sessions");
}
tracing::info!(%user_id, %request_id, "account purged");
Ok(())
}
}
async fn revoke_credentials<C: sea_orm::ConnectionTrait>(
txn: &C,
user_id: i64,
now: chrono::DateTime<Utc>,
) -> AuthResult<()> {
user_sessions::Entity::update_many()
.col_expr(user_sessions::Column::RevokedAt, Expr::value(now))
.filter(user_sessions::Column::UserId.eq(user_id))
.filter(user_sessions::Column::RevokedAt.is_null())
.exec(txn)
.await?;
user_ssh_keys::Entity::update_many()
.col_expr(user_ssh_keys::Column::RevokedAt, Expr::value(now))
.filter(user_ssh_keys::Column::UserId.eq(user_id))
.filter(user_ssh_keys::Column::RevokedAt.is_null())
.exec(txn)
.await?;
user_mfa::Entity::update_many()
.col_expr(user_mfa::Column::RevokedAt, Expr::value(now))
.filter(user_mfa::Column::UserId.eq(user_id))
.filter(user_mfa::Column::RevokedAt.is_null())
.exec(txn)
.await?;
user_mfa_backup_codes::Entity::update_many()
.col_expr(user_mfa_backup_codes::Column::RevokedAt, Expr::value(now))
.filter(user_mfa_backup_codes::Column::UserId.eq(user_id))
.filter(user_mfa_backup_codes::Column::RevokedAt.is_null())
.exec(txn)
.await?;
Ok(())
}