tenant deletion/reset

This commit is contained in:
2025-11-11 12:17:24 +01:00
parent 4f29ff73c6
commit 8751637b92
15 changed files with 1015 additions and 27 deletions
+191 -13
View File
@@ -14,7 +14,7 @@ use papercrate::{
config::AppConfig,
db::{self, PgPool},
documents::search::ensure_quickwit_index,
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_DELETE_TENANT},
models::{
DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership,
Tenant, TenantStatus, User,
@@ -27,6 +27,7 @@ use papercrate::{
storage::{ObjectStorage, S3Storage, TenantStorage},
tenants::TenantService,
utils::{text::normalize_identifier, tracing::init_tracing},
workers::tenants::{build_delete_proof_message, sign_delete_proof, DeleteAction},
};
#[derive(Parser)]
@@ -58,6 +59,15 @@ enum Command {
},
DeleteTenant {
tenant_id: Uuid,
#[arg(long = "tenant-name")]
tenant_name: String,
},
ResetTenant {
tenant_id: Uuid,
#[arg(long = "tenant-name")]
tenant_name: String,
#[arg(long = "final-status", value_enum, default_value_t = TenantFinalStatusArg::Active)]
final_status: TenantFinalStatusArg,
},
AddUserToTenant {
username: String,
@@ -80,6 +90,15 @@ enum Command {
QuickwitDelete {
tenant_id: Uuid,
},
EnqueueDeleteTenant {
tenant_id: Uuid,
#[arg(long = "tenant-name")]
tenant_name: String,
#[arg(long = "remove-tenant")]
remove_tenant: bool,
#[arg(long = "final-status", value_enum)]
final_status: Option<TenantFinalStatusArg>,
},
MagicToken {
username: String,
#[arg(long = "ttl-minutes", default_value_t = 10)]
@@ -112,6 +131,21 @@ impl From<MagicTokenKindArg> for MagicTokenKind {
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum TenantFinalStatusArg {
Active,
Suspended,
}
impl TenantFinalStatusArg {
fn as_str(&self) -> &'static str {
match self {
TenantFinalStatusArg::Active => "active",
TenantFinalStatusArg::Suspended => "suspended",
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing("info");
@@ -128,7 +162,15 @@ async fn main() -> Result<()> {
storage_root,
quickwit_index,
} => create_tenant(&pool, &name, storage_root, quickwit_index)?,
Command::DeleteTenant { tenant_id } => delete_tenant(&pool, tenant_id)?,
Command::DeleteTenant {
tenant_id,
tenant_name,
} => delete_tenant(&config, &pool, tenant_id, &tenant_name)?,
Command::ResetTenant {
tenant_id,
tenant_name,
final_status,
} => reset_tenant(&config, &pool, tenant_id, &tenant_name, final_status)?,
Command::AddUserToTenant {
username,
tenant_id,
@@ -148,6 +190,19 @@ async fn main() -> Result<()> {
Command::QuickwitDelete { tenant_id } => {
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await?
}
Command::EnqueueDeleteTenant {
tenant_id,
tenant_name,
remove_tenant,
final_status,
} => enqueue_delete_tenant_job(
&config,
&pool,
tenant_id,
&tenant_name,
remove_tenant,
final_status,
)?,
Command::MagicToken {
username,
ttl_minutes,
@@ -233,8 +288,17 @@ fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
.optional()?
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
diesel::delete(user_memberships::table.filter(user_memberships::user_id.eq(user.id)))
.execute(&mut conn)?;
let has_memberships: bool = select(exists(
user_memberships::table.filter(user_memberships::user_id.eq(user.id)),
))
.get_result(&mut conn)?;
if has_memberships {
bail!(
"user '{}' is still a member of one or more tenants; remove memberships first",
username
);
}
diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?;
println!("deleted user '{}'", username);
@@ -335,7 +399,51 @@ fn hash_token(token: &str) -> String {
hex::encode(hasher.finalize())
}
fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
fn delete_tenant(
config: &AppConfig,
pool: &PgPool,
tenant_id: Uuid,
tenant_name: &str,
) -> Result<()> {
enqueue_delete_tenant_job_internal(config, pool, tenant_id, tenant_name, true, None)?;
println!(
"delete job enqueued; tenant '{}' will be permanently removed",
tenant_name
);
Ok(())
}
fn reset_tenant(
config: &AppConfig,
pool: &PgPool,
tenant_id: Uuid,
tenant_name: &str,
final_status: TenantFinalStatusArg,
) -> Result<()> {
enqueue_delete_tenant_job_internal(
config,
pool,
tenant_id,
tenant_name,
false,
Some(final_status),
)?;
println!(
"reset job enqueued; tenant '{}' will be wiped and set to {}",
tenant_name,
final_status.as_str()
);
Ok(())
}
fn enqueue_delete_tenant_job_internal(
config: &AppConfig,
pool: &PgPool,
tenant_id: Uuid,
expected_name: &str,
remove_tenant: bool,
final_status: Option<TenantFinalStatusArg>,
) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
@@ -344,19 +452,89 @@ fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
let member_exists: bool = select(exists(
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)),
))
.get_result(&mut conn)?;
if member_exists {
bail!("tenant '{}' still has user memberships", tenant.name);
if tenant.name != expected_name {
bail!(
"tenant name mismatch: expected '{}', database has '{}'",
expected_name,
tenant.name
);
}
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
println!("deleted tenant '{}'", tenant.name);
diesel::update(tenants::table.find(tenant.id))
.set(tenants::status.eq(TenantStatus::Deleting))
.execute(&mut conn)?;
let nonce = generate_random_token();
let issued_at = Utc::now();
let issued_at_str = issued_at.to_rfc3339();
let action = if remove_tenant {
DeleteAction::Delete
} else {
DeleteAction::Reset
};
let resolved_final_status = if remove_tenant {
None
} else {
Some(
final_status
.unwrap_or(TenantFinalStatusArg::Suspended)
.as_str(),
)
};
let message = build_delete_proof_message(
tenant.id,
expected_name,
action,
&nonce,
&issued_at_str,
resolved_final_status,
);
let signature = sign_delete_proof(&config.jwt_secret, &message).map_err(|err| anyhow!(err))?;
let mut payload = serde_json::json!({
"remove_tenant": remove_tenant,
"tenant_name": tenant.name.clone(),
"action": action.as_str(),
"nonce": nonce,
"issued_at": issued_at_str,
"signature": signature,
});
if let Some(status) = resolved_final_status {
payload["final_status"] = serde_json::json!(status);
}
enqueue_job(&mut conn, tenant.id, JOB_DELETE_TENANT, payload, None)?;
let status_label = if remove_tenant {
"deleted"
} else {
final_status.map(|s| s.as_str()).unwrap_or("suspended")
};
println!(
"delete-tenant job enqueued for '{}' (remove_tenant={}, final_status={})",
tenant.name, remove_tenant, status_label
);
Ok(())
}
fn enqueue_delete_tenant_job(
config: &AppConfig,
pool: &PgPool,
tenant_id: Uuid,
tenant_name: &str,
remove_tenant: bool,
final_status: Option<TenantFinalStatusArg>,
) -> Result<()> {
enqueue_delete_tenant_job_internal(
config,
pool,
tenant_id,
tenant_name,
remove_tenant,
final_status,
)
}
fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;