feat: 0.6.0 — 事务/版本化/类型化/inject/run
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m37s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 37s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 50s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled

- 写路径事务化:add/update/delete 与 audit 同事务,update CAS 并发保护
- 版本化与回滚:secrets_history 表、version 字段、history/rollback 命令
- 类型化字段:key:=<json> 支持数字、布尔、数组、对象
- 临时 env 模式:inject 输出 KEY=VALUE,run 向子进程注入
- inject/run 至少需一个过滤条件;search -o env 使用 shell_quote;JSON 输出含 version

Made-with: Cursor
This commit is contained in:
voson
2026-03-19 10:30:45 +08:00
parent 31b0ea9bf1
commit a765dcc428
16 changed files with 1247 additions and 196 deletions

134
AGENTS.md
View File

@@ -10,17 +10,19 @@ secrets/
main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数 main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数
output.rs # OutputMode 枚举 + TTY 检测TTY→text非 TTY→json-compact output.rs # OutputMode 枚举 + TTY 检测TTY→text非 TTY→json-compact
config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url
db.rs # PgPool 创建 + 建表/索引(幂等,含 audit_log + kv_config db.rs # PgPool 创建 + 建表/索引(幂等,含 audit_log + kv_config + secrets_history
crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串 crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
models.rs # Secret 结构体sqlx::FromRow + serde models.rs # Secret 结构体sqlx::FromRow + serde,含 version 字段
audit.rs # 审计写入:向 audit_log 表记录所有写操作 audit.rs # 审计写入:log_tx事务内/ log保留备用
commands/ commands/
init.rs # init 命令:主密钥初始化(每台设备一次) init.rs # init 命令:主密钥初始化(每台设备一次)
add.rs # add 命令upsert支持 --meta key=value / --secret key=@file / -o json add.rs # add 命令upsert事务化,含历史快照,支持 key:=json 类型化值
config.rs # config 命令set-db / show / path持久化 database_url config.rs # config 命令set-db / show / path持久化 database_url
search.rs # search 命令:多条件查询,-f/-o/--summary/--limit/--offset/--sort search.rs # search 命令:多条件查询,公开 fetch_rows / build_env_map
delete.rs # delete 命令 delete.rs # delete 命令:事务化,含历史快照
update.rs # update 命令:增量更新(合并 tags/metadata/encrypted update.rs # update 命令:增量更新CAS 并发保护,含历史快照
rollback.rs # rollback / history 命令:版本回滚与历史查看
run.rs # inject / run 命令:临时环境变量注入
scripts/ scripts/
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
.gitea/workflows/ .gitea/workflows/
@@ -46,12 +48,30 @@ secrets (
tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"] tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"]
metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location... metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location...
encrypted BYTEA NOT NULL DEFAULT '\x', -- AES-256-GCM 密文: nonce(12B)||ciphertext+tag encrypted BYTEA NOT NULL DEFAULT '\x', -- AES-256-GCM 密文: nonce(12B)||ciphertext+tag
version BIGINT NOT NULL DEFAULT 1, -- 乐观锁版本号,每次写操作自增
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name) UNIQUE(namespace, kind, name)
) )
``` ```
```sql
secrets_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
secret_id UUID NOT NULL, -- 对应 secrets.id
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
version BIGINT NOT NULL, -- 被快照时的版本号
action VARCHAR(16) NOT NULL, -- 'add' | 'update' | 'delete' | 'rollback'
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
encrypted BYTEA NOT NULL DEFAULT '\x', -- 快照时的加密密文
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
```
```sql ```sql
kv_config ( kv_config (
key TEXT PRIMARY KEY, -- 如 'argon2_salt' key TEXT PRIMARY KEY, -- 如 'argon2_salt'
@@ -224,6 +244,13 @@ secrets add -n refining --kind service --name gitea \
secrets add -n ricnsmart --kind service --name mqtt \ secrets add -n ricnsmart --kind service --name mqtt \
-m host=mqtt.ricnsmart.com -m port=1883 \ -m host=mqtt.ricnsmart.com -m port=1883 \
-s password=@./mqtt_password.txt -s password=@./mqtt_password.txt
# 使用类型化值key:=<json>)存储非字符串类型
secrets add -n refining --kind service --name prometheus \
-m scrape_interval:=15 \
-m enabled:=true \
-m labels:='["prod","metrics"]' \
-s api_key=abc123
``` ```
--- ---
@@ -284,6 +311,98 @@ secrets delete -n ricnsmart --kind server --name i-old-server-id
--- ---
### history — 查看变更历史
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --limit 返回条数(默认 20
# 查看某条记录的历史版本列表
secrets history -n refining --kind service --name gitea
# 查最近 5 条
secrets history -n refining --kind service --name gitea --limit 5
# JSON 输出
secrets history -n refining --kind service --name gitea -o json
```
---
### rollback — 回滚到指定版本
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --to-version <N> 目标版本号(省略则恢复最近一次快照)
# 撤销上次修改(回滚到最近一次快照)
secrets rollback -n refining --kind service --name gitea
# 回滚到版本 3
secrets rollback -n refining --kind service --name gitea --to-version 3
```
---
### inject — 输出临时环境变量
敏感值仅打印到 stdout不持久化、不写入当前 shell。
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --tag 按 tag 过滤(可重复)
# --prefix 变量名前缀(留空则以记录 name 作前缀)
# -o / --output text默认 KEY=VALUE| json | json-compact
# 打印单条记录的所有变量KEY=VALUE 格式)
secrets inject -n refining --kind service --name gitea
# 自定义前缀
secrets inject -n refining --kind service --name gitea --prefix GITEA
# JSON 格式(适合管道或脚本解析)
secrets inject -n refining --kind service --name gitea -o json
# eval 注入当前 shell谨慎使用
eval $(secrets inject -n refining --kind service --name gitea)
```
---
### run — 向子进程注入 secrets 并执行命令
secrets 仅作用于子进程环境,不修改当前 shell进程退出码透传。
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --tag 按 tag 过滤(可重复)
# --prefix 变量名前缀
# -- <command> 执行的命令及参数
# 向脚本注入单条记录的 secrets
secrets run -n refining --kind service --name gitea -- ./deploy.sh
# 按 tag 批量注入(多条记录合并)
secrets run --tag production -- env | grep -i token
# 验证注入了哪些变量
secrets run -n refining --kind service --name gitea -- printenv
```
---
### config — 配置管理(无需主密钥) ### config — 配置管理(无需主密钥)
```bash ```bash
@@ -366,6 +485,7 @@ cargo fmt -- --check && cargo clippy -- -D warnings && cargo test
- 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制到 Gitea Release - 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制到 Gitea Release
- 通知:飞书 Webhook`vars.WEBHOOK_URL` - 通知:飞书 Webhook`vars.WEBHOOK_URL`
- 所需 secrets/vars`RELEASE_TOKEN`Release 上传Gitea PAT`vars.WEBHOOK_URL`(通知,可选) - 所需 secrets/vars`RELEASE_TOKEN`Release 上传Gitea PAT`vars.WEBHOOK_URL`(通知,可选)
- **注意**Gitea Actions 的 Secret/Variable 创建时,`data`/`value` 字段需传入**原始值**,不要使用 base64 编码
## 环境变量 ## 环境变量

2
Cargo.lock generated
View File

@@ -1473,7 +1473,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "secrets" name = "secrets"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "secrets" name = "secrets"
version = "0.5.0" version = "0.6.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -202,5 +202,6 @@ scripts/
- `RELEASE_TOKEN`SecretGitea PAT用于创建 Release 上传二进制 - `RELEASE_TOKEN`SecretGitea PAT用于创建 Release 上传二进制
- `WEBHOOK_URL`Variable飞书通知可选 - `WEBHOOK_URL`Variable飞书通知可选
- **注意**Secret/Variable 的 `data`/`value` 字段需传入原始值,不要 base64 编码
详见 [AGENTS.md](AGENTS.md)。 详见 [AGENTS.md](AGENTS.md)。

View File

@@ -7,7 +7,9 @@
# - secrets.RELEASE_TOKEN (必选) Release 上传用,值为 Gitea PAT # - secrets.RELEASE_TOKEN (必选) Release 上传用,值为 Gitea PAT
# - vars.WEBHOOK_URL (可选) 飞书通知 # - vars.WEBHOOK_URL (可选) 飞书通知
# #
# 注意: Gitea 不允许 secret/variable 名以 GITEA_ 或 GITHUB_ 开头,故使用 RELEASE_TOKEN # 注意:
# - Gitea 不允许 secret/variable 名以 GITEA_ 或 GITHUB_ 开头,故使用 RELEASE_TOKEN
# - Secret/Variable 的 data/value 字段需传入原始值,不要使用 base64 编码
# #
# 用法: # 用法:
# 1. 从 ~/.config/gitea/config.env 读取 GITEA_URL, GITEA_TOKEN, GITEA_WEBHOOK_URL # 1. 从 ~/.config/gitea/config.env 读取 GITEA_URL, GITEA_TOKEN, GITEA_WEBHOOK_URL
@@ -108,11 +110,13 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━
echo "" echo ""
# 1. 创建 Secret: RELEASE_TOKEN # 1. 创建 Secret: RELEASE_TOKEN
# 注意: Gitea Actions API 的 data 字段需传入原始值,不要使用 base64 编码
echo "1. 创建 Secret: RELEASE_TOKEN" echo "1. 创建 Secret: RELEASE_TOKEN"
secret_payload=$(jq -n --arg t "$GITEA_TOKEN" '{data: $t}')
resp=$(curl -s -w "\n%{http_code}" -X PUT \ resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"data\":\"${GITEA_TOKEN}\"}" \ -d "$secret_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN") "${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d') body=$(echo "$resp" | sed '$d')
@@ -126,14 +130,16 @@ else
fi fi
# 2. 创建/更新 Variable: WEBHOOK_URL可选 # 2. 创建/更新 Variable: WEBHOOK_URL可选
# 注意: Secret 和 Variable 均使用原始值,不要 base64 编码
WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}" WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}"
if [[ -n "$WEBHOOK_VALUE" ]]; then if [[ -n "$WEBHOOK_VALUE" ]]; then
echo "" echo ""
echo "2. 创建/更新 Variable: WEBHOOK_URL" echo "2. 创建/更新 Variable: WEBHOOK_URL"
var_payload=$(jq -n --arg v "$WEBHOOK_VALUE" '{value: $v}')
resp=$(curl -s -w "\n%{http_code}" -X POST \ resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \ -d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL") "${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d') body=$(echo "$resp" | sed '$d')
@@ -145,7 +151,7 @@ if [[ -n "$WEBHOOK_VALUE" ]]; then
resp=$(curl -s -w "\n%{http_code}" -X PUT \ resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \ -d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL") "${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)
if [[ "$http_code" == "200" || "$http_code" == "204" ]]; then if [[ "$http_code" == "200" || "$http_code" == "204" ]]; then

View File

@@ -1,9 +1,39 @@
use anyhow::Result;
use serde_json::Value; use serde_json::Value;
use sqlx::PgPool; use sqlx::{PgPool, Postgres, Transaction};
/// Write an audit entry for a write operation. Failures are logged as warnings /// Write an audit entry within an existing transaction.
/// and do not interrupt the main flow. pub async fn log_tx(
tx: &mut Transaction<'_, Postgres>,
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(&mut **tx)
.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");
}
}
/// Write an audit entry using the pool (fire-and-forget, non-fatal).
/// Kept for future use or scenarios without an active transaction.
#[allow(dead_code)]
pub async fn log( pub async fn log(
pool: &PgPool, pool: &PgPool,
action: &str, action: &str,

View File

@@ -4,13 +4,30 @@ use sqlx::PgPool;
use std::fs; use std::fs;
use crate::crypto; use crate::crypto;
use crate::db;
use crate::output::OutputMode; use crate::output::OutputMode;
/// Parse "key=value" entries. Value starting with '@' reads from file. /// Parse "key=value" or "key:=<json>" entries.
pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> { /// - `key=value` → stores the literal string `value`
/// - `key:=<json>` → parses `<json>` as a typed JSON value (number, bool, null, array, object)
/// - `value=@file` → reads the file content as a string (only for `=` form)
pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> {
// Typed JSON form: key:=<json>
if let Some((key, json_str)) = entry.split_once(":=") {
let val: Value = serde_json::from_str(json_str).map_err(|e| {
anyhow::anyhow!(
"Invalid JSON value for key '{}': {} (use key=value for plain strings)",
key,
e
)
})?;
return Ok((key.to_string(), val));
}
// Plain string form: key=value or key=@file
let (key, raw_val) = entry.split_once('=').ok_or_else(|| { let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"Invalid format '{}'. Expected: key=value or key=@file", "Invalid format '{}'. Expected: key=value, key=@file, or key:=<json>",
entry entry
) )
})?; })?;
@@ -22,14 +39,14 @@ pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> {
raw_val.to_string() raw_val.to_string()
}; };
Ok((key.to_string(), value)) Ok((key.to_string(), Value::String(value)))
} }
pub(crate) fn build_json(entries: &[String]) -> Result<Value> { pub(crate) fn build_json(entries: &[String]) -> Result<Value> {
let mut map = Map::new(); let mut map = Map::new();
for entry in entries { for entry in entries {
let (key, value) = parse_kv(entry)?; let (key, value) = parse_kv(entry)?;
map.insert(key, Value::String(value)); map.insert(key, value);
} }
Ok(Value::Object(map)) Ok(Value::Object(map))
} }
@@ -47,21 +64,72 @@ pub struct AddArgs<'a> {
pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Result<()> { pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let metadata = build_json(args.meta_entries)?; let metadata = build_json(args.meta_entries)?;
let secret_json = build_json(args.secret_entries)?; let secret_json = build_json(args.secret_entries)?;
// Encrypt the secret JSON before storing
let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?; let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?;
tracing::debug!(args.namespace, args.kind, args.name, "upserting record"); tracing::debug!(args.namespace, args.kind, args.name, "upserting record");
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();
let mut tx = pool.begin().await?;
// Snapshot existing row into history before overwriting (if it exists).
#[derive(sqlx::FromRow)]
struct ExistingRow {
id: uuid::Uuid,
version: i64,
tags: Vec<String>,
metadata: serde_json::Value,
encrypted: Vec<u8>,
}
let existing: Option<ExistingRow> = sqlx::query_as(
"SELECT id, version, tags, metadata, encrypted FROM secrets \
WHERE namespace = $1 AND kind = $2 AND name = $3",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(&mut *tx)
.await?;
if let Some(ex) = existing
&& let Err(e) = db::snapshot_history(
&mut tx,
db::SnapshotParams {
secret_id: ex.id,
namespace: args.namespace,
kind: args.kind,
name: args.name,
version: ex.version,
action: "add",
tags: &ex.tags,
metadata: &ex.metadata,
encrypted: &ex.encrypted,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot history before upsert");
}
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, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW()) VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
ON CONFLICT (namespace, kind, name) ON CONFLICT (namespace, kind, name)
DO UPDATE SET DO UPDATE SET
tags = EXCLUDED.tags, tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata, metadata = EXCLUDED.metadata,
encrypted = EXCLUDED.encrypted, encrypted = EXCLUDED.encrypted,
version = secrets.version + 1,
updated_at = NOW() updated_at = NOW()
"#, "#,
) )
@@ -71,22 +139,11 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
.bind(args.tags) .bind(args.tags)
.bind(&metadata) .bind(&metadata)
.bind(&encrypted_bytes) .bind(&encrypted_bytes)
.execute(pool) .execute(&mut *tx)
.await?; .await?;
let meta_keys: Vec<&str> = args crate::audit::log_tx(
.meta_entries &mut tx,
.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,
"add", "add",
args.namespace, args.namespace,
args.kind, args.kind,
@@ -99,6 +156,8 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
) )
.await; .await;
tx.commit().await?;
let result_json = json!({ let result_json = json!({
"action": "added", "action": "added",
"namespace": args.namespace, "namespace": args.namespace,

View File

@@ -1,9 +1,20 @@
use anyhow::Result; use anyhow::Result;
use serde_json::json; use serde_json::{Value, json};
use sqlx::PgPool; use sqlx::{FromRow, PgPool};
use uuid::Uuid;
use crate::db;
use crate::output::OutputMode; use crate::output::OutputMode;
#[derive(FromRow)]
struct DeleteRow {
id: Uuid,
version: i64,
tags: Vec<String>,
metadata: Value,
encrypted: Vec<u8>,
}
pub async fn run( pub async fn run(
pool: &PgPool, pool: &PgPool,
namespace: &str, namespace: &str,
@@ -13,15 +24,21 @@ pub async fn run(
) -> Result<()> { ) -> Result<()> {
tracing::debug!(namespace, kind, name, "deleting record"); tracing::debug!(namespace, kind, name, "deleting record");
let result = let mut tx = pool.begin().await?;
sqlx::query("DELETE FROM secrets WHERE namespace = $1 AND kind = $2 AND name = $3")
.bind(namespace)
.bind(kind)
.bind(name)
.execute(pool)
.await?;
if result.rows_affected() == 0 { let row: Option<DeleteRow> = sqlx::query_as(
"SELECT id, version, tags, metadata, encrypted FROM secrets \
WHERE 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?;
tracing::warn!(namespace, kind, name, "record not found for deletion"); tracing::warn!(namespace, kind, name, "record not found for deletion");
match output { match output {
OutputMode::Json => println!( OutputMode::Json => println!(
@@ -38,23 +55,53 @@ pub async fn run(
), ),
_ => println!("Not found: [{}/{}] {}", namespace, kind, name), _ => println!("Not found: [{}/{}] {}", namespace, kind, name),
} }
} else { return Ok(());
crate::audit::log(pool, "delete", namespace, kind, name, json!({})).await; };
match output {
OutputMode::Json => println!( // Snapshot before physical delete so the row can be restored via rollback.
"{}", if let Err(e) = db::snapshot_history(
serde_json::to_string_pretty( &mut tx,
&json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name}) db::SnapshotParams {
)? secret_id: row.id,
), namespace,
OutputMode::JsonCompact => println!( kind,
"{}", name,
serde_json::to_string( version: row.version,
&json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name}) action: "delete",
)? tags: &row.tags,
), metadata: &row.metadata,
_ => println!("Deleted: [{}/{}] {}", namespace, kind, name), encrypted: &row.encrypted,
} },
)
.await
{
tracing::warn!(error = %e, "failed to snapshot history before delete");
} }
sqlx::query("DELETE FROM secrets WHERE id = $1")
.bind(row.id)
.execute(&mut *tx)
.await?;
crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await;
tx.commit().await?;
match output {
OutputMode::Json => println!(
"{}",
serde_json::to_string_pretty(
&json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name})
)?
),
OutputMode::JsonCompact => println!(
"{}",
serde_json::to_string(
&json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name})
)?
),
_ => println!("Deleted: [{}/{}] {}", namespace, kind, name),
}
Ok(()) Ok(())
} }

