Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
3 changes: 3 additions & 0 deletions migrations/056_admin_audit_target_no_fk.sql
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
58 changes: 54 additions & 4 deletions src/routes/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<PgPool>,
Extension(AdminEmail(admin_email)): Extension<AdminEmail>,
Path(user_id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
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
Expand All @@ -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;
Expand Down Expand Up @@ -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!();
Expand Down
186 changes: 186 additions & 0 deletions src/user_purge.rs
Original file line number Diff line number Diff line change
@@ -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<PurgeCounts, sqlx::Error> {
let expired: Vec<Uuid> = 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<bool, sqlx::Error> {
let mut tx = pool.begin().await?;
let deleted_at: Option<DateTime<Utc>> = 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<bool, sqlx::Error> {
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<String> {
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<Uuid, sqlx::Error> {
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");
}
}
Loading