use anyhow::Result; use serde_json::json; use sqlx::PgPool; use uuid::Uuid; use crate::db; use crate::models::{EntryRow, SecretFieldRow}; #[derive(Debug, serde::Serialize)] pub struct DeletedEntry { pub namespace: String, pub kind: String, pub name: String, } #[derive(Debug, serde::Serialize)] pub struct DeleteResult { pub deleted: Vec, pub dry_run: bool, } pub struct DeleteParams<'a> { pub namespace: &'a str, pub kind: Option<&'a str>, pub name: Option<&'a str>, pub dry_run: bool, pub user_id: Option, } pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result { match params.name { Some(name) => { let kind = params .kind .ok_or_else(|| anyhow::anyhow!("--kind is required when --name is specified"))?; delete_one( pool, params.namespace, kind, name, params.dry_run, params.user_id, ) .await } None => { delete_bulk( pool, params.namespace, params.kind, params.dry_run, params.user_id, ) .await } } } async fn delete_one( pool: &PgPool, namespace: &str, kind: &str, name: &str, dry_run: bool, user_id: Option, ) -> Result { if dry_run { let exists: bool = if let Some(uid) = user_id { sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM entries \ WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4)", ) .bind(uid) .bind(namespace) .bind(kind) .bind(name) .fetch_one(pool) .await? } else { sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM entries \ WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3)", ) .bind(namespace) .bind(kind) .bind(name) .fetch_one(pool) .await? }; let deleted = if exists { vec![DeletedEntry { namespace: namespace.to_string(), kind: kind.to_string(), name: name.to_string(), }] } else { vec![] }; return Ok(DeleteResult { deleted, dry_run: true, }); } let mut tx = pool.begin().await?; let row: Option = if let Some(uid) = user_id { sqlx::query_as( "SELECT id, version, tags, metadata FROM entries \ WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE", ) .bind(uid) .bind(namespace) .bind(kind) .bind(name) .fetch_optional(&mut *tx) .await? } else { sqlx::query_as( "SELECT id, version, tags, metadata FROM entries \ WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE", ) .bind(namespace) .bind(kind) .bind(name) .fetch_optional(&mut *tx) .await? }; let Some(row) = row else { tx.rollback().await?; return Ok(DeleteResult { deleted: vec![], dry_run: false, }); }; snapshot_and_delete(&mut tx, namespace, kind, name, &row, user_id).await?; crate::audit::log_tx(&mut tx, user_id, "delete", namespace, kind, name, json!({})).await; tx.commit().await?; Ok(DeleteResult { deleted: vec![DeletedEntry { namespace: namespace.to_string(), kind: kind.to_string(), name: name.to_string(), }], dry_run: false, }) } async fn delete_bulk( pool: &PgPool, namespace: &str, kind: Option<&str>, dry_run: bool, user_id: Option, ) -> Result { #[derive(Debug, sqlx::FromRow)] struct FullEntryRow { id: Uuid, version: i64, kind: String, name: String, metadata: serde_json::Value, tags: Vec, } let rows: Vec = match (user_id, kind) { (Some(uid), Some(k)) => { sqlx::query_as( "SELECT id, version, kind, name, metadata, tags FROM entries \ WHERE user_id = $1 AND namespace = $2 AND kind = $3 ORDER BY name", ) .bind(uid) .bind(namespace) .bind(k) .fetch_all(pool) .await? } (Some(uid), None) => { sqlx::query_as( "SELECT id, version, kind, name, metadata, tags FROM entries \ WHERE user_id = $1 AND namespace = $2 ORDER BY kind, name", ) .bind(uid) .bind(namespace) .fetch_all(pool) .await? } (None, Some(k)) => { sqlx::query_as( "SELECT id, version, kind, name, metadata, tags FROM entries \ WHERE user_id IS NULL AND namespace = $1 AND kind = $2 ORDER BY name", ) .bind(namespace) .bind(k) .fetch_all(pool) .await? } (None, None) => { sqlx::query_as( "SELECT id, version, kind, name, metadata, tags FROM entries \ WHERE user_id IS NULL AND namespace = $1 ORDER BY kind, name", ) .bind(namespace) .fetch_all(pool) .await? } }; if dry_run { let deleted = rows .iter() .map(|r| DeletedEntry { namespace: namespace.to_string(), kind: r.kind.clone(), name: r.name.clone(), }) .collect(); return Ok(DeleteResult { deleted, dry_run: true, }); } let mut deleted = Vec::with_capacity(rows.len()); for row in &rows { let entry_row = EntryRow { id: row.id, version: row.version, tags: row.tags.clone(), metadata: row.metadata.clone(), }; let mut tx = pool.begin().await?; snapshot_and_delete( &mut tx, namespace, &row.kind, &row.name, &entry_row, user_id, ) .await?; crate::audit::log_tx( &mut tx, user_id, "delete", namespace, &row.kind, &row.name, json!({"bulk": true}), ) .await; tx.commit().await?; deleted.push(DeletedEntry { namespace: namespace.to_string(), kind: row.kind.clone(), name: row.name.clone(), }); } Ok(DeleteResult { deleted, dry_run: false, }) } async fn snapshot_and_delete( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, namespace: &str, kind: &str, name: &str, row: &EntryRow, user_id: Option, ) -> Result<()> { if let Err(e) = db::snapshot_entry_history( tx, db::EntrySnapshotParams { entry_id: row.id, user_id, namespace, kind, name, version: row.version, action: "delete", tags: &row.tags, metadata: &row.metadata, }, ) .await { tracing::warn!(error = %e, "failed to snapshot entry history before delete"); } let fields: Vec = sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1") .bind(row.id) .fetch_all(&mut **tx) .await?; for f in &fields { if let Err(e) = db::snapshot_secret_history( tx, db::SecretSnapshotParams { entry_id: row.id, secret_id: f.id, entry_version: row.version, field_name: &f.field_name, encrypted: &f.encrypted, action: "delete", }, ) .await { tracing::warn!(error = %e, "failed to snapshot secret history before delete"); } } sqlx::query("DELETE FROM entries WHERE id = $1") .bind(row.id) .execute(&mut **tx) .await?; Ok(()) }