View File

@@ -2,5 +2,7 @@ pub mod add;
pub mod config; pub mod config;
pub mod delete; pub mod delete;
pub mod init; pub mod init;
pub mod rollback;
pub mod run;
pub mod search; pub mod search;
pub mod update; pub mod update;

245
src/commands/rollback.rs Normal file
View File

@@ -0,0 +1,245 @@
use anyhow::Result;
use serde_json::{Value, json};
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
use crate::output::OutputMode;
#[derive(FromRow)]
struct HistoryRow {
secret_id: Uuid,
#[allow(dead_code)]
namespace: String,
#[allow(dead_code)]
kind: String,
#[allow(dead_code)]
name: String,
version: i64,
action: String,
tags: Vec<String>,
metadata: Value,
encrypted: Vec<u8>,
}
pub struct RollbackArgs<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
/// Target version to restore. None → restore the most recent history entry.
pub to_version: Option<i64>,
pub output: OutputMode,
}
pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let snap: Option<HistoryRow> = if let Some(ver) = args.to_version {
sqlx::query_as(
"SELECT secret_id, namespace, kind, name, version, action, tags, metadata, encrypted \
FROM secrets_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
ORDER BY id DESC LIMIT 1",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.bind(ver)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT secret_id, namespace, kind, name, version, action, tags, metadata, encrypted \
FROM secrets_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
ORDER BY id DESC LIMIT 1",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(pool)
.await?
};
let snap = snap.ok_or_else(|| {
anyhow::anyhow!(
"No history found for [{}/{}] {}{}.",
args.namespace,
args.kind,
args.name,
args.to_version
.map(|v| format!(" at version {}", v))
.unwrap_or_default()
)
})?;
// Validate encrypted blob is non-trivial (re-encrypt guard).
if !snap.encrypted.is_empty() {
// Probe decrypt to ensure the blob is valid before restoring.
crate::crypto::decrypt_json(master_key, &snap.encrypted)?;
}
let mut tx = pool.begin().await?;
// Snapshot current live row (if it exists) before overwriting.
#[derive(sqlx::FromRow)]
struct LiveRow {
id: Uuid,
version: i64,
tags: Vec<String>,
metadata: Value,
encrypted: Vec<u8>,
}
let live: Option<LiveRow> = sqlx::query_as(
"SELECT id, version, tags, metadata, encrypted FROM secrets \
WHERE namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(&mut *tx)
.await?;
if let Some(lr) = live
&& let Err(e) = crate::db::snapshot_history(
&mut tx,
crate::db::SnapshotParams {
secret_id: lr.id,
namespace: args.namespace,
kind: args.kind,
name: args.name,
version: lr.version,
action: "rollback",
tags: &lr.tags,
metadata: &lr.metadata,
encrypted: &lr.encrypted,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot current row before rollback");
}
sqlx::query(
"INSERT INTO secrets (id, namespace, kind, name, tags, metadata, encrypted, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) \
ON CONFLICT (namespace, kind, name) DO UPDATE SET \
tags = EXCLUDED.tags, \
metadata = EXCLUDED.metadata, \
encrypted = EXCLUDED.encrypted, \
version = secrets.version + 1, \
updated_at = NOW()",
)
.bind(snap.secret_id)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(&snap.encrypted)
.bind(snap.version)
.execute(&mut *tx)
.await?;
crate::audit::log_tx(
&mut tx,
"rollback",
args.namespace,
args.kind,
args.name,
json!({
"restored_version": snap.version,
"original_action": snap.action,
}),
)
.await;
tx.commit().await?;
let result_json = json!({
"action": "rolled_back",
"namespace": args.namespace,
"kind": args.kind,
"name": args.name,
"restored_version": snap.version,
});
match args.output {
OutputMode::Json => println!("{}", serde_json::to_string_pretty(&result_json)?),
OutputMode::JsonCompact => println!("{}", serde_json::to_string(&result_json)?),
_ => println!(
"Rolled back: [{}/{}] {} → version {}",
args.namespace, args.kind, args.name, snap.version
),
}
Ok(())
}
/// List history entries for a record.
pub async fn list_history(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
limit: u32,
output: OutputMode,
) -> Result<()> {
#[derive(FromRow)]
struct HistorySummary {
version: i64,
action: String,
actor: String,
created_at: chrono::DateTime<chrono::Utc>,
}
let rows: Vec<HistorySummary> = sqlx::query_as(
"SELECT version, action, actor, created_at FROM secrets_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
ORDER BY id DESC LIMIT $4",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(limit as i64)
.fetch_all(pool)
.await?;
match output {
OutputMode::Json | OutputMode::JsonCompact => {
let arr: Vec<Value> = rows
.iter()
.map(|r| {
json!({
"version": r.version,
"action": r.action,
"actor": r.actor,
"created_at": r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
})
.collect();
let out = if output == OutputMode::Json {
serde_json::to_string_pretty(&arr)?
} else {
serde_json::to_string(&arr)?
};
println!("{}", out);
}
_ => {
if rows.is_empty() {
println!("No history found for [{}/{}] {}.", namespace, kind, name);
return Ok(());
}
println!("History for [{}/{}] {}:", namespace, kind, name);
for r in &rows {
println!(
" v{:<4} {:8} {} {}",
r.version,
r.action,
r.actor,
r.created_at.format("%Y-%m-%d %H:%M:%S UTC")
);
}
println!(" (use `secrets rollback --to-version <N>` to restore)");
}
}
Ok(())
}

143
src/commands/run.rs Normal file
View File

@@ -0,0 +1,143 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::HashMap;
use crate::commands::search::build_env_map;
use crate::output::OutputMode;
pub struct InjectArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
/// Prefix to prepend to every variable name. Empty string means no prefix.
pub prefix: &'a str,
pub output: OutputMode,
}
pub struct RunArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub prefix: &'a str,
/// The command and its arguments to execute with injected secrets.
pub command: &'a [String],
}
/// Fetch secrets matching the filter and build a flat env map.
/// Metadata and secret fields are merged; naming: `<PREFIX_><NAME>_<KEY>` (uppercased).
pub async fn collect_env_map(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
prefix: &str,
master_key: &[u8; 32],
) -> Result<HashMap<String, String>> {
if namespace.is_none() && kind.is_none() && name.is_none() && tags.is_empty() {
anyhow::bail!(
"At least one filter (--namespace, --kind, --name, or --tag) is required for inject/run"
);
}
let rows = crate::commands::search::fetch_rows(pool, namespace, kind, name, tags, None).await?;
if rows.is_empty() {
anyhow::bail!("No records matched the given filters.");
}
let mut map = HashMap::new();
for row in &rows {
let row_map = build_env_map(row, prefix, Some(master_key))?;
for (k, v) in row_map {
map.insert(k, v);
}
}
Ok(map)
}
/// `inject` command: print env vars to stdout (suitable for `eval $(...)` or export).
pub async fn run_inject(pool: &PgPool, args: InjectArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let env_map = collect_env_map(
pool,
args.namespace,
args.kind,
args.name,
args.tags,
args.prefix,
master_key,
)
.await?;
match args.output {
OutputMode::Json => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string_pretty(&Value::Object(obj))?);
}
OutputMode::JsonCompact => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string(&Value::Object(obj))?);
}
_ => {
// Shell-safe KEY=VALUE output, one per line.
let mut pairs: Vec<(String, String)> = env_map.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in pairs {
println!("{}={}", k, shell_quote(&v));
}
}
}
Ok(())
}
/// `run` command: inject secrets into a child process environment and execute.
pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
if args.command.is_empty() {
anyhow::bail!(
"No command specified. Usage: secrets run [filter flags] -- <command> [args]"
);
}
let env_map = collect_env_map(
pool,
args.namespace,
args.kind,
args.name,
args.tags,
args.prefix,
master_key,
)
.await?;
tracing::debug!(
vars = env_map.len(),
cmd = args.command[0].as_str(),
"injecting secrets into child process"
);
let status = std::process::Command::new(&args.command[0])
.args(&args.command[1..])
.envs(&env_map)
.status()
.map_err(|e| anyhow::anyhow!("Failed to execute '{}': {}", args.command[0], e))?;
if !status.success() {
let code = status.code().unwrap_or(1);
std::process::exit(code);
}
Ok(())
}
/// Quote a value for safe shell output. Wraps the value in single quotes,
/// escaping any single quotes within the value.
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}

