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
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:
34
src/audit.rs
Normal file
34
src/audit.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
18
src/db.rs
18
src/db.rs
@@ -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(())
|
||||
}
|
||||
|
||||
23
src/main.rs
23
src/main.rs
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user