feat: 添加结构化日志与审计
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m17s
Secrets CLI - Build & Release / 通知 (push) Successful in 6s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has started running
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been cancelled

- tracing + tracing-subscriber,全局 --verbose/-v 与 RUST_LOG 控制
- 新增 audit_log 表,add/update/delete 成功后自动写入审计记录
- 新增 src/audit.rs,审计失败仅 warn 不中断主流程
- 更新 README/AGENTS.md,补充 verbose、audit_log 说明
- .vscode/tasks.json 增加 verbose/update/audit 测试任务

Made-with: Cursor
This commit is contained in:
voson
2026-03-18 16:30:42 +08:00
parent 9620ff1923
commit 535683b15c
12 changed files with 370 additions and 25 deletions

34
src/audit.rs Normal file
View File

@@ -0,0 +1,34 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
/// Write an audit entry for a write operation. Failures are logged as warnings
/// and do not interrupt the main flow.
pub async fn log(
pool: &PgPool,
action: &str,
namespace: &str,
kind: &str,
name: &str,
detail: Value,
) {
let actor = std::env::var("USER").unwrap_or_default();
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(action)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&detail)
.bind(&actor)
.execute(pool)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, "failed to write audit log");
} else {
tracing::debug!(action, namespace, kind, name, actor, "audit logged");
}
}

View File

@@ -1,5 +1,5 @@
use anyhow::Result;
use serde_json::{Map, Value};
use serde_json::{Map, Value, json};
use sqlx::PgPool;
use std::fs;
@@ -43,6 +43,8 @@ pub async fn run(
let metadata = build_json(meta_entries)?;
let encrypted = build_json(secret_entries)?;
tracing::debug!(namespace, kind, name, "upserting record");
sqlx::query(
r#"
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, updated_at)
@@ -64,23 +66,38 @@ pub async fn run(
.execute(pool)
.await?;
let meta_keys: Vec<&str> = meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
let secret_keys: Vec<&str> = secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
crate::audit::log(
pool,
"add",
namespace,
kind,
name,
json!({
"tags": tags,
"meta_keys": meta_keys,
"secret_keys": secret_keys,
}),
)
.await;
println!("Added: [{}/{}] {}", namespace, kind, name);
if !tags.is_empty() {
println!(" tags: {}", tags.join(", "));
}
if !meta_entries.is_empty() {
let keys: Vec<&str> = meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" metadata: {}", keys.join(", "));
println!(" metadata: {}", meta_keys.join(", "));
}
if !secret_entries.is_empty() {
let keys: Vec<&str> = secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" secrets: {}", keys.join(", "));
println!(" secrets: {}", secret_keys.join(", "));
}
Ok(())

View File

@@ -1,7 +1,10 @@
use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
pub async fn run(pool: &PgPool, namespace: &str, kind: &str, name: &str) -> Result<()> {
tracing::debug!(namespace, kind, name, "deleting record");
let result =
sqlx::query("DELETE FROM secrets WHERE namespace = $1 AND kind = $2 AND name = $3")
.bind(namespace)
@@ -11,8 +14,10 @@ pub async fn run(pool: &PgPool, namespace: &str, kind: &str, name: &str) -> Resu
.await?;
if result.rows_affected() == 0 {
tracing::warn!(namespace, kind, name, "record not found for deletion");
println!("Not found: [{}/{}] {}", namespace, kind, name);
} else {
crate::audit::log(pool, "delete", namespace, kind, name, json!({})).await;
println!("Deleted: [{}/{}] {}", namespace, kind, name);
}
Ok(())

View File

@@ -44,6 +44,8 @@ pub async fn run(
where_clause
);
tracing::debug!(sql, "executing search query");
let mut q = sqlx::query_as::<_, Secret>(&sql);
if let Some(v) = namespace {
q = q.bind(v);

View File

@@ -1,5 +1,5 @@
use anyhow::Result;
use serde_json::{Map, Value};
use serde_json::{Map, Value, json};
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
@@ -85,6 +85,13 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
}
let encrypted = Value::Object(enc_map);
tracing::debug!(
namespace = args.namespace,
kind = args.kind,
name = args.name,
"updating record"
);
sqlx::query(
r#"
UPDATE secrets
@@ -99,6 +106,34 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
.execute(pool)
.await?;
let meta_keys: Vec<&str> = args
.meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
let secret_keys: Vec<&str> = args
.secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
crate::audit::log(
pool,
"update",
args.namespace,
args.kind,
args.name,
json!({
"add_tags": args.add_tags,
"remove_tags": args.remove_tags,
"meta_keys": meta_keys,
"remove_meta": args.remove_meta,
"secret_keys": secret_keys,
"remove_secrets": args.remove_secrets,
}),
)
.await;
println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name);
if !args.add_tags.is_empty() {

View File

@@ -3,14 +3,17 @@ use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
tracing::debug!("connecting to database");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
tracing::debug!("database connection established");
Ok(pool)
}
pub async fn migrate(pool: &PgPool) -> Result<()> {
tracing::debug!("running migrations");
sqlx::raw_sql(
r#"
CREATE TABLE IF NOT EXISTS secrets (
@@ -36,9 +39,24 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_secrets_kind ON secrets(kind);
CREATE INDEX IF NOT EXISTS idx_secrets_tags ON secrets USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_secrets_metadata ON secrets USING GIN(metadata jsonb_path_ops);
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action VARCHAR(32) NOT NULL,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind);
"#,
)
.execute(pool)
.await?;
tracing::debug!("migrations complete");
Ok(())
}

View File

@@ -1,3 +1,4 @@
mod audit;
mod commands;
mod config;
mod db;
@@ -5,6 +6,7 @@ mod models;
use anyhow::Result;
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(
@@ -17,6 +19,10 @@ struct Cli {
#[arg(long, global = true, default_value = "")]
db_url: String,
/// Enable verbose debug output
#[arg(long, short, global = true)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
@@ -132,6 +138,16 @@ enum ConfigAction {
async fn main() -> Result<()> {
let cli = Cli::parse();
let filter = if cli.verbose {
EnvFilter::new("secrets=debug")
} else {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("secrets=warn"))
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.init();
// config 子命令不需要数据库连接,提前处理
if let Commands::Config { action } = &cli.command {
let cmd_action = match action {
@@ -157,6 +173,8 @@ async fn main() -> Result<()> {
meta,
secrets,
} => {
let _span =
tracing::info_span!("cmd", command = "add", %namespace, %kind, %name).entered();
commands::add::run(&pool, namespace, kind, name, tags, meta, secrets).await?;
}
Commands::Search {
@@ -166,6 +184,7 @@ async fn main() -> Result<()> {
query,
show_secrets,
} => {
let _span = tracing::info_span!("cmd", command = "search").entered();
commands::search::run(
&pool,
namespace.as_deref(),
@@ -181,6 +200,8 @@ async fn main() -> Result<()> {
kind,
name,
} => {
let _span =
tracing::info_span!("cmd", command = "delete", %namespace, %kind, %name).entered();
commands::delete::run(&pool, namespace, kind, name).await?;
}
Commands::Update {
@@ -194,6 +215,8 @@ async fn main() -> Result<()> {
secrets,
remove_secrets,
} => {
let _span =
tracing::info_span!("cmd", command = "update", %namespace, %kind, %name).entered();
commands::update::run(
&pool,
commands::update::UpdateArgs {