#![allow(dead_code)]
use crate::generation::sql::resolve_tenant_column_ref;
use crate::generation::{CatalogManifest, ManifestTable};
use crate::runtime::executor_utils::qi_runtime;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PurgeTarget {
pub schema: String,
pub table: String,
pub tenant_column: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExcludedPurgeTable {
pub schema: String,
pub table: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TenantPurgePlan {
pub targets: Vec<PurgeTarget>,
pub excluded: Vec<ExcludedPurgeTable>,
pub fk_ordered: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PurgedTableCount {
pub schema: String,
pub table: String,
pub tenant_column: String,
pub deleted: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TenantPurgeReport {
pub tenant_id: String,
pub purged: Vec<PurgedTableCount>,
pub excluded: Vec<ExcludedPurgeTable>,
pub total_deleted: u64,
pub tenant_denylisted: bool,
pub principals_denylisted: usize,
}
pub(crate) fn plan_tenant_purge(manifest: &CatalogManifest) -> TenantPurgePlan {
let mut included: Vec<&ManifestTable> = Vec::new();
let mut tenant_columns: Vec<String> = Vec::new();
let mut excluded: Vec<ExcludedPurgeTable> = Vec::new();
for table in &manifest.tables {
match resolve_tenant_column_ref(table) {
Some(column) => {
included.push(table);
tenant_columns.push(column.column_name.clone());
}
None => excluded.push(ExcludedPurgeTable {
schema: table.schema.clone(),
table: table.table.clone(),
reason: "no resolvable tenant-isolation column".to_string(),
}),
}
}
let (order, fk_ordered) = order_children_first(&included);
let targets = order
.into_iter()
.map(|i| PurgeTarget {
schema: included[i].schema.clone(),
table: included[i].table.clone(),
tenant_column: tenant_columns[i].clone(),
})
.collect();
TenantPurgePlan {
targets,
excluded,
fk_ordered,
}
}
fn order_children_first(tables: &[&ManifestTable]) -> (Vec<usize>, bool) {
use std::collections::HashMap;
let n = tables.len();
let key = |schema: &str, table: &str| format!("{schema}\u{1}{table}");
let index: HashMap<String, usize> = tables
.iter()
.enumerate()
.map(|(i, t)| (key(&t.schema, &t.table), i))
.collect();
let mut parents_of: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut indeg = vec![0usize; n];
let mut had_edge = false;
for (i, table) in tables.iter().enumerate() {
for fk in &table.foreign_keys {
if let Some(&parent) = index.get(&key(&fk.ref_schema, &fk.ref_table)) {
if parent == i {
continue; }
had_edge = true;
parents_of[i].push(parent);
indeg[parent] += 1;
}
}
}
let mut order = Vec::with_capacity(n);
let mut visited = vec![false; n];
while let Some(i) = (0..n).find(|&i| !visited[i] && indeg[i] == 0) {
visited[i] = true;
order.push(i);
for &parent in &parents_of[i] {
if indeg[parent] > 0 {
indeg[parent] -= 1;
}
}
}
let acyclic = order.len() == n;
if !acyclic {
for i in 0..n {
if !visited[i] {
order.push(i);
}
}
}
(order, had_edge && acyclic)
}
fn qualified_relation(schema: &str, table: &str) -> String {
format!("{}.{}", qi_runtime(schema), qi_runtime(table))
}
fn validate_purge_tenant_id(tenant_id: &str) -> Result<&str, tonic::Status> {
let tenant_id = tenant_id.trim();
if tenant_id.is_empty() {
return Err(crate::runtime::executor_utils::invalid_argument_fields(
"tenant_id is required",
[("tenant_id", "must be a non-empty tenant id")],
));
}
Ok(tenant_id)
}
fn tenant_purge_internal_status(
operation: impl Into<String>,
message: impl Into<String>,
) -> tonic::Status {
crate::runtime::executor_utils::internal_status("tenant_purge", operation, message)
}
pub(crate) async fn purge_tenant(
pool: &sqlx::PgPool,
manifest: &CatalogManifest,
tenant_id: &str,
principal_ids: &[String],
#[cfg(feature = "redis")] denylist: Option<&crate::runtime::authn::revocation::JtiDenylist>,
now_unix: u64,
) -> Result<TenantPurgeReport, tonic::Status> {
let tenant_id = validate_purge_tenant_id(tenant_id)?;
let plan = plan_tenant_purge(manifest);
let mut tx = pool.begin().await.map_err(|err| {
tenant_purge_internal_status(
"begin_tenant_purge",
format!("failed to begin tenant-purge transaction: {err}"),
)
})?;
let mut purged = Vec::with_capacity(plan.targets.len());
let mut total_deleted = 0u64;
for target in &plan.targets {
let sql = format!(
"DELETE FROM {} WHERE {}::text = $1",
qualified_relation(&target.schema, &target.table),
qi_runtime(&target.tenant_column),
);
let result = sqlx::query(&sql)
.bind(tenant_id)
.execute(&mut *tx)
.await
.map_err(|err| {
tenant_purge_internal_status(
"delete_tenant_table",
format!(
"tenant purge failed deleting from {}.{}: {err}",
target.schema, target.table
),
)
})?;
let deleted = result.rows_affected();
total_deleted += deleted;
purged.push(PurgedTableCount {
schema: target.schema.clone(),
table: target.table.clone(),
tenant_column: target.tenant_column.clone(),
deleted,
});
}
tx.commit().await.map_err(|err| {
tenant_purge_internal_status(
"commit_tenant_purge",
format!("failed to commit tenant purge: {err}"),
)
})?;
#[cfg(feature = "redis")]
let (tenant_denylisted, principals_denylisted) = {
let mut tenant_denylisted = false;
let mut principals_denylisted = 0usize;
if let Some(denylist) = denylist {
match denylist.deny_tenant_after(tenant_id, now_unix).await {
Ok(()) => tenant_denylisted = true,
Err(err) => tracing::warn!(
tenant_id = %tenant_id,
error = %err,
"tenant purge committed but tenant denylist cutoff was not recorded \
(tokens will still fail at the durable revocation read / natural TTL)"
),
}
for principal_id in principal_ids {
match denylist.deny_principal_after(principal_id, now_unix).await {
Ok(()) => principals_denylisted += 1,
Err(err) => tracing::warn!(
principal_id = %principal_id,
error = %err,
"tenant purge committed but principal denylist cutoff was not recorded"
),
}
}
}
(tenant_denylisted, principals_denylisted)
};
#[cfg(not(feature = "redis"))]
let (tenant_denylisted, principals_denylisted) = {
let _ = (principal_ids, now_unix);
(false, 0usize)
};
Ok(TenantPurgeReport {
tenant_id: tenant_id.to_string(),
purged,
excluded: plan.excluded,
total_deleted,
tenant_denylisted,
principals_denylisted,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generation::{CatalogManifest, ManifestColumn, ManifestTable};
use crate::proto::{ErrorDetail, ErrorKind};
use crate::runtime::executor_utils::ERROR_DETAIL_METADATA_KEY;
use prost::Message as _;
fn decode_detail(status: &tonic::Status) -> ErrorDetail {
let raw = status
.metadata()
.get_bin(ERROR_DETAIL_METADATA_KEY)
.expect("error-detail trailer present")
.to_bytes()
.expect("trailer decodes to bytes");
crate::runtime::executor_utils::decode_error_detail_from_raw(&raw)
}
fn assert_internal_detail(
status: &tonic::Status,
backend: &str,
operation: &str,
message: &str,
) {
assert_eq!(status.code(), tonic::Code::Internal);
assert_eq!(status.message(), message);
let detail = decode_detail(status);
assert_eq!(detail.kind, ErrorKind::Internal as i32);
assert_eq!(detail.backend, backend);
assert_eq!(detail.operation, operation);
assert!(!detail.retryable);
}
fn column(name: &str) -> ManifestColumn {
ManifestColumn {
field_name: name.to_string(),
column_name: name.to_string(),
..ManifestColumn::default()
}
}
fn table(schema: &str, name: &str, columns: Vec<ManifestColumn>) -> ManifestTable {
ManifestTable {
schema: schema.to_string(),
table: name.to_string(),
columns,
..ManifestTable::default()
}
}
#[test]
fn purge_tenant_missing_tenant_id_carries_field_violation() {
let err = validate_purge_tenant_id(" ")
.expect_err("missing tenant_id must fail before planning or DB access");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert_eq!(err.message(), "tenant_id is required");
let detail = decode_detail(&err);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations.len(), 1);
assert_eq!(detail.field_violations[0].field, "tenant_id");
assert_eq!(
detail.field_violations[0].description,
"must be a non-empty tenant id"
);
}
#[test]
fn tenant_purge_internal_status_carries_typed_detail() {
let status = tenant_purge_internal_status(
"begin_tenant_purge",
"failed to begin tenant-purge transaction",
);
assert_internal_detail(
&status,
"tenant_purge",
"begin_tenant_purge",
"failed to begin tenant-purge transaction",
);
}
#[test]
fn plan_includes_tenant_tables_and_excludes_tenantless() {
let mut declared = table("app", "invoice", vec![column("id"), column("owner_tenant")]);
declared.table_security.tenant_column = "owner_tenant".to_string();
let mut flagged_col = column("tenant_ref");
flagged_col.is_tenant_column = true;
let flagged = table("app", "ledger", vec![column("id"), flagged_col]);
let conventional = table("app", "note", vec![column("id"), column("tenant_id")]);
let tenantless = table("app", "currency_rate", vec![column("id"), column("rate")]);
let manifest = CatalogManifest {
tables: vec![declared, flagged, conventional, tenantless],
..CatalogManifest::default()
};
let plan = plan_tenant_purge(&manifest);
let included: std::collections::HashMap<&str, &str> = plan
.targets
.iter()
.map(|t| (t.table.as_str(), t.tenant_column.as_str()))
.collect();
assert_eq!(
included.len(),
3,
"three tenant-owned tables must be planned"
);
assert_eq!(included.get("invoice"), Some(&"owner_tenant"));
assert_eq!(included.get("ledger"), Some(&"tenant_ref"));
assert_eq!(included.get("note"), Some(&"tenant_id"));
assert!(
!included.contains_key("currency_rate"),
"a tenant-less table must never be a purge target"
);
assert_eq!(plan.excluded.len(), 1, "the tenant-less table is reported");
assert_eq!(plan.excluded[0].table, "currency_rate");
assert!(
!plan.excluded[0].reason.trim().is_empty(),
"excluded tables must carry a human reason, not be silently skipped"
);
assert!(!plan.fk_ordered);
}
#[test]
fn plan_orders_children_before_parents_via_fk() {
let parent = table("app", "invoice", vec![column("id"), column("tenant_id")]);
let mut child = table("app", "line_item", vec![column("id"), column("tenant_id")]);
child
.foreign_keys
.push(crate::generation::ManifestForeignKey {
name: "line_item_invoice_fk".to_string(),
columns: vec!["invoice_id".to_string()],
ref_schema: "app".to_string(),
ref_table: "invoice".to_string(),
ref_columns: vec!["id".to_string()],
..crate::generation::ManifestForeignKey::default()
});
let manifest = CatalogManifest {
tables: vec![parent, child],
..CatalogManifest::default()
};
let plan = plan_tenant_purge(&manifest);
let order: Vec<&str> = plan.targets.iter().map(|t| t.table.as_str()).collect();
assert_eq!(order, vec!["line_item", "invoice"], "children purge first");
assert!(
plan.fk_ordered,
"FK metadata yielded an acyclic child→parent order"
);
}
}