tenant deletion/reset
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE jobs
|
||||||
|
DROP CONSTRAINT jobs_tenant_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE jobs
|
||||||
|
ALTER COLUMN tenant_id SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE jobs
|
||||||
|
ADD CONSTRAINT jobs_tenant_id_fkey
|
||||||
|
FOREIGN KEY (tenant_id)
|
||||||
|
REFERENCES tenants(id);
|
||||||
|
|
||||||
|
ALTER TABLE jobs
|
||||||
|
DROP COLUMN result;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE jobs
|
||||||
|
ALTER COLUMN tenant_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE jobs
|
||||||
|
DROP CONSTRAINT jobs_tenant_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE jobs
|
||||||
|
ADD CONSTRAINT jobs_tenant_id_fkey
|
||||||
|
FOREIGN KEY (tenant_id)
|
||||||
|
REFERENCES tenants(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE jobs
|
||||||
|
ADD COLUMN result JSONB;
|
||||||
+191
-13
@@ -14,7 +14,7 @@ use papercrate::{
|
|||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
db::{self, PgPool},
|
db::{self, PgPool},
|
||||||
documents::search::ensure_quickwit_index,
|
documents::search::ensure_quickwit_index,
|
||||||
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_DELETE_TENANT},
|
||||||
models::{
|
models::{
|
||||||
DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership,
|
DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership,
|
||||||
Tenant, TenantStatus, User,
|
Tenant, TenantStatus, User,
|
||||||
@@ -27,6 +27,7 @@ use papercrate::{
|
|||||||
storage::{ObjectStorage, S3Storage, TenantStorage},
|
storage::{ObjectStorage, S3Storage, TenantStorage},
|
||||||
tenants::TenantService,
|
tenants::TenantService,
|
||||||
utils::{text::normalize_identifier, tracing::init_tracing},
|
utils::{text::normalize_identifier, tracing::init_tracing},
|
||||||
|
workers::tenants::{build_delete_proof_message, sign_delete_proof, DeleteAction},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -58,6 +59,15 @@ enum Command {
|
|||||||
},
|
},
|
||||||
DeleteTenant {
|
DeleteTenant {
|
||||||
tenant_id: Uuid,
|
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 {
|
AddUserToTenant {
|
||||||
username: String,
|
username: String,
|
||||||
@@ -80,6 +90,15 @@ enum Command {
|
|||||||
QuickwitDelete {
|
QuickwitDelete {
|
||||||
tenant_id: Uuid,
|
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 {
|
MagicToken {
|
||||||
username: String,
|
username: String,
|
||||||
#[arg(long = "ttl-minutes", default_value_t = 10)]
|
#[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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
init_tracing("info");
|
init_tracing("info");
|
||||||
@@ -128,7 +162,15 @@ async fn main() -> Result<()> {
|
|||||||
storage_root,
|
storage_root,
|
||||||
quickwit_index,
|
quickwit_index,
|
||||||
} => create_tenant(&pool, &name, 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 {
|
Command::AddUserToTenant {
|
||||||
username,
|
username,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
@@ -148,6 +190,19 @@ async fn main() -> Result<()> {
|
|||||||
Command::QuickwitDelete { tenant_id } => {
|
Command::QuickwitDelete { tenant_id } => {
|
||||||
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await?
|
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 {
|
Command::MagicToken {
|
||||||
username,
|
username,
|
||||||
ttl_minutes,
|
ttl_minutes,
|
||||||
@@ -233,8 +288,17 @@ fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
|
|||||||
.optional()?
|
.optional()?
|
||||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||||
|
|
||||||
diesel::delete(user_memberships::table.filter(user_memberships::user_id.eq(user.id)))
|
let has_memberships: bool = select(exists(
|
||||||
.execute(&mut conn)?;
|
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)?;
|
diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?;
|
||||||
|
|
||||||
println!("deleted user '{}'", username);
|
println!("deleted user '{}'", username);
|
||||||
@@ -335,7 +399,51 @@ fn hash_token(token: &str) -> String {
|
|||||||
hex::encode(hasher.finalize())
|
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 mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
let tenant: Tenant = tenants::table
|
let tenant: Tenant = tenants::table
|
||||||
@@ -344,19 +452,89 @@ fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
|
|||||||
.optional()?
|
.optional()?
|
||||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||||
|
|
||||||
let member_exists: bool = select(exists(
|
if tenant.name != expected_name {
|
||||||
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)),
|
bail!(
|
||||||
))
|
"tenant name mismatch: expected '{}', database has '{}'",
|
||||||
.get_result(&mut conn)?;
|
expected_name,
|
||||||
if member_exists {
|
tenant.name
|
||||||
bail!("tenant '{}' still has user memberships", tenant.name);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
|
diesel::update(tenants::table.find(tenant.id))
|
||||||
println!("deleted tenant '{}'", tenant.name);
|
.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(())
|
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<()> {
|
fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> {
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
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> {
|
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
||||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
for key in ["_source", "source", "fields", "stored_fields"] {
|
||||||
if let Some(value) = hit.get(key) {
|
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_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
|
||||||
pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
|
pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
|
||||||
pub const JOB_PURGE_DOCUMENT: &str = "purge-document";
|
pub const JOB_PURGE_DOCUMENT: &str = "purge-document";
|
||||||
|
pub const JOB_DELETE_TENANT: &str = "delete-tenant";
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum JobQueueError {
|
pub enum JobQueueError {
|
||||||
|
|||||||
@@ -654,7 +654,8 @@ pub struct Job {
|
|||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Option<Uuid>,
|
||||||
|
pub result: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ diesel::table! {
|
|||||||
last_error -> Nullable<Text>,
|
last_error -> Nullable<Text>,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_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)
|
format!("{}{}", self.root, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn root_prefix(&self) -> &str {
|
||||||
|
&self.root
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn put_object(
|
pub async fn put_object(
|
||||||
&self,
|
&self,
|
||||||
key: &str,
|
key: &str,
|
||||||
|
|||||||
@@ -129,6 +129,25 @@ impl FakeStorage {
|
|||||||
let guard = self.objects.lock().await;
|
let guard = self.objects.lock().await;
|
||||||
guard.len()
|
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 {
|
pub struct TestApp {
|
||||||
|
|||||||
@@ -50,7 +50,16 @@ impl JobHandler for AnalyzeDocumentJob {
|
|||||||
job: crate::models::Job,
|
job: crate::models::Job,
|
||||||
storage: TenantStorage,
|
storage: TenantStorage,
|
||||||
) -> JobExecution {
|
) -> 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 {
|
return JobExecution::Failed {
|
||||||
error: err.to_string(),
|
error: err.to_string(),
|
||||||
};
|
};
|
||||||
@@ -68,7 +77,7 @@ impl JobHandler for AnalyzeDocumentJob {
|
|||||||
let mut context = DocumentVersionTaskContext::new(
|
let mut context = DocumentVersionTaskContext::new(
|
||||||
job.id,
|
job.id,
|
||||||
JOB_ANALYZE_DOCUMENT,
|
JOB_ANALYZE_DOCUMENT,
|
||||||
job.tenant_id,
|
tenant_id,
|
||||||
payload.document_id,
|
payload.document_id,
|
||||||
payload.document_version_id,
|
payload.document_version_id,
|
||||||
payload.force,
|
payload.force,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub mod taskflow;
|
|||||||
pub mod tenants;
|
pub mod tenants;
|
||||||
pub mod thumbnails;
|
pub mod thumbnails;
|
||||||
|
|
||||||
use tenants::ProvisionTenantJob;
|
use tenants::{DeleteTenantJob, ProvisionTenantJob};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum JobExecution {
|
pub enum JobExecution {
|
||||||
@@ -93,8 +93,23 @@ impl Worker {
|
|||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
if let Some(job) = job_opt {
|
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()) {
|
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) => {
|
Ok(storage) => {
|
||||||
handler
|
handler
|
||||||
.handle(self.state.clone(), job.clone(), storage)
|
.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(analyze::AnalyzeDocumentJob::new()),
|
||||||
Arc::new(purge::PurgeDocumentJob::new()),
|
Arc::new(purge::PurgeDocumentJob::new()),
|
||||||
Arc::new(ProvisionTenantJob::new()),
|
Arc::new(ProvisionTenantJob::new()),
|
||||||
|
Arc::new(DeleteTenantJob::new()),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,16 @@ impl JobHandler for PurgeDocumentJob {
|
|||||||
job: crate::models::Job,
|
job: crate::models::Job,
|
||||||
storage: TenantStorage,
|
storage: TenantStorage,
|
||||||
) -> JobExecution {
|
) -> 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 {
|
return JobExecution::Failed {
|
||||||
error: err.to_string(),
|
error: err.to_string(),
|
||||||
};
|
};
|
||||||
@@ -71,7 +80,7 @@ impl JobHandler for PurgeDocumentJob {
|
|||||||
let mut context = PurgeTaskContext::new(
|
let mut context = PurgeTaskContext::new(
|
||||||
job.id,
|
job.id,
|
||||||
JOB_PURGE_DOCUMENT,
|
JOB_PURGE_DOCUMENT,
|
||||||
job.tenant_id,
|
tenant_id,
|
||||||
payload.document_id,
|
payload.document_id,
|
||||||
state.clone(),
|
state.clone(),
|
||||||
storage,
|
storage,
|
||||||
|
|||||||
@@ -2,21 +2,30 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::{DateTime, Utc};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use diesel::sql_types::Jsonb;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
use crate::auth::capability_sets::{
|
use crate::auth::capability_sets::{
|
||||||
ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities,
|
ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities,
|
||||||
webdav_capabilities,
|
webdav_capabilities,
|
||||||
};
|
};
|
||||||
use crate::documents::search::ensure_quickwit_index;
|
use crate::documents::search::{delete_quickwit_index, ensure_quickwit_index};
|
||||||
use crate::jobs::JOB_PROVISION_TENANT;
|
use crate::jobs::{JOB_DELETE_TENANT, JOB_PROVISION_TENANT};
|
||||||
use crate::models::{NewUserMembership, TenantStatus};
|
use crate::models::{NewUserMembership, Tenant, TenantStatus};
|
||||||
use crate::schema::{tenants, user_memberships};
|
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::state::AppState;
|
||||||
use crate::tenants::TenantRepository;
|
use crate::tenants::TenantRepository;
|
||||||
use crate::workers::{
|
use crate::workers::{
|
||||||
@@ -25,6 +34,10 @@ use crate::workers::{
|
|||||||
JobExecution, JobHandler,
|
JobExecution, JobHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
const DELETE_PROOF_TTL_SECONDS: i64 = 300;
|
||||||
|
const DELETE_PROOF_VERSION: &str = "v1";
|
||||||
|
|
||||||
pub struct ProvisionTenantJob;
|
pub struct ProvisionTenantJob;
|
||||||
|
|
||||||
impl ProvisionTenantJob {
|
impl ProvisionTenantJob {
|
||||||
@@ -45,11 +58,20 @@ impl JobHandler for ProvisionTenantJob {
|
|||||||
job: crate::models::Job,
|
job: crate::models::Job,
|
||||||
_storage: crate::storage::TenantStorage,
|
_storage: crate::storage::TenantStorage,
|
||||||
) -> JobExecution {
|
) -> 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 members = ProvisionPayload::from_job(&job).unwrap_or_default();
|
||||||
let mut context = ProvisionContext::new(
|
let mut context = ProvisionContext::new(
|
||||||
job.id,
|
job.id,
|
||||||
JOB_PROVISION_TENANT,
|
JOB_PROVISION_TENANT,
|
||||||
job.tenant_id,
|
tenant_id,
|
||||||
state.clone(),
|
state.clone(),
|
||||||
members,
|
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)]
|
#[derive(Deserialize, Default)]
|
||||||
struct ProvisionPayload {
|
struct ProvisionPayload {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -246,3 +506,140 @@ impl ProvisionPayload {
|
|||||||
.ok()
|
.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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use papercrate::jobs::{enqueue_job, JOB_DELETE_TENANT};
|
||||||
|
use papercrate::models::TenantStatus;
|
||||||
|
use papercrate::schema::{documents, tenants, user_memberships};
|
||||||
|
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
|
||||||
|
use papercrate::workers::tenants::DeleteTenantJob;
|
||||||
|
use papercrate::workers::JobHandler;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_tenant_job_keeps_tenant_when_requested() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "delete-keep";
|
||||||
|
app.insert_user("tenant-keep", TestUserRole::Owner).await?;
|
||||||
|
let token = app.login_token("tenant-keep", password).await?;
|
||||||
|
|
||||||
|
let upload = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"keep.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
b"keep",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(upload.status(), StatusCode::CREATED);
|
||||||
|
body_to_vec(upload.into_body()).await?;
|
||||||
|
|
||||||
|
let tenant_id = app
|
||||||
|
.state
|
||||||
|
.tenants
|
||||||
|
.get_by_name("test_tenant")
|
||||||
|
.map_err(|err| anyhow!("tenant lookup failed: {err:?}"))?
|
||||||
|
.id;
|
||||||
|
set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
|
||||||
|
|
||||||
|
let job = enqueue_delete_job(&app, tenant_id, false).await?;
|
||||||
|
let handler = DeleteTenantJob::new();
|
||||||
|
let state = Arc::new(app.state.clone());
|
||||||
|
let storage = app
|
||||||
|
.state
|
||||||
|
.storage_for_tenant(tenant_id)
|
||||||
|
.map_err(|err| anyhow!("storage unavailable: {err:?}"))?;
|
||||||
|
|
||||||
|
let execution = handler.handle(state, job, storage).await;
|
||||||
|
assert!(matches!(
|
||||||
|
execution,
|
||||||
|
papercrate::workers::JobExecution::Success
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(app.storage().object_count().await, 0);
|
||||||
|
|
||||||
|
let tenant_id_for_check = tenant_id;
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
use diesel::dsl::count_star;
|
||||||
|
|
||||||
|
let doc_count: i64 = documents::table.select(count_star()).get_result(conn)?;
|
||||||
|
assert_eq!(doc_count, 0);
|
||||||
|
|
||||||
|
let membership_count: i64 = user_memberships::table
|
||||||
|
.select(count_star())
|
||||||
|
.get_result(conn)?;
|
||||||
|
assert!(membership_count > 0, "memberships should remain");
|
||||||
|
|
||||||
|
let status: TenantStatus = tenants::table
|
||||||
|
.find(tenant_id_for_check)
|
||||||
|
.select(tenants::status)
|
||||||
|
.first(conn)?;
|
||||||
|
assert_eq!(status, TenantStatus::Suspended);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_tenant_job_can_reset_tenant_to_active() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "delete-reset";
|
||||||
|
app.insert_user("tenant-reset", TestUserRole::Owner).await?;
|
||||||
|
let token = app.login_token("tenant-reset", password).await?;
|
||||||
|
|
||||||
|
let upload = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"reset.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
b"reset",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(upload.status(), StatusCode::CREATED);
|
||||||
|
body_to_vec(upload.into_body()).await?;
|
||||||
|
|
||||||
|
let tenant_id = app
|
||||||
|
.state
|
||||||
|
.tenants
|
||||||
|
.get_by_name("test_tenant")
|
||||||
|
.map_err(|err| anyhow!("tenant lookup failed: {err:?}"))?
|
||||||
|
.id;
|
||||||
|
set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
|
||||||
|
|
||||||
|
let job = enqueue_delete_job_with_status(&app, tenant_id, false, Some("active")).await?;
|
||||||
|
let handler = DeleteTenantJob::new();
|
||||||
|
let state = Arc::new(app.state.clone());
|
||||||
|
let storage = app
|
||||||
|
.state
|
||||||
|
.storage_for_tenant(tenant_id)
|
||||||
|
.map_err(|err| anyhow!("storage unavailable: {err:?}"))?;
|
||||||
|
|
||||||
|
let execution = handler.handle(state, job, storage).await;
|
||||||
|
assert!(matches!(
|
||||||
|
execution,
|
||||||
|
papercrate::workers::JobExecution::Success
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(app.storage().object_count().await, 0);
|
||||||
|
|
||||||
|
let tenant_id_for_check = tenant_id;
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
use diesel::dsl::count_star;
|
||||||
|
|
||||||
|
let doc_count: i64 = documents::table.select(count_star()).get_result(conn)?;
|
||||||
|
assert_eq!(doc_count, 0);
|
||||||
|
|
||||||
|
let membership_count: i64 = user_memberships::table
|
||||||
|
.select(count_star())
|
||||||
|
.get_result(conn)?;
|
||||||
|
assert!(membership_count > 0, "memberships should remain");
|
||||||
|
|
||||||
|
let status: TenantStatus = tenants::table
|
||||||
|
.find(tenant_id_for_check)
|
||||||
|
.select(tenants::status)
|
||||||
|
.first(conn)?;
|
||||||
|
assert_eq!(status, TenantStatus::Active);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_tenant_job_removes_tenant_entirely() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "delete-remove";
|
||||||
|
app.insert_user("tenant-remove", TestUserRole::Owner)
|
||||||
|
.await?;
|
||||||
|
let token = app.login_token("tenant-remove", password).await?;
|
||||||
|
|
||||||
|
let upload = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"remove.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
b"remove",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(upload.status(), StatusCode::CREATED);
|
||||||
|
body_to_vec(upload.into_body()).await?;
|
||||||
|
|
||||||
|
let tenant_id = app
|
||||||
|
.state
|
||||||
|
.tenants
|
||||||
|
.get_by_name("test_tenant")
|
||||||
|
.map_err(|err| anyhow!("tenant lookup failed: {err:?}"))?
|
||||||
|
.id;
|
||||||
|
set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
|
||||||
|
|
||||||
|
let job = enqueue_delete_job(&app, tenant_id, true).await?;
|
||||||
|
let handler = DeleteTenantJob::new();
|
||||||
|
let state = Arc::new(app.state.clone());
|
||||||
|
let storage = app
|
||||||
|
.state
|
||||||
|
.storage_for_tenant(tenant_id)
|
||||||
|
.map_err(|err| anyhow!("storage unavailable: {err:?}"))?;
|
||||||
|
|
||||||
|
let execution = handler.handle(state, job, storage).await;
|
||||||
|
assert!(matches!(
|
||||||
|
execution,
|
||||||
|
papercrate::workers::JobExecution::Success
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(app.storage().object_count().await, 0);
|
||||||
|
|
||||||
|
let tenant_id_for_check = tenant_id;
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
use diesel::dsl::count_star;
|
||||||
|
|
||||||
|
let doc_count: i64 = documents::table.select(count_star()).get_result(conn)?;
|
||||||
|
assert_eq!(doc_count, 0);
|
||||||
|
|
||||||
|
let membership_count: i64 = user_memberships::table
|
||||||
|
.select(count_star())
|
||||||
|
.get_result(conn)?;
|
||||||
|
assert_eq!(membership_count, 0, "memberships should be removed");
|
||||||
|
|
||||||
|
let exists: Option<TenantStatus> = tenants::table
|
||||||
|
.find(tenant_id_for_check)
|
||||||
|
.select(tenants::status)
|
||||||
|
.first(conn)
|
||||||
|
.optional()?;
|
||||||
|
assert!(exists.is_none(), "tenant row should be deleted");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_tenant_status(app: &TestApp, tenant_id: Uuid, status: TenantStatus) -> Result<()> {
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
diesel::update(tenants::table.find(tenant_id))
|
||||||
|
.set(tenants::status.eq(status))
|
||||||
|
.execute(conn)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enqueue_delete_job(
|
||||||
|
app: &TestApp,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
remove_tenant: bool,
|
||||||
|
) -> Result<papercrate::models::Job> {
|
||||||
|
enqueue_delete_job_with_status(app, tenant_id, remove_tenant, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enqueue_delete_job_with_status(
|
||||||
|
app: &TestApp,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
remove_tenant: bool,
|
||||||
|
final_status: Option<&'static str>,
|
||||||
|
) -> Result<papercrate::models::Job> {
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
let tenant_name: String = tenants::table
|
||||||
|
.find(tenant_id)
|
||||||
|
.select(tenants::name)
|
||||||
|
.first(conn)
|
||||||
|
.map_err(|err| anyhow!("tenant lookup failed: {err}"))?;
|
||||||
|
|
||||||
|
let mut payload = json!({
|
||||||
|
"remove_tenant": remove_tenant,
|
||||||
|
"tenant_name": tenant_name,
|
||||||
|
});
|
||||||
|
if let Some(status) = final_status {
|
||||||
|
payload["final_status"] = json!(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue_job(conn, tenant_id, JOB_DELETE_TENANT, payload, None)
|
||||||
|
.map_err(|err| anyhow!("enqueue failed: {err}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Job Catalogue
|
||||||
|
|
||||||
|
Papercrate stores asynchronous work in the shared `jobs` table. Each job carries a
|
||||||
|
`tenant_id`, a small JSON payload, and one of the statuses defined in
|
||||||
|
`backend/src/jobs.rs` (`queued`, `processing`, `succeeded`, `failed`). Workers
|
||||||
|
continuously reserve jobs by type and execute the appropriate handler. This
|
||||||
|
document lists every job type that is currently recognized by the backend and
|
||||||
|
briefly describes what it does.
|
||||||
|
|
||||||
|
| Job type | Payload shape | When it is enqueued | Work performed |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `analyze-document` | `{ "document_id": Uuid, "document_version_id": Uuid, "force": bool }` | Uploading a document, calling the re-analyze bulk action, or after a metadata edit (e.g. title change) | Runs the taskflow pipeline (`GenerateThumbnailsTask`, `GenerateOcrTask`, `DetermineIssuedAtTask`, `IndexDocumentTask`) for the specified document version. The handler refuses to run if the tenant is not `Active`. |
|
||||||
|
| `purge-document` | `{ "document_id": Uuid }` | `DELETE /api/documents/{id}` after the document has been trashed | Removes every version and asset object from tenant storage, deletes database rows (`documents`, `document_versions`, associated assets/tags/correspondents), and leaves the system ready for GC. |
|
||||||
|
| `provision-tenant` | `{ "members": [Uuid, ...] }` | When a tenant is created with status `creating` | Creates/ensures the tenant’s Quickwit index, materializes the system capability sets (`owner`, `user`, `readonly`, `webdav`), attaches the initial member list, and flips the tenant status to `active`. |
|
||||||
|
| `delete-tenant` | `{ "remove_tenant": bool, "tenant_name": string, "action": "delete"\|"reset", "nonce": string, "issued_at": RFC3339 datetime, "signature": hex(HMAC-SHA256), "final_status"?: "active"\|"suspended" }` | Administrative action after a tenant has been marked `deleting` | Deletes all tenant-scoped storage objects, wipes the tenant’s Quickwit index (and optionally deletes it entirely), truncates the tenant schemas/tables, removes queued jobs for that tenant, and either deletes the tenant row or leaves it in the requested final status (defaults to `suspended`) while recreating an empty Quickwit index. |
|
||||||
|
|
||||||
|
## Retired job types
|
||||||
|
|
||||||
|
`generate-thumbnails` and `generate-ocr-text` once existed as standalone jobs.
|
||||||
|
Those behaviors now run as tasks inside `analyze-document`. No worker is
|
||||||
|
registered for the legacy types; keep them out of new payloads.
|
||||||
|
|
||||||
|
### Tenant delete/reset safety checks
|
||||||
|
|
||||||
|
The `delete-tenant` job refuses to run without a signed payload. The admin CLI
|
||||||
|
derives a message of the form `v1|tenant_id|tenant_name|action|nonce|issued_at|final_status`
|
||||||
|
and signs it with an HMAC-SHA256 key based on the server’s JWT secret.
|
||||||
|
Workers verify the signature, ensure the payload matches the job flags, and
|
||||||
|
require the `issued_at` timestamp to be no more than five minutes old. This
|
||||||
|
protects against accidental wipes triggered by stale requests or insufficiently
|
||||||
|
scoped API calls.
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
* Every job handler calls `ensure_active_tenant` (or an equivalent guard) before
|
||||||
|
touching tenant data. If a tenant is suspended or deleting, the job will fail
|
||||||
|
immediately.
|
||||||
|
* Jobs are only enqueued for the tenant they operate on. Consequently, wiping a
|
||||||
|
tenant with `delete-tenant` also removes any remaining queued jobs for that
|
||||||
|
tenant so workers do not waste effort on work that can no longer succeed.
|
||||||
Reference in New Issue
Block a user