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

44
.vscode/tasks.json vendored
View File

@@ -25,12 +25,36 @@
"command": "./target/debug/secrets help add", "command": "./target/debug/secrets help add",
"dependsOn": "build" "dependsOn": "build"
}, },
{
"label": "cli: help config",
"type": "shell",
"command": "./target/debug/secrets help config",
"dependsOn": "build"
},
{
"label": "cli: config path",
"type": "shell",
"command": "./target/debug/secrets config path",
"dependsOn": "build"
},
{
"label": "cli: config show",
"type": "shell",
"command": "./target/debug/secrets config show",
"dependsOn": "build"
},
{ {
"label": "test: search all", "label": "test: search all",
"type": "shell", "type": "shell",
"command": "./target/debug/secrets search", "command": "./target/debug/secrets search",
"dependsOn": "build" "dependsOn": "build"
}, },
{
"label": "test: search all (verbose)",
"type": "shell",
"command": "./target/debug/secrets --verbose search",
"dependsOn": "build"
},
{ {
"label": "test: search by namespace (refining)", "label": "test: search by namespace (refining)",
"type": "shell", "type": "shell",
@@ -82,7 +106,7 @@
{ {
"label": "test: search with secrets revealed", "label": "test: search with secrets revealed",
"type": "shell", "type": "shell",
"command": "./target/debug/secrets search -n refining --kind service --name gitea --show-secrets", "command": "./target/debug/secrets search -n refining --kind service --show-secrets",
"dependsOn": "build" "dependsOn": "build"
}, },
{ {
@@ -97,6 +121,24 @@
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search ---' && ./target/debug/secrets search -n test --show-secrets && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name roundtrip-test && echo '--- verify deleted ---' && ./target/debug/secrets search -n test", "command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search ---' && ./target/debug/secrets search -n test --show-secrets && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name roundtrip-test && echo '--- verify deleted ---' && ./target/debug/secrets search -n test",
"dependsOn": "build" "dependsOn": "build"
}, },
{
"label": "test: add + delete roundtrip (verbose)",
"type": "shell",
"command": "echo '--- add (verbose) ---' && ./target/debug/secrets --verbose add -n test --kind demo --name roundtrip-verbose --tag test -m foo=bar -s password=secret123 && echo '--- delete (verbose) ---' && ./target/debug/secrets --verbose delete -n test --kind demo --name roundtrip-verbose",
"dependsOn": "build"
},
{
"label": "test: update roundtrip",
"type": "shell",
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name update-test --tag v1 -m env=staging && echo '--- update ---' && ./target/debug/secrets update -n test --kind demo --name update-test --add-tag v2 --remove-tag v1 -m env=production && echo '--- verify ---' && ./target/debug/secrets search -n test --kind demo && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind demo --name update-test",
"dependsOn": "build"
},
{
"label": "test: audit log",
"type": "shell",
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name audit-test -m foo=bar -s key=val && echo '--- update ---' && ./target/debug/secrets update -n test --kind demo --name audit-test -m foo=baz && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name audit-test && echo '--- audit log (last 5) ---' && psql $DATABASE_URL -c \"SELECT action, namespace, kind, name, actor, detail, created_at FROM audit_log ORDER BY created_at DESC LIMIT 5;\"",
"dependsOn": "build"
},
{ {
"label": "test: add with file secret", "label": "test: add with file secret",
"type": "shell", "type": "shell",

View File

@@ -7,19 +7,22 @@
``` ```
secrets/ secrets/
src/ src/
main.rs # CLI 入口clap 命令定义auto-migrate main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数
db.rs # PgPool 创建 + 建表/索引(幂等 config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url
db.rs # PgPool 创建 + 建表/索引(幂等,含 audit_log
models.rs # Secret 结构体sqlx::FromRow + serde models.rs # Secret 结构体sqlx::FromRow + serde
audit.rs # 审计写入:向 audit_log 表记录所有写操作
commands/ commands/
add.rs # add 命令upsert支持 --meta key=value / --secret key=@file add.rs # add 命令upsert支持 --meta key=value / --secret key=@file
config.rs # config 命令set-db / show / path持久化 database_url
search.rs # search 命令:多条件动态查询 search.rs # search 命令:多条件动态查询
delete.rs # delete 命令 delete.rs # delete 命令
update.rs # update 命令:增量更新(合并 tags/metadata/encrypted
scripts/ scripts/
seed-data.sh # 从 refining/ricnsmart config.toml 导入全量数据 seed-data.sh # 从 refining/ricnsmart config.toml 导入全量数据
.gitea/workflows/ .gitea/workflows/
secrets.yml # CIfmt + clippy + musl 构建 + Release 上传 + 飞书通知 secrets.yml # CIfmt + clippy + musl 构建 + Release 上传 + 飞书通知
.vscode/tasks.json # 本地测试任务build / search / add+delete roundtrip 等) .vscode/tasks.json # 本地测试任务build / config / search / add+delete / update / audit 等)
.env # DATABASE_URLgitignore不提交
``` ```
## 数据库 ## 数据库
@@ -27,7 +30,7 @@ secrets/
- **Host**: `47.117.131.22:5432`(阿里云上海 ECSPostgreSQL 18 with io_uring - **Host**: `47.117.131.22:5432`(阿里云上海 ECSPostgreSQL 18 with io_uring
- **Database**: `secrets` - **Database**: `secrets`
- **连接串**: `postgres://postgres:<password>@47.117.131.22:5432/secrets` - **连接串**: `postgres://postgres:<password>@47.117.131.22:5432/secrets`
- **表**: 单张 `secrets`首次连接自动建表auto-migrate - **表**: `secrets`(主表)+ `audit_log`(审计表)首次连接自动建表auto-migrate
### 表结构 ### 表结构
@@ -46,6 +49,21 @@ secrets (
) )
``` ```
### audit_log 表结构
```sql
audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action VARCHAR(32) NOT NULL, -- 'add' | 'update' | 'delete'
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}', -- 变更摘要tags/meta keys/secret keys不含 value
actor VARCHAR(128) NOT NULL DEFAULT '', -- 操作者($USER 环境变量)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
```
### 字段职责划分 ### 字段职责划分
| 字段 | 存什么 | 示例 | | 字段 | 存什么 | 示例 |
@@ -57,6 +75,18 @@ secrets (
| `metadata` | 明文非敏感信息 | `{"ip":"47.243.154.187","desc":"Grafana","domains":["..."]}` | | `metadata` | 明文非敏感信息 | `{"ip":"47.243.154.187","desc":"Grafana","domains":["..."]}` |
| `encrypted` | 敏感凭据MVP 阶段明文存储,后续对 value 加密) | `{"ssh_key":"-----BEGIN...","password":"..."}` | | `encrypted` | 敏感凭据MVP 阶段明文存储,后续对 value 加密) | `{"ssh_key":"-----BEGIN...","password":"..."}` |
## 数据库配置
首次使用需显式配置数据库连接,设置一次后在该设备上持久生效:
```bash
secrets config set-db "postgres://postgres:<password>@47.117.131.22:5432/secrets"
secrets config show # 查看当前配置(密码脱敏)
secrets config path # 打印配置文件路径
```
配置文件:`~/.config/secrets/config.toml`,权限 0600。`--db-url` 参数可一次性覆盖。
## CLI 命令 ## CLI 命令
```bash ```bash
@@ -77,6 +107,11 @@ secrets add -n <namespace> --kind <kind> --name <name> \
secrets search [-n <namespace>] [--kind <kind>] [--tag <tag>] [-q <keyword>] [--show-secrets] secrets search [-n <namespace>] [--kind <kind>] [--tag <tag>] [-q <keyword>] [--show-secrets]
# -q 匹配范围name、namespace、kind、metadata 全文内容、tags # -q 匹配范围name、namespace、kind、metadata 全文内容、tags
# 开启 debug 级别日志(全局参数,位于子命令之前)
secrets --verbose <subcommand>
secrets -v <subcommand>
# 或通过环境变量控制RUST_LOG=secrets=trace secrets search
# 增量更新已有记录(合并语义,记录不存在则报错) # 增量更新已有记录(合并语义,记录不存在则报错)
secrets update -n <namespace> --kind <kind> --name <name> \ secrets update -n <namespace> --kind <kind> --name <name> \
[--add-tag <tag>]... # 添加标签(不影响已有标签) [--add-tag <tag>]... # 添加标签(不影响已有标签)
@@ -88,6 +123,11 @@ secrets update -n <namespace> --kind <kind> --name <name> \
# 删除 # 删除
secrets delete -n <namespace> --kind <kind> --name <name> secrets delete -n <namespace> --kind <kind> --name <name>
# 配置(持久化 database_url设置一次即可
secrets config set-db <url>
secrets config show
secrets config path
``` ```
### 示例 ### 示例
@@ -134,7 +174,9 @@ secrets update -n refining --kind service --name mqtt \
- 异步:全程 `tokio`,数据库操作 `sqlx` async - 异步:全程 `tokio`,数据库操作 `sqlx` async
- SQL使用 `sqlx::query` / `sqlx::query_as` 绑定参数,禁止字符串拼接(搜索的动态 WHERE 子句除外,需使用参数绑定 `$1/$2` - SQL使用 `sqlx::query` / `sqlx::query_as` 绑定参数,禁止字符串拼接(搜索的动态 WHERE 子句除外,需使用参数绑定 `$1/$2`
- 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码 - 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码
- 字段命名CLI 短标志 `-n`=namespace`-m`=meta`-s`=secret`-q`=query - 字段命名CLI 短标志 `-n`=namespace`-m`=meta`-s`=secret`-q`=query`-v`=verbose
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log()`,写入 `audit_log` 表;失败只 warn 不中断
## 提交前检查(必须全部通过) ## 提交前检查(必须全部通过)
@@ -181,4 +223,7 @@ cargo fmt -- --check && cargo clippy -- -D warnings && cargo test
| 变量 | 说明 | | 变量 | 说明 |
|------|------| |------|------|
| `DATABASE_URL` | PostgreSQL 连接串,优先级高于 `--db-url` 参数 | | `RUST_LOG` | 日志级别,如 `secrets=debug``secrets=trace`(默认 warn |
| `USER` | 审计日志 actor 字段来源Shell 自动设置,通常无需手动配置 |
数据库连接通过 `secrets config set-db` 持久化到 `~/.config/secrets/config.toml`,不支持环境变量。

100
Cargo.lock generated
View File

@@ -2,6 +2,15 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -828,6 +837,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "md-5" name = "md-5"
version = "0.10.6" version = "0.10.6"
@@ -855,6 +873,15 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.6" version = "0.8.6"
@@ -1113,6 +1140,23 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -1212,6 +1256,8 @@ dependencies = [
"sqlx", "sqlx",
"tokio", "tokio",
"toml", "toml",
"tracing",
"tracing-subscriber",
"uuid", "uuid",
] ]
@@ -1307,6 +1353,15 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -1646,6 +1701,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.2" version = "0.8.2"
@@ -1779,6 +1843,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -1862,6 +1956,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"

View File

@@ -13,4 +13,6 @@ serde_json = "1.0.149"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] } sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
toml = "1.0.7" toml = "1.0.7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.22.0", features = ["serde", "v4"] } uuid = { version = "1.22.0", features = ["serde", "v4"] }

View File

@@ -11,11 +11,10 @@ cargo build --release
# 或从 Release 页面下载预编译二进制 # 或从 Release 页面下载预编译二进制
``` ```
配置数据库连接: 配置数据库连接(首次使用需执行一次,之后在该设备上持久生效)
```bash ```bash
export DATABASE_URL=postgres://postgres:<password>@<host>:5432/secrets secrets config set-db "postgres://postgres:<password>@<host>:5432/secrets"
# 或在项目根目录创建 .env 文件写入上述变量
``` ```
## 使用 ## 使用
@@ -30,6 +29,7 @@ secrets --help
secrets -h secrets -h
# 查看子命令帮助 # 查看子命令帮助
secrets help config
secrets help add secrets help add
secrets help search secrets help search
secrets help delete secrets help delete
@@ -54,6 +54,13 @@ secrets search --tag hongkong
secrets search -q mqtt # 关键词匹配 name / metadata / tags secrets search -q mqtt # 关键词匹配 name / metadata / tags
secrets search -n refining --kind service --name gitea --show-secrets secrets search -n refining --kind service --name gitea --show-secrets
# 开启 debug 级别日志(--verbose / -v全局参数
secrets --verbose search -q mqtt
secrets -v add -n refining --kind service --name gitea -m url=xxx -s token=yyy
# 或通过环境变量精细控制日志级别
RUST_LOG=secrets=trace secrets search
# 增量更新已有记录(合并语义,记录不存在则报错) # 增量更新已有记录(合并语义,记录不存在则报错)
secrets update -n refining --kind server --name my-server -m ip=10.0.0.1 secrets update -n refining --kind server --name my-server -m ip=10.0.0.1
secrets update -n refining --kind service --name gitea --add-tag production -s token=<new-token> secrets update -n refining --kind service --name gitea --add-tag production -s token=<new-token>
@@ -65,7 +72,7 @@ secrets delete -n refining --kind server --name my-server
## 数据模型 ## 数据模型
单张 `secrets` 表,首次连接自动建表。 单张 `secrets` 表,首次连接自动建表;同时自动创建 `audit_log` 表,记录所有写操作
| 字段 | 说明 | | 字段 | 说明 |
|------|------| |------|------|
@@ -78,15 +85,30 @@ secrets delete -n refining --kind server --name my-server
`-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `encrypted``value=@file` 从文件读取内容。 `-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `encrypted``value=@file` 从文件读取内容。
## 审计日志
`add``update``delete` 操作成功后自动向 `audit_log` 表写入一条记录,包含操作类型、操作对象和变更摘要(不含 secret 值)。操作者取自 `$USER` 环境变量。
```sql
-- 查看最近 20 条审计记录
SELECT action, namespace, kind, name, actor, detail, created_at
FROM audit_log
ORDER BY created_at DESC
LIMIT 20;
```
## 项目结构 ## 项目结构
``` ```
src/ src/
main.rs # CLI 入口clap main.rs # CLI 入口clap
db.rs # 连接池 + auto-migrate config.rs # 配置读写(~/.config/secrets/config.toml
db.rs # 连接池 + auto-migratesecrets + audit_log
models.rs # Secret 结构体 models.rs # Secret 结构体
audit.rs # 审计日志写入audit_log 表)
commands/ commands/
add.rs # upsert add.rs # upsert
config.rs # config set-db/show/path
search.rs # 多条件查询 search.rs # 多条件查询
delete.rs # 删除 delete.rs # 删除
update.rs # 增量更新(合并 tags/metadata/encrypted update.rs # 增量更新(合并 tags/metadata/encrypted

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
use anyhow::Result; use anyhow::Result;
use serde_json::{Map, Value}; use serde_json::{Map, Value, json};
use sqlx::{FromRow, PgPool}; use sqlx::{FromRow, PgPool};
use uuid::Uuid; use uuid::Uuid;
@@ -85,6 +85,13 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
} }
let encrypted = Value::Object(enc_map); let encrypted = Value::Object(enc_map);
tracing::debug!(
namespace = args.namespace,
kind = args.kind,
name = args.name,
"updating record"
);
sqlx::query( sqlx::query(
r#" r#"
UPDATE secrets UPDATE secrets
@@ -99,6 +106,34 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
.execute(pool) .execute(pool)
.await?; .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); println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name);
if !args.add_tags.is_empty() { if !args.add_tags.is_empty() {

View File

@@ -3,14 +3,17 @@ use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
pub async fn create_pool(database_url: &str) -> Result<PgPool> { pub async fn create_pool(database_url: &str) -> Result<PgPool> {
tracing::debug!("connecting to database");
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
.max_connections(5) .max_connections(5)
.connect(database_url) .connect(database_url)
.await?; .await?;
tracing::debug!("database connection established");
Ok(pool) Ok(pool)
} }
pub async fn migrate(pool: &PgPool) -> Result<()> { pub async fn migrate(pool: &PgPool) -> Result<()> {
tracing::debug!("running migrations");
sqlx::raw_sql( sqlx::raw_sql(
r#" r#"
CREATE TABLE IF NOT EXISTS secrets ( 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_kind ON secrets(kind);
CREATE INDEX IF NOT EXISTS idx_secrets_tags ON secrets USING GIN(tags); 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 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) .execute(pool)
.await?; .await?;
tracing::debug!("migrations complete");
Ok(()) Ok(())
} }

View File

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