tenant deletion/reset
This commit is contained in:
+191
-13
@@ -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")?;
|
||||
|
||||
|
||||
@@ -163,6 +163,20 @@ pub async fn ensure_quickwit_index(client: &Client, endpoint: &str, index_id: &s
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> {
|
||||
let base = endpoint.trim_end_matches('/');
|
||||
let url = format!("{}/api/v1/indexes/{}", base, index_id);
|
||||
let response = client.delete(&url).send().await?;
|
||||
match response.status() {
|
||||
status if status.is_success() => Ok(()),
|
||||
StatusCode::NOT_FOUND => Ok(()),
|
||||
status => {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
bail!("quickwit delete index failed with status {status}: {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
||||
if let Some(value) = hit.get(key) {
|
||||
|
||||
@@ -20,6 +20,7 @@ pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
|
||||
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
|
||||
pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
|
||||
pub const JOB_PURGE_DOCUMENT: &str = "purge-document";
|
||||
pub const JOB_DELETE_TENANT: &str = "delete-tenant";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum JobQueueError {
|
||||
|
||||
@@ -654,7 +654,8 @@ pub struct Job {
|
||||
pub last_error: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
pub tenant_id: Option<Uuid>,
|
||||
pub result: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
|
||||
@@ -131,7 +131,8 @@ diesel::table! {
|
||||
last_error -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
tenant_id -> Nullable<Uuid>,
|
||||
result -> Nullable<Jsonb>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,10 @@ impl TenantStorage {
|
||||
format!("{}{}", self.root, key)
|
||||
}
|
||||
|
||||
pub fn root_prefix(&self) -> &str {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub async fn put_object(
|
||||
&self,
|
||||
key: &str,
|
||||
|
||||
@@ -129,6 +129,25 @@ impl FakeStorage {
|
||||
let guard = self.objects.lock().await;
|
||||
guard.len()
|
||||
}
|
||||
|
||||
pub async fn object_count_with_prefix(&self, prefix: &str) -> usize {
|
||||
let guard = self.objects.lock().await;
|
||||
guard.keys().filter(|key| key.starts_with(prefix)).count()
|
||||
}
|
||||
|
||||
pub async fn contains_key(&self, key: &str) -> bool {
|
||||
let guard = self.objects.lock().await;
|
||||
guard.contains_key(key)
|
||||
}
|
||||
|
||||
pub async fn keys_with_prefix(&self, prefix: &str) -> Vec<String> {
|
||||
let guard = self.objects.lock().await;
|
||||
guard
|
||||
.keys()
|
||||
.filter(|key| key.starts_with(prefix))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TestApp {
|
||||
|
||||
@@ -50,7 +50,16 @@ impl JobHandler for AnalyzeDocumentJob {
|
||||
job: crate::models::Job,
|
||||
storage: TenantStorage,
|
||||
) -> JobExecution {
|
||||
if let Err(err) = ensure_active_tenant(&state, job.tenant_id) {
|
||||
let tenant_id = match job.tenant_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return JobExecution::Failed {
|
||||
error: "job is no longer associated with a tenant".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = ensure_active_tenant(&state, tenant_id) {
|
||||
return JobExecution::Failed {
|
||||
error: err.to_string(),
|
||||
};
|
||||
@@ -68,7 +77,7 @@ impl JobHandler for AnalyzeDocumentJob {
|
||||
let mut context = DocumentVersionTaskContext::new(
|
||||
job.id,
|
||||
JOB_ANALYZE_DOCUMENT,
|
||||
job.tenant_id,
|
||||
tenant_id,
|
||||
payload.document_id,
|
||||
payload.document_version_id,
|
||||
payload.force,
|
||||
|
||||
@@ -23,7 +23,7 @@ pub mod taskflow;
|
||||
pub mod tenants;
|
||||
pub mod thumbnails;
|
||||
|
||||
use tenants::ProvisionTenantJob;
|
||||
use tenants::{DeleteTenantJob, ProvisionTenantJob};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum JobExecution {
|
||||
@@ -93,8 +93,23 @@ impl Worker {
|
||||
drop(conn);
|
||||
|
||||
if let Some(job) = job_opt {
|
||||
let tenant_id = match job.tenant_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
warn!(job_id = %job.id, job_type = %job.job_type, "job detached from tenant; marking failed");
|
||||
if let Ok(mut conn) = self.state.db_unscoped() {
|
||||
let _ = mark_job_failed(
|
||||
&mut conn,
|
||||
job.id,
|
||||
"job detached from tenant context",
|
||||
);
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(handler) = self.handlers.get(job.job_type.as_str()) {
|
||||
let execution = match self.state.storage_for_tenant(job.tenant_id) {
|
||||
let execution = match self.state.storage_for_tenant(tenant_id) {
|
||||
Ok(storage) => {
|
||||
handler
|
||||
.handle(self.state.clone(), job.clone(), storage)
|
||||
@@ -153,6 +168,7 @@ pub fn default_handlers() -> Vec<Arc<dyn JobHandler>> {
|
||||
Arc::new(analyze::AnalyzeDocumentJob::new()),
|
||||
Arc::new(purge::PurgeDocumentJob::new()),
|
||||
Arc::new(ProvisionTenantJob::new()),
|
||||
Arc::new(DeleteTenantJob::new()),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,16 @@ impl JobHandler for PurgeDocumentJob {
|
||||
job: crate::models::Job,
|
||||
storage: TenantStorage,
|
||||
) -> JobExecution {
|
||||
if let Err(err) = ensure_active_tenant(&state, job.tenant_id) {
|
||||
let tenant_id = match job.tenant_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return JobExecution::Failed {
|
||||
error: "job is no longer associated with a tenant".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = ensure_active_tenant(&state, tenant_id) {
|
||||
return JobExecution::Failed {
|
||||
error: err.to_string(),
|
||||
};
|
||||
@@ -71,7 +80,7 @@ impl JobHandler for PurgeDocumentJob {
|
||||
let mut context = PurgeTaskContext::new(
|
||||
job.id,
|
||||
JOB_PURGE_DOCUMENT,
|
||||
job.tenant_id,
|
||||
tenant_id,
|
||||
payload.document_id,
|
||||
state.clone(),
|
||||
storage,
|
||||
|
||||
@@ -2,21 +2,30 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use chrono::{DateTime, Utc};
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::Jsonb;
|
||||
use hmac::{Hmac, Mac};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::auth::capability_sets::{
|
||||
ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities,
|
||||
webdav_capabilities,
|
||||
};
|
||||
use crate::documents::search::ensure_quickwit_index;
|
||||
use crate::jobs::JOB_PROVISION_TENANT;
|
||||
use crate::models::{NewUserMembership, TenantStatus};
|
||||
use crate::schema::{tenants, user_memberships};
|
||||
use crate::documents::search::{delete_quickwit_index, ensure_quickwit_index};
|
||||
use crate::jobs::{JOB_DELETE_TENANT, JOB_PROVISION_TENANT};
|
||||
use crate::models::{NewUserMembership, Tenant, TenantStatus};
|
||||
use crate::schema::{
|
||||
api_tokens, correspondents, document_asset_objects, document_assets, document_correspondents,
|
||||
document_tags, document_versions, documents, folders, tags, tenants, user_memberships,
|
||||
user_sessions,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::tenants::TenantRepository;
|
||||
use crate::workers::{
|
||||
@@ -25,6 +34,10 @@ use crate::workers::{
|
||||
JobExecution, JobHandler,
|
||||
};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
const DELETE_PROOF_TTL_SECONDS: i64 = 300;
|
||||
const DELETE_PROOF_VERSION: &str = "v1";
|
||||
|
||||
pub struct ProvisionTenantJob;
|
||||
|
||||
impl ProvisionTenantJob {
|
||||
@@ -45,11 +58,20 @@ impl JobHandler for ProvisionTenantJob {
|
||||
job: crate::models::Job,
|
||||
_storage: crate::storage::TenantStorage,
|
||||
) -> JobExecution {
|
||||
let tenant_id = match job.tenant_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return JobExecution::Failed {
|
||||
error: "provision job is missing tenant context".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let members = ProvisionPayload::from_job(&job).unwrap_or_default();
|
||||
let mut context = ProvisionContext::new(
|
||||
job.id,
|
||||
JOB_PROVISION_TENANT,
|
||||
job.tenant_id,
|
||||
tenant_id,
|
||||
state.clone(),
|
||||
members,
|
||||
);
|
||||
@@ -233,6 +255,244 @@ impl Task<ProvisionContext> for ProvisionTask {
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_tenant(
|
||||
state: &Arc<AppState>,
|
||||
storage: crate::storage::TenantStorage,
|
||||
tenant_id: Uuid,
|
||||
current_job_id: Uuid,
|
||||
payload: &DeleteTenantPayload,
|
||||
) -> Result<(), String> {
|
||||
let remove_tenant = payload.remove_tenant;
|
||||
let tenant = {
|
||||
let mut conn = state
|
||||
.db_unscoped()
|
||||
.map_err(|err| format!("failed to get db connection: {err:?}"))?;
|
||||
TenantRepository::get_by_id(&mut conn, tenant_id)
|
||||
.map_err(|err| format!("tenant lookup failed: {err:?}"))?
|
||||
};
|
||||
|
||||
if tenant.name != payload.tenant_name {
|
||||
return Err(format!(
|
||||
"tenant name mismatch: expected '{}', got '{}'",
|
||||
payload.tenant_name, tenant.name
|
||||
));
|
||||
}
|
||||
|
||||
if tenant.status != TenantStatus::Deleting {
|
||||
return Err(format!(
|
||||
"tenant status '{}' not eligible for deletion",
|
||||
tenant.status.as_str()
|
||||
));
|
||||
}
|
||||
|
||||
match (payload_action_applicable(remove_tenant), payload.action) {
|
||||
(DeleteAction::Delete, DeleteAction::Delete)
|
||||
| (DeleteAction::Reset, DeleteAction::Reset) => {}
|
||||
_ => {
|
||||
return Err("delete payload action mismatch".into());
|
||||
}
|
||||
}
|
||||
|
||||
let issued_at = DateTime::parse_from_rfc3339(&payload.issued_at)
|
||||
.map_err(|_| "invalid issued_at timestamp".to_string())?
|
||||
.with_timezone(&Utc);
|
||||
if (Utc::now() - issued_at).num_seconds().abs() > DELETE_PROOF_TTL_SECONDS {
|
||||
return Err("delete confirmation expired".into());
|
||||
}
|
||||
|
||||
let resolved_final_status = if remove_tenant {
|
||||
None
|
||||
} else {
|
||||
Some(payload.final_status.unwrap_or(FinalTenantStatus::Suspended))
|
||||
};
|
||||
let final_status_str = resolved_final_status.map(|s| s.as_str());
|
||||
|
||||
let message = build_delete_proof_message(
|
||||
tenant_id,
|
||||
&tenant.name,
|
||||
payload.action,
|
||||
&payload.nonce,
|
||||
&payload.issued_at,
|
||||
final_status_str,
|
||||
);
|
||||
|
||||
verify_delete_proof(&state.config.jwt_secret, &message, &payload.signature)?;
|
||||
|
||||
let object_keys = {
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("failed to scope tenant connection: {err:?}"))?;
|
||||
collect_object_keys(&mut conn)
|
||||
.map_err(|err| format!("failed to collect storage keys: {err}"))?
|
||||
};
|
||||
|
||||
delete_storage_objects(&storage, &object_keys)
|
||||
.await
|
||||
.map_err(|err| format!("failed to delete storage objects: {err}"))?;
|
||||
|
||||
reset_quickwit_index(state, &tenant, remove_tenant)
|
||||
.await
|
||||
.map_err(|err| format!("quickwit cleanup failed: {err}"))?;
|
||||
|
||||
{
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("failed to scope tenant connection: {err:?}"))?;
|
||||
delete_tenant_rows(&mut conn, tenant_id, remove_tenant)
|
||||
.map_err(|err| format!("tenant data cleanup failed: {err}"))?;
|
||||
}
|
||||
|
||||
{
|
||||
let mut conn = state
|
||||
.db_unscoped()
|
||||
.map_err(|err| format!("failed to get db connection: {err:?}"))?;
|
||||
|
||||
let detach_result = json!({
|
||||
"tenant": {
|
||||
"id": tenant.id,
|
||||
"name": tenant.name,
|
||||
},
|
||||
"action": payload.action.as_str(),
|
||||
"remove_tenant": remove_tenant,
|
||||
"final_status": final_status_str,
|
||||
"timestamp": Utc::now().to_rfc3339(),
|
||||
});
|
||||
|
||||
diesel::sql_query(
|
||||
"UPDATE jobs \
|
||||
SET tenant_id = NULL, \
|
||||
result = jsonb_set(COALESCE(result, '{}'::jsonb), '{detached_tenant}', $3::jsonb, true) \
|
||||
WHERE tenant_id = $1 AND id <> $2",
|
||||
)
|
||||
.bind::<diesel::sql_types::Uuid, _>(tenant_id)
|
||||
.bind::<diesel::sql_types::Uuid, _>(current_job_id)
|
||||
.bind::<Jsonb, _>(detach_result)
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("failed to detach tenant jobs: {err}"))?;
|
||||
|
||||
if remove_tenant {
|
||||
diesel::delete(tenants::table.find(tenant_id))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("failed to delete tenant row: {err}"))?;
|
||||
} else {
|
||||
let new_status = match resolved_final_status.unwrap_or(FinalTenantStatus::Suspended) {
|
||||
FinalTenantStatus::Active => TenantStatus::Active,
|
||||
FinalTenantStatus::Suspended => TenantStatus::Suspended,
|
||||
};
|
||||
diesel::update(tenants::table.find(tenant_id))
|
||||
.set((
|
||||
tenants::status.eq(new_status),
|
||||
tenants::updated_at.eq(Utc::now().naive_utc()),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("failed to update tenant status: {err}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct TenantObjectKeys {
|
||||
version_keys: Vec<String>,
|
||||
asset_keys: Vec<String>,
|
||||
}
|
||||
|
||||
fn collect_object_keys(conn: &mut PgConnection) -> Result<TenantObjectKeys, diesel::result::Error> {
|
||||
let version_keys = document_versions::table
|
||||
.select(document_versions::s3_key)
|
||||
.load::<String>(conn)?;
|
||||
let asset_keys = document_asset_objects::table
|
||||
.select(document_asset_objects::s3_key)
|
||||
.load::<String>(conn)?;
|
||||
|
||||
Ok(TenantObjectKeys {
|
||||
version_keys,
|
||||
asset_keys,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_storage_objects(
|
||||
storage: &crate::storage::TenantStorage,
|
||||
keys: &TenantObjectKeys,
|
||||
) -> Result<(), String> {
|
||||
for key in keys.version_keys.iter().chain(keys.asset_keys.iter()) {
|
||||
storage
|
||||
.delete_object(key)
|
||||
.await
|
||||
.map_err(|err| format!("failed to delete object '{key}': {err}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reset_quickwit_index(
|
||||
state: &Arc<AppState>,
|
||||
tenant: &Tenant,
|
||||
remove_index: bool,
|
||||
) -> Result<(), String> {
|
||||
let endpoint = match state.config.quickwit_endpoint.as_ref() {
|
||||
Some(endpoint) => endpoint,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let index_id = match tenant.quickwit_index.as_deref() {
|
||||
Some(index) => index,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let client = Client::new();
|
||||
delete_quickwit_index(&client, endpoint, index_id)
|
||||
.await
|
||||
.map_err(|err| format!("quickwit delete failed: {err}"))?;
|
||||
|
||||
if !remove_index {
|
||||
ensure_quickwit_index(&client, endpoint, index_id)
|
||||
.await
|
||||
.map_err(|err| format!("quickwit ensure failed: {err}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_tenant_rows(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
remove_memberships: bool,
|
||||
) -> Result<(), diesel::result::Error> {
|
||||
conn.transaction(|conn| {
|
||||
diesel::delete(
|
||||
document_asset_objects::table.filter(document_asset_objects::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(conn)?;
|
||||
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant_id)))
|
||||
.execute(conn)?;
|
||||
diesel::delete(
|
||||
document_correspondents::table.filter(document_correspondents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(conn)?;
|
||||
diesel::delete(document_tags::table.filter(document_tags::tenant_id.eq(tenant_id)))
|
||||
.execute(conn)?;
|
||||
diesel::delete(document_versions::table.filter(document_versions::tenant_id.eq(tenant_id)))
|
||||
.execute(conn)?;
|
||||
diesel::delete(documents::table.filter(documents::tenant_id.eq(tenant_id)))
|
||||
.execute(conn)?;
|
||||
diesel::delete(folders::table.filter(folders::tenant_id.eq(tenant_id))).execute(conn)?;
|
||||
diesel::delete(correspondents::table.filter(correspondents::tenant_id.eq(tenant_id)))
|
||||
.execute(conn)?;
|
||||
diesel::delete(tags::table.filter(tags::tenant_id.eq(tenant_id))).execute(conn)?;
|
||||
diesel::delete(user_sessions::table.filter(user_sessions::tenant_id.eq(tenant_id)))
|
||||
.execute(conn)?;
|
||||
diesel::delete(api_tokens::table.filter(api_tokens::tenant_id.eq(tenant_id)))
|
||||
.execute(conn)?;
|
||||
if remove_memberships {
|
||||
diesel::delete(
|
||||
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(conn)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ProvisionPayload {
|
||||
#[serde(default)]
|
||||
@@ -246,3 +506,140 @@ impl ProvisionPayload {
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
pub struct DeleteTenantJob;
|
||||
|
||||
impl DeleteTenantJob {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_delete_proof_message(
|
||||
tenant_id: Uuid,
|
||||
tenant_name: &str,
|
||||
action: DeleteAction,
|
||||
nonce: &str,
|
||||
issued_at: &str,
|
||||
final_status: Option<&str>,
|
||||
) -> String {
|
||||
let status = final_status.unwrap_or("none");
|
||||
format!(
|
||||
"{}|{}|{}|{}|{}|{}|{}",
|
||||
DELETE_PROOF_VERSION,
|
||||
tenant_id,
|
||||
tenant_name,
|
||||
action.as_str(),
|
||||
nonce,
|
||||
issued_at,
|
||||
status
|
||||
)
|
||||
}
|
||||
|
||||
pub fn sign_delete_proof(secret: &str, message: &str) -> Result<String, String> {
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
||||
.map_err(|err| format!("failed to init hmac: {err}"))?;
|
||||
mac.update(message.as_bytes());
|
||||
let bytes = mac.finalize().into_bytes();
|
||||
Ok(hex::encode(bytes))
|
||||
}
|
||||
|
||||
fn verify_delete_proof(secret: &str, message: &str, signature: &str) -> Result<(), String> {
|
||||
let signature_bytes = hex::decode(signature)
|
||||
.map_err(|_| "invalid delete proof signature encoding".to_string())?;
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
||||
.map_err(|err| format!("failed to init hmac: {err}"))?;
|
||||
mac.update(message.as_bytes());
|
||||
mac.verify_slice(&signature_bytes)
|
||||
.map_err(|_| "delete proof signature mismatch".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeleteTenantPayload {
|
||||
#[serde(default)]
|
||||
remove_tenant: bool,
|
||||
#[serde(default)]
|
||||
final_status: Option<FinalTenantStatus>,
|
||||
tenant_name: String,
|
||||
action: DeleteAction,
|
||||
nonce: String,
|
||||
issued_at: String,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum FinalTenantStatus {
|
||||
Active,
|
||||
Suspended,
|
||||
}
|
||||
|
||||
impl FinalTenantStatus {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
FinalTenantStatus::Active => "active",
|
||||
FinalTenantStatus::Suspended => "suspended",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DeleteAction {
|
||||
Delete,
|
||||
Reset,
|
||||
}
|
||||
|
||||
impl DeleteAction {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DeleteAction::Delete => "delete",
|
||||
DeleteAction::Reset => "reset",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_action_applicable(remove_tenant: bool) -> DeleteAction {
|
||||
if remove_tenant {
|
||||
DeleteAction::Delete
|
||||
} else {
|
||||
DeleteAction::Reset
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl JobHandler for DeleteTenantJob {
|
||||
fn job_type(&self) -> &'static str {
|
||||
JOB_DELETE_TENANT
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
state: Arc<AppState>,
|
||||
job: crate::models::Job,
|
||||
storage: crate::storage::TenantStorage,
|
||||
) -> JobExecution {
|
||||
let payload: DeleteTenantPayload = match serde_json::from_value(job.payload.clone()) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
return JobExecution::Failed {
|
||||
error: format!("invalid delete tenant payload: {err}"),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tenant_id = match job.tenant_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return JobExecution::Failed {
|
||||
error: "delete job is missing tenant context".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match delete_tenant(&state, storage, tenant_id, job.id, &payload).await {
|
||||
Ok(()) => JobExecution::Success,
|
||||
Err(err) => JobExecution::Failed { error: err },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user