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( txn: &C, user_id: i64, now: chrono::DateTime, ) -> 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(()) }