All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 7m20s
Secrets MCP — Build & Release / Build Linux (musl) (push) Successful in 8m23s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 1s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- audit_log 增加 user_id;业务写审计透传 user_id - Web /audit 与侧边栏;Dashboard 版本 footer 贴底(margin-top: auto) - 停止 API Key 鉴权成功写入登录审计 - 文档、CI、release-check 配套更新 Made-with: Cursor
323 lines
8.4 KiB
Rust
323 lines
8.4 KiB
Rust
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<DeletedEntry>,
|
|
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<Uuid>,
|
|
}
|
|
|
|
pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult> {
|
|
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<Uuid>,
|
|
) -> Result<DeleteResult> {
|
|
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<EntryRow> = 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<Uuid>,
|
|
) -> Result<DeleteResult> {
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
struct FullEntryRow {
|
|
id: Uuid,
|
|
version: i64,
|
|
kind: String,
|
|
name: String,
|
|
metadata: serde_json::Value,
|
|
tags: Vec<String>,
|
|
}
|
|
|
|
let rows: Vec<FullEntryRow> = 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<Uuid>,
|
|
) -> 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<SecretFieldRow> =
|
|
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(())
|
|
}
|