diff --git a/.env.example b/.env.example index 179f528..2a6ea88 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,14 @@ JWT_SECRET= # generate with: openssl rand -hex 32 # SYNC_BLOB_RETENTION_DAYS=90 +# Account deletion (optional) --------------------------------------------- +# Days an admin soft-deleted account stays restorable. After that the daily +# purge hard-deletes it and its email can be registered again. An account +# still referenced elsewhere (team owner, shared-object author) is kept but +# its email is released, and it can no longer be restored. +# USER_PURGE_GRACE_DAYS=30 + + # Admin dashboard (optional) --------------------------------------------- # Only set this if you also run the separate admin-dashboard container. # Generate the same way as JWT_SECRET. diff --git a/compose.yml b/compose.yml index d186e4f..8e17b88 100644 --- a/compose.yml +++ b/compose.yml @@ -49,6 +49,7 @@ services: USER_SEARCH_RATE_LIMIT: ${USER_SEARCH_RATE_LIMIT:-} STRANGER_KNOCK_RATE_LIMIT: ${STRANGER_KNOCK_RATE_LIMIT:-} SYNC_BLOB_RETENTION_DAYS: ${SYNC_BLOB_RETENTION_DAYS:-} + USER_PURGE_GRACE_DAYS: ${USER_PURGE_GRACE_DAYS:-} TEAM_OBJECTS_MIN_CLIENT_VERSION: ${TEAM_OBJECTS_MIN_CLIENT_VERSION:-} # Only needed if you also run the admin dashboard. ADMIN_SECRET: ${ADMIN_SECRET:-} diff --git a/migrations/056_admin_audit_target_no_fk.sql b/migrations/056_admin_audit_target_no_fk.sql new file mode 100644 index 0000000..c1f59b4 --- /dev/null +++ b/migrations/056_admin_audit_target_no_fk.sql @@ -0,0 +1,3 @@ +-- Soft delete audits the user as target_id, so this FK made every soft-deleted +-- user impossible to hard-delete. The audit trail must outlive the user anyway. +ALTER TABLE admin_audit_log DROP CONSTRAINT admin_audit_log_target_id_fkey; diff --git a/src/main.rs b/src/main.rs index 9b3aa48..f91632f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod team_plan; mod vault_key_exposure; mod sync_notifier; mod terminal_manager; +mod user_purge; #[cfg(test)] mod test_support; @@ -56,6 +57,13 @@ use tracing_subscriber::EnvFilter; /// passed, with no paid subscription and no admin override, has consumed its /// trial and reverts. Paid accounts carry `trial_ends_at = NULL` and are never /// matched. Keeps `subscription_tier` (and thus admin metrics) truthful. +async fn purge_soft_deleted_users(pool: &sqlx::PgPool, grace_days: i64) { + match user_purge::purge_expired(pool, grace_days).await { + Ok(c) => tracing::info!(deleted = c.deleted, released = c.released, grace_days, "Soft-deleted user purge completed"), + Err(e) => tracing::error!(error = %e, "Soft-deleted user purge failed"), + } +} + async fn expire_lapsed_trials(pool: &sqlx::PgPool) { match sqlx::query( r#"UPDATE users SET @@ -99,6 +107,11 @@ async fn main() { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(90); + let user_purge_grace_days: i64 = std::env::var("USER_PURGE_GRACE_DAYS") + .ok() + .and_then(|v| v.parse().ok()) + .filter(|d| *d >= 0) + .unwrap_or(30); // Opt-in floor for clients WRITING team vault objects. Unset by default so // self-hosted deployments keep working until their operator opts in. See // routes::client_version for why this is not a security control. @@ -110,12 +123,14 @@ async fn main() { // Expire lapsed trials immediately on boot so the DB and admin metrics are // truthful right after deploy, then keep them so via the daily sweep below. expire_lapsed_trials(&retention_pool).await; + purge_soft_deleted_users(&retention_pool, user_purge_grace_days).await; tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(86_400)); interval.tick().await; // skip first immediate tick loop { interval.tick().await; expire_lapsed_trials(&retention_pool).await; + purge_soft_deleted_users(&retention_pool, user_purge_grace_days).await; match sqlx::query( r#"DELETE FROM audit_logs USING teams diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 1ad23f3..e0d548b 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -15,6 +15,7 @@ use uuid::Uuid; use crate::auth::AdminEmail; use crate::lemonsqueezy::{LsCache, LsSummaryResponse}; use crate::sync_notifier::SyncNotifier; +use crate::user_purge::RELEASED_EMAIL; use crate::PresenceMap; // ─── Audit helper ───────────────────────────────────────────────────────────── @@ -1041,15 +1042,16 @@ pub async fn delete_user( } /// POST /v1/admin/users/:id/restore — clear deleted_at within grace period. +/// 409 once the purge released the email, since the address may belong to someone else. pub async fn restore_user( State(pool): State, Extension(AdminEmail(admin_email)): Extension, Path(user_id): Path, ) -> Result { - let result = sqlx::query( + let result = sqlx::query(&format!( "UPDATE users SET deleted_at = NULL, deletion_reason = NULL, deleted_by = NULL - WHERE id = $1 AND deleted_at IS NOT NULL", - ) + WHERE id = $1 AND deleted_at IS NOT NULL AND email <> {RELEASED_EMAIL}" + )) .bind(user_id) .execute(&pool) .await @@ -1059,7 +1061,17 @@ pub async fn restore_user( })?; if result.rows_affected() == 0 { - return Err(StatusCode::NOT_FOUND); + let released: bool = sqlx::query_scalar(&format!( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND email = {RELEASED_EMAIL})" + )) + .bind(user_id) + .fetch_one(&pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to check restore target"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + return Err(if released { StatusCode::CONFLICT } else { StatusCode::NOT_FOUND }); } write_audit(&pool, &admin_email, Some(user_id), "restore_user", json!({})).await; @@ -1929,6 +1941,44 @@ mod admin_handler_tests { assert!(deleted_at_of(&pool, user).await.is_none()); } + #[tokio::test] + async fn restore_user_conflict_once_purge_released_the_email() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + seed_team(&pool, owner).await; + sqlx::query("UPDATE users SET deleted_at = now() - INTERVAL '31 days' WHERE id = $1") + .bind(owner) + .execute(&pool) + .await + .expect("soft-delete past grace"); + crate::user_purge::purge_expired(&pool, 30).await.expect("purge"); + + let res = restore_user(State(pool.clone()), admin(), Path(owner)).await; + + assert_eq!(res.unwrap_err(), StatusCode::CONFLICT); + assert!(deleted_at_of(&pool, owner).await.is_some()); + } + + #[tokio::test] + async fn delete_user_force_succeeds_after_soft_delete() { + let pool = test_pool_or_skip!(); + let user = seed_user(&pool).await; + for force in [None, Some(true)] { + let _ = delete_user( + State(pool.clone()), + admin(), + notifier(), + Path(user), + Query(DeleteQuery { force }), + Json(DeleteBody { reason: None }), + ) + .await + .expect("delete ok"); + } + + assert!(!user_exists(&pool, user).await); + } + #[tokio::test] async fn restore_user_not_found_when_not_deleted() { let pool = test_pool_or_skip!(); diff --git a/src/user_purge.rs b/src/user_purge.rs new file mode 100644 index 0000000..d928f45 --- /dev/null +++ b/src/user_purge.rs @@ -0,0 +1,186 @@ +use chrono::{DateTime, Utc}; +use serde_json::{json, Value}; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +const ACTOR: &str = "system"; + +pub const RELEASED_EMAIL: &str = "id::text || '@purged.invalid'"; + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct PurgeCounts { + pub deleted: u64, + pub released: u64, +} + +/// Hard-delete users soft-deleted more than `grace_days` ago. A user other rows +/// still reference cannot be deleted, so their email is released instead. +pub async fn purge_expired(pool: &PgPool, grace_days: i64) -> Result { + let expired: Vec = sqlx::query_scalar( + "SELECT id FROM users WHERE deleted_at < now() - ($1 * INTERVAL '1 day') ORDER BY deleted_at", + ) + .bind(grace_days) + .fetch_all(pool) + .await?; + + let mut counts = PurgeCounts::default(); + for id in expired { + match hard_delete(pool, id, grace_days).await { + Ok(deleted) => counts.deleted += u64::from(deleted), + Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("23503") => { + let constraint = e.constraint().unwrap_or("unknown").to_string(); + counts.released += u64::from(release_email(pool, id, grace_days, &constraint).await?); + } + Err(e) => return Err(e), + } + } + Ok(counts) +} + +async fn hard_delete(pool: &PgPool, id: Uuid, grace_days: i64) -> Result { + let mut tx = pool.begin().await?; + let deleted_at: Option> = sqlx::query_scalar( + "DELETE FROM users WHERE id = $1 AND deleted_at < now() - ($2 * INTERVAL '1 day') + RETURNING deleted_at", + ) + .bind(id) + .bind(grace_days) + .fetch_optional(&mut *tx) + .await?; + let Some(deleted_at) = deleted_at else { return Ok(false) }; + + audit(&mut tx, id, "purge_user", json!({ "user_id": id, "deleted_at": deleted_at, "grace_days": grace_days })).await?; + tx.commit().await?; + Ok(true) +} + +async fn release_email(pool: &PgPool, id: Uuid, grace_days: i64, constraint: &str) -> Result { + let mut tx = pool.begin().await?; + let released = sqlx::query(&format!( + "UPDATE users SET email = {RELEASED_EMAIL} + WHERE id = $1 AND deleted_at < now() - ($2 * INTERVAL '1 day') AND email <> {RELEASED_EMAIL}" + )) + .bind(id) + .bind(grace_days) + .execute(&mut *tx) + .await? + .rows_affected() + > 0; + if !released { + return Ok(false); + } + + audit(&mut tx, id, "purge_user_release_email", json!({ "user_id": id, "blocked_by": constraint, "grace_days": grace_days })).await?; + tx.commit().await?; + Ok(true) +} + +async fn audit(tx: &mut Transaction<'_, Postgres>, id: Uuid, action: &str, detail: Value) -> Result<(), sqlx::Error> { + sqlx::query("INSERT INTO admin_audit_log (admin_email, target_id, action, detail) VALUES ($1, $2, $3, $4)") + .bind(ACTOR) + .bind(id) + .bind(action) + .bind(detail) + .execute(&mut **tx) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_pool_or_skip; + use crate::test_support::{seed_team, seed_user}; + + const GRACE: i64 = 30; + + async fn soft_delete_days_ago(pool: &PgPool, user: Uuid, days: i64) { + sqlx::query("UPDATE users SET deleted_at = now() - ($1 * INTERVAL '1 day') WHERE id = $2") + .bind(days) + .bind(user) + .execute(pool) + .await + .expect("soft-delete user"); + sqlx::query("INSERT INTO admin_audit_log (admin_email, target_id, action) VALUES ('a@test', $1, 'delete_user_soft')") + .bind(user) + .execute(pool) + .await + .expect("audit soft delete"); + } + + async fn email_of(pool: &PgPool, user: Uuid) -> Option { + sqlx::query_scalar("SELECT email FROM users WHERE id = $1") + .bind(user) + .fetch_optional(pool) + .await + .expect("read email") + } + + async fn audit_count(pool: &PgPool, user: Uuid, action: &str) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM admin_audit_log WHERE target_id = $1 AND action = $2") + .bind(user) + .bind(action) + .fetch_one(pool) + .await + .expect("count audit rows") + } + + async fn register_with_email(pool: &PgPool, email: &str) -> Result { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO users (id, email, account_id, auth_hash, handle) VALUES ($1, $2, $3, 'h', $4)", + ) + .bind(id) + .bind(email) + .bind(Uuid::new_v4()) + .bind(format!("u{}", id.simple())) + .execute(pool) + .await?; + Ok(id) + } + + #[tokio::test] + async fn purges_user_past_grace_and_frees_their_email() { + let pool = test_pool_or_skip!(); + let user = seed_user(&pool).await; + let email = email_of(&pool, user).await.unwrap(); + soft_delete_days_ago(&pool, user, GRACE + 1).await; + + purge_expired(&pool, GRACE).await.expect("purge"); + + assert_eq!(email_of(&pool, user).await, None, "row is gone"); + assert_eq!(audit_count(&pool, user, "purge_user").await, 1); + assert_eq!(audit_count(&pool, user, "delete_user_soft").await, 1, "audit trail outlives the user"); + register_with_email(&pool, &email.to_uppercase()).await.expect("email is free again"); + } + + #[tokio::test] + async fn keeps_live_users_and_users_within_grace() { + let pool = test_pool_or_skip!(); + let live = seed_user(&pool).await; + let recent = seed_user(&pool).await; + soft_delete_days_ago(&pool, recent, GRACE - 1).await; + + purge_expired(&pool, GRACE).await.expect("purge"); + + assert!(email_of(&pool, live).await.is_some()); + let email = email_of(&pool, recent).await.expect("recently deleted user kept"); + assert!(register_with_email(&pool, &email).await.is_err(), "email stays reserved during grace"); + } + + #[tokio::test] + async fn releases_email_when_references_block_the_delete() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + seed_team(&pool, owner).await; + let email = email_of(&pool, owner).await.unwrap(); + soft_delete_days_ago(&pool, owner, GRACE + 1).await; + + purge_expired(&pool, GRACE).await.expect("purge"); + purge_expired(&pool, GRACE).await.expect("second purge"); + + assert_eq!(email_of(&pool, owner).await, Some(format!("{owner}@purged.invalid"))); + assert_eq!(audit_count(&pool, owner, "purge_user_release_email").await, 1, "released once"); + register_with_email(&pool, &email).await.expect("email is free again"); + } +}