View File

@@ -1,6 +1,7 @@
use anyhow::Result; use anyhow::Result;
use serde_json::{Value, json}; use serde_json::{Value, json};
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashMap;
use crate::crypto; use crate::crypto;
use crate::models::Secret; use crate::models::Secret;
@@ -22,88 +23,20 @@ pub struct SearchArgs<'a> {
} }
pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 32]>) -> Result<()> { pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 32]>) -> Result<()> {
let mut conditions: Vec<String> = Vec::new(); let rows = fetch_rows_paged(
let mut idx: i32 = 1; pool,
PagedFetchArgs {
if args.namespace.is_some() { namespace: args.namespace,
conditions.push(format!("namespace = ${}", idx)); kind: args.kind,
idx += 1; name: args.name,
} tags: args.tags,
if args.kind.is_some() { query: args.query,
conditions.push(format!("kind = ${}", idx)); sort: args.sort,
idx += 1; limit: args.limit,
} offset: args.offset,
if args.name.is_some() { },
conditions.push(format!("name = ${}", idx)); )
idx += 1; .await?;
}
if !args.tags.is_empty() {
// Use PostgreSQL array containment: tags @> ARRAY[$n, $m, ...] means all specified tags must be present
let placeholders: Vec<String> = args
.tags
.iter()
.map(|_| {
let p = format!("${}", idx);
idx += 1;
p
})
.collect();
conditions.push(format!("tags @> ARRAY[{}]", placeholders.join(", ")));
}
if args.query.is_some() {
conditions.push(format!(
"(name ILIKE ${i} ESCAPE '\\' OR namespace ILIKE ${i} ESCAPE '\\' OR kind ILIKE ${i} ESCAPE '\\' OR metadata::text ILIKE ${i} ESCAPE '\\' OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))",
i = idx
));
idx += 1;
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let order = match args.sort {
"updated" => "updated_at DESC",
"created" => "created_at DESC",
_ => "namespace, kind, name",
};
let sql = format!(
"SELECT * FROM secrets {} ORDER BY {} LIMIT ${} OFFSET ${}",
where_clause,
order,
idx,
idx + 1
);
tracing::debug!(sql, "executing search query");
let mut q = sqlx::query_as::<_, Secret>(&sql);
if let Some(v) = args.namespace {
q = q.bind(v);
}
if let Some(v) = args.kind {
q = q.bind(v);
}
if let Some(v) = args.name {
q = q.bind(v);
}
for v in args.tags {
q = q.bind(v.as_str());
}
if let Some(v) = args.query {
q = q.bind(format!(
"%{}%",
v.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
));
}
q = q.bind(args.limit as i64).bind(args.offset as i64);
let rows = q.fetch_all(pool).await?;
// -f/--field: extract specific field values directly // -f/--field: extract specific field values directly
if !args.fields.is_empty() { if !args.fields.is_empty() {
@@ -131,7 +64,12 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
); );
} }
if let Some(row) = rows.first() { if let Some(row) = rows.first() {
print_env(row, args.show_secrets, master_key)?; let map = build_env_map(row, "", master_key)?;
let mut pairs: Vec<(String, String)> = map.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in pairs {
println!("{}={}", k, shell_quote(&v));
}
} else { } else {
eprintln!("No records found."); eprintln!("No records found.");
} }
@@ -158,8 +96,195 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
Ok(()) Ok(())
} }
/// Fetch rows with simple equality/tag filters (no pagination). Used by inject/run.
pub async fn fetch_rows(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
) -> Result<Vec<Secret>> {
fetch_rows_paged(
pool,
PagedFetchArgs {
namespace,
kind,
name,
tags,
query,
sort: "name",
limit: 200,
offset: 0,
},
)
.await
}
/// Arguments for the internal paged fetch. Grouped to avoid too-many-arguments lint.
struct PagedFetchArgs<'a> {
namespace: Option<&'a str>,
kind: Option<&'a str>,
name: Option<&'a str>,
tags: &'a [String],
query: Option<&'a str>,
sort: &'a str,
limit: u32,
offset: u32,
}
async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Secret>> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if a.namespace.is_some() {
conditions.push(format!("namespace = ${}", idx));
idx += 1;
}
if a.kind.is_some() {
conditions.push(format!("kind = ${}", idx));
idx += 1;
}
if a.name.is_some() {
conditions.push(format!("name = ${}", idx));
idx += 1;
}
if !a.tags.is_empty() {
let placeholders: Vec<String> = a
.tags
.iter()
.map(|_| {
let p = format!("${}", idx);
idx += 1;
p
})
.collect();
conditions.push(format!("tags @> ARRAY[{}]", placeholders.join(", ")));
}
if a.query.is_some() {
conditions.push(format!(
"(name ILIKE ${i} ESCAPE '\\' OR namespace ILIKE ${i} ESCAPE '\\' OR kind ILIKE ${i} ESCAPE '\\' OR metadata::text ILIKE ${i} ESCAPE '\\' OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))",
i = idx
));
idx += 1;
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let order = match a.sort {
"updated" => "updated_at DESC",
"created" => "created_at DESC",
_ => "namespace, kind, name",
};
let sql = format!(
"SELECT * FROM secrets {} ORDER BY {} LIMIT ${} OFFSET ${}",
where_clause,
order,
idx,
idx + 1
);
tracing::debug!(sql, "executing search query");
let mut q = sqlx::query_as::<_, Secret>(&sql);
if let Some(v) = a.namespace {
q = q.bind(v);
}
if let Some(v) = a.kind {
q = q.bind(v);
}
if let Some(v) = a.name {
q = q.bind(v);
}
for v in a.tags {
q = q.bind(v.as_str());
}
if let Some(v) = a.query {
q = q.bind(format!(
"%{}%",
v.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
));
}
q = q.bind(a.limit as i64).bind(a.offset as i64);
let rows = q.fetch_all(pool).await?;
Ok(rows)
}
/// Build a flat `KEY=VALUE` map from a record's metadata and decrypted secrets.
/// Variable names: `<PREFIX><NAME>_<FIELD>` (all uppercased, hyphens/dots → underscores).
/// If `prefix` is empty, the name segment alone is used as the prefix.
pub fn build_env_map(
row: &Secret,
prefix: &str,
master_key: Option<&[u8; 32]>,
) -> Result<HashMap<String, String>> {
let name_part = row.name.to_uppercase().replace(['-', '.', ' '], "_");
let effective_prefix = if prefix.is_empty() {
name_part
} else {
format!(
"{}_{}",
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
name_part
)
};
let mut map = HashMap::new();
if let Some(meta) = row.metadata.as_object() {
for (k, v) in meta {
let key = format!(
"{}_{}",
effective_prefix,
k.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_value_to_env_string(v));
}
}
if let Some(master_key) = master_key
&& !row.encrypted.is_empty()
{
let decrypted = crypto::decrypt_json(master_key, &row.encrypted)?;
if let Some(enc) = decrypted.as_object() {
for (k, v) in enc {
let key = format!(
"{}_{}",
effective_prefix,
k.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_value_to_env_string(v));
}
}
}
Ok(map)
}
/// Quote a value for safe shell / env output. Wraps in single quotes,
/// escaping any single quotes within the value.
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
/// Convert a JSON value to its string representation suitable for env vars.
fn json_value_to_env_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
/// Decrypt the encrypted blob for a row. Returns an empty object on empty blobs. /// Decrypt the encrypted blob for a row. Returns an empty object on empty blobs.
/// Returns an error value on decrypt failure (so callers can decide how to handle).
fn try_decrypt(row: &Secret, master_key: Option<&[u8; 32]>) -> Result<Value> { fn try_decrypt(row: &Secret, master_key: Option<&[u8; 32]>) -> Result<Value> {
if row.encrypted.is_empty() { if row.encrypted.is_empty() {
return Ok(Value::Object(Default::default())); return Ok(Value::Object(Default::default()));
@@ -211,10 +336,12 @@ fn to_json(
"tags": row.tags, "tags": row.tags,
"metadata": row.metadata, "metadata": row.metadata,
"secrets": secrets_val, "secrets": secrets_val,
"version": row.version,
"created_at": row.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "created_at": row.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
}) })
} }
fn print_text( fn print_text(
row: &Secret, row: &Secret,
show_secrets: bool, show_secrets: bool,
@@ -267,30 +394,9 @@ fn print_text(
Ok(()) Ok(())
} }
fn print_env(row: &Secret, show_secrets: bool, master_key: Option<&[u8; 32]>) -> Result<()> {
let prefix = row.name.to_uppercase().replace(['-', '.'], "_");
if let Some(meta) = row.metadata.as_object() {
for (k, v) in meta {
let key = format!("{}_{}", prefix, k.to_uppercase().replace('-', "_"));
println!("{}={}", key, v.as_str().unwrap_or(&v.to_string()));
}
}
if show_secrets {
let decrypted = try_decrypt(row, master_key)?;
if let Some(enc) = decrypted.as_object() {
for (k, v) in enc {
let key = format!("{}_{}", prefix, k.to_uppercase().replace('-', "_"));
println!("{}={}", key, v.as_str().unwrap_or(&v.to_string()));
}
}
}
Ok(())
}
/// Extract one or more field paths like `metadata.url` or `secret.token`. /// Extract one or more field paths like `metadata.url` or `secret.token`.
fn print_fields(rows: &[Secret], fields: &[String], master_key: Option<&[u8; 32]>) -> Result<()> { fn print_fields(rows: &[Secret], fields: &[String], master_key: Option<&[u8; 32]>) -> Result<()> {
for row in rows { for row in rows {
// Decrypt once per row if any field requires it
let decrypted: Option<Value> = if fields let decrypted: Option<Value> = if fields
.iter() .iter()
.any(|f| f.starts_with("secret") || f.starts_with("encrypted")) .any(|f| f.starts_with("secret") || f.starts_with("encrypted"))

View File

@@ -5,11 +5,13 @@ use uuid::Uuid;
use super::add::parse_kv; use super::add::parse_kv;
use crate::crypto; use crate::crypto;
use crate::db;
use crate::output::OutputMode; use crate::output::OutputMode;
#[derive(FromRow)] #[derive(FromRow)]
struct UpdateRow { struct UpdateRow {
id: Uuid, id: Uuid,
version: i64,
tags: Vec<String>, tags: Vec<String>,
metadata: Value, metadata: Value,
encrypted: Vec<u8>, encrypted: Vec<u8>,
@@ -29,17 +31,18 @@ pub struct UpdateArgs<'a> {
} }
pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> Result<()> { pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let mut tx = pool.begin().await?;
let row: Option<UpdateRow> = sqlx::query_as( let row: Option<UpdateRow> = sqlx::query_as(
r#" "SELECT id, version, tags, metadata, encrypted \
SELECT id, tags, metadata, encrypted FROM secrets \
FROM secrets WHERE namespace = $1 AND kind = $2 AND name = $3 \
WHERE namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
"#,
) )
.bind(args.namespace) .bind(args.namespace)
.bind(args.kind) .bind(args.kind)
.bind(args.name) .bind(args.name)
.fetch_optional(pool) .fetch_optional(&mut *tx)
.await?; .await?;
let row = row.ok_or_else(|| { let row = row.ok_or_else(|| {
@@ -51,6 +54,26 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
) )
})?; })?;
// Snapshot current state before modifying.
if let Err(e) = db::snapshot_history(
&mut tx,
db::SnapshotParams {
secret_id: row.id,
namespace: args.namespace,
kind: args.kind,
name: args.name,
version: row.version,
action: "update",
tags: &row.tags,
metadata: &row.metadata,
encrypted: &row.encrypted,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot history before update");
}
// Merge tags // Merge tags
let mut tags: Vec<String> = row.tags; let mut tags: Vec<String> = row.tags;
for t in args.add_tags { for t in args.add_tags {
@@ -67,7 +90,7 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
}; };
for entry in args.meta_entries { for entry in args.meta_entries {
let (key, value) = parse_kv(entry)?; let (key, value) = parse_kv(entry)?;
meta_map.insert(key, Value::String(value)); meta_map.insert(key, value);
} }
for key in args.remove_meta { for key in args.remove_meta {
meta_map.remove(key); meta_map.remove(key);
@@ -86,7 +109,7 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
}; };
for entry in args.secret_entries { for entry in args.secret_entries {
let (key, value) = parse_kv(entry)?; let (key, value) = parse_kv(entry)?;
enc_map.insert(key, Value::String(value)); enc_map.insert(key, value);
} }
for key in args.remove_secrets { for key in args.remove_secrets {
enc_map.remove(key); enc_map.remove(key);
@@ -101,33 +124,43 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
"updating record" "updating record"
); );
sqlx::query( // CAS: update only if version hasn't changed (FOR UPDATE lock ensures this).
r#" let result = sqlx::query(
UPDATE secrets "UPDATE secrets \
SET tags = $1, metadata = $2, encrypted = $3, updated_at = NOW() SET tags = $1, metadata = $2, encrypted = $3, version = version + 1, updated_at = NOW() \
WHERE id = $4 WHERE id = $4 AND version = $5",
"#,
) )
.bind(&tags) .bind(&tags)
.bind(metadata) .bind(&metadata)
.bind(encrypted_bytes) .bind(&encrypted_bytes)
.bind(row.id) .bind(row.id)
.execute(pool) .bind(row.version)
.execute(&mut *tx)
.await?; .await?;
if result.rows_affected() == 0 {
tx.rollback().await?;
anyhow::bail!(
"Concurrent modification detected for [{}/{}] {}. Please retry.",
args.namespace,
args.kind,
args.name
);
}
let meta_keys: Vec<&str> = args let meta_keys: Vec<&str> = args
.meta_entries .meta_entries
.iter() .iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k)) .filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
.collect(); .collect();
let secret_keys: Vec<&str> = args let secret_keys: Vec<&str> = args
.secret_entries .secret_entries
.iter() .iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k)) .filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
.collect(); .collect();
crate::audit::log( crate::audit::log_tx(
pool, &mut tx,
"update", "update",
args.namespace, args.namespace,
args.kind, args.kind,
@@ -143,6 +176,8 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
) )
.await; .await;
tx.commit().await?;
let result_json = json!({ let result_json = json!({
"action": "updated", "action": "updated",
"namespace": args.namespace, "namespace": args.namespace,

View File

@@ -25,6 +25,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
tags TEXT[] NOT NULL DEFAULT '{}', tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}', metadata JSONB NOT NULL DEFAULT '{}',
encrypted BYTEA NOT NULL DEFAULT '\x', encrypted BYTEA NOT NULL DEFAULT '\x',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name) UNIQUE(namespace, kind, name)
@@ -36,6 +37,11 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
EXCEPTION WHEN OTHERS THEN NULL; EXCEPTION WHEN OTHERS THEN NULL;
END $$; END $$;
DO $$ BEGIN
ALTER TABLE secrets ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1;
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
-- Migrate encrypted column from JSONB to BYTEA if still JSONB type. -- Migrate encrypted column from JSONB to BYTEA if still JSONB type.
-- After migration, old plaintext rows will have their JSONB data -- After migration, old plaintext rows will have their JSONB data
-- stored as raw bytes (UTF-8 encoded). -- stored as raw bytes (UTF-8 encoded).
@@ -79,6 +85,26 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); 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); CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind);
-- History table: snapshot of secrets before each write operation.
-- Supports rollback to any prior version via `secrets rollback`.
CREATE TABLE IF NOT EXISTS secrets_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
secret_id UUID NOT NULL,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
version BIGINT NOT NULL,
action VARCHAR(16) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
encrypted BYTEA NOT NULL DEFAULT '\x',
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_history_secret_id ON secrets_history(secret_id, version DESC);
CREATE INDEX IF NOT EXISTS idx_history_ns_kind_name ON secrets_history(namespace, kind, name, version DESC);
"#, "#,
) )
.execute(pool) .execute(pool)
@@ -87,6 +113,47 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
Ok(()) Ok(())
} }
/// Snapshot parameters grouped to avoid too-many-arguments lint.
pub struct SnapshotParams<'a> {
pub secret_id: uuid::Uuid,
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
pub version: i64,
pub action: &'a str,
pub tags: &'a [String],
pub metadata: &'a serde_json::Value,
pub encrypted: &'a [u8],
}
/// Snapshot a secrets row into `secrets_history` before a write operation.
/// `action` is one of "add", "update", "delete".
/// Failures are non-fatal (caller should warn).
pub async fn snapshot_history(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
p: SnapshotParams<'_>,
) -> Result<()> {
let actor = std::env::var("USER").unwrap_or_default();
sqlx::query(
"INSERT INTO secrets_history \
(secret_id, namespace, kind, name, version, action, tags, metadata, encrypted, actor) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
)
.bind(p.secret_id)
.bind(p.namespace)
.bind(p.kind)
.bind(p.name)
.bind(p.version)
.bind(p.action)
.bind(p.tags)
.bind(p.metadata)
.bind(p.encrypted)
.bind(&actor)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Load the Argon2id salt from the database. /// Load the Argon2id salt from the database.
/// Returns None if not yet initialized. /// Returns None if not yet initialized.
pub async fn load_argon2_salt(pool: &PgPool) -> Result<Option<Vec<u8>>> { pub async fn load_argon2_salt(pool: &PgPool) -> Result<Option<Vec<u8>>> {

View File

@@ -282,6 +282,112 @@ EXAMPLES:
#[command(subcommand)] #[command(subcommand)]
action: ConfigAction, action: ConfigAction,
}, },
/// Show the change history for a record.
#[command(after_help = "EXAMPLES:
# Show last 20 versions for a service record
secrets history -n refining --kind service --name gitea
# Show last 5 versions
secrets history -n refining --kind service --name gitea --limit 5")]
History {
#[arg(short, long)]
namespace: String,
#[arg(long)]
kind: String,
#[arg(long)]
name: String,
/// Number of history entries to show [default: 20]
#[arg(long, default_value = "20")]
limit: u32,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Roll back a record to a previous version.
#[command(after_help = "EXAMPLES:
# Roll back to the most recent snapshot (undo last change)
secrets rollback -n refining --kind service --name gitea
# Roll back to a specific version number
secrets rollback -n refining --kind service --name gitea --to-version 3")]
Rollback {
#[arg(short, long)]
namespace: String,
#[arg(long)]
kind: String,
#[arg(long)]
name: String,
/// Target version to restore. Omit to restore the most recent snapshot.
#[arg(long)]
to_version: Option<i64>,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Print secrets as environment variables (stdout only, nothing persisted).
///
/// Outputs KEY=VALUE pairs for all matched records. Safe to pipe or eval.
#[command(after_help = "EXAMPLES:
# Print env vars for a single service
secrets inject -n refining --kind service --name gitea
# With a custom prefix
secrets inject -n refining --kind service --name gitea --prefix GITEA
# JSON output (all vars as a JSON object)
secrets inject -n refining --kind service --name gitea -o json
# Eval into current shell (use with caution)
eval $(secrets inject -n refining --kind service --name gitea)")]
Inject {
#[arg(short, long)]
namespace: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
tag: Vec<String>,
/// Prefix to prepend to every variable name (uppercased automatically)
#[arg(long, default_value = "")]
prefix: String,
/// Output format: text/KEY=VALUE (default), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Run a command with secrets injected as environment variables.
///
/// Secrets are available only to the child process; the current shell
/// environment is not modified. The process exit code is propagated.
#[command(after_help = "EXAMPLES:
# Run a script with a single service's secrets injected
secrets run -n refining --kind service --name gitea -- ./deploy.sh
# Run with a tag filter (all matched records merged)
secrets run --tag production -- env | grep GITEA
# With prefix
secrets run -n refining --kind service --name gitea --prefix GITEA -- printenv")]
Run {
#[arg(short, long)]
namespace: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
tag: Vec<String>,
/// Prefix to prepend to every variable name (uppercased automatically)
#[arg(long, default_value = "")]
prefix: String,
/// Command and arguments to execute with injected environment
#[arg(last = true, required = true)]
command: Vec<String>,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -445,6 +551,89 @@ async fn main() -> Result<()> {
) )
.await?; .await?;
} }
Commands::History {
namespace,
kind,
name,
limit,
output,
} => {
let out = resolve_output_mode(output.as_deref())?;
commands::rollback::list_history(&pool, &namespace, &kind, &name, limit, out).await?;
}
Commands::Rollback {
namespace,
kind,
name,
to_version,
output,
} => {
let master_key = crypto::load_master_key()?;
let out = resolve_output_mode(output.as_deref())?;
commands::rollback::run(
&pool,
commands::rollback::RollbackArgs {
namespace: &namespace,
kind: &kind,
name: &name,
to_version,
output: out,
},
&master_key,
)
.await?;
}
Commands::Inject {
namespace,
kind,
name,
tag,
prefix,
output,
} => {
let master_key = crypto::load_master_key()?;
let out = resolve_output_mode(output.as_deref())?;
commands::run::run_inject(
&pool,
commands::run::InjectArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
prefix: &prefix,
output: out,
},
&master_key,
)
.await?;
}
Commands::Run {
namespace,
kind,
name,
tag,
prefix,
command,
} => {
let master_key = crypto::load_master_key()?;
commands::run::run_exec(
&pool,
commands::run::RunArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
prefix: &prefix,
command: &command,
},
&master_key,
)
.await?;
}
} }
Ok(()) Ok(())

View File

@@ -14,6 +14,7 @@ pub struct Secret {
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag /// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
/// Decrypt with crypto::decrypt_json() before use. /// Decrypt with crypto::decrypt_json() before use.
pub encrypted: Vec<u8>, pub encrypted: Vec<u8>,
pub version: i64,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }