feat: 0.6.0 — 事务/版本化/类型化/inject/run

- 写路径事务化: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 1723d05428
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 全局参数
output.rs # OutputMode 枚举 + TTY 检测TTY→text非 TTY→json-compact
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 钥匙串
models.rs # Secret 结构体sqlx::FromRow + serde
audit.rs # 审计写入:向 audit_log 表记录所有写操作
models.rs # Secret 结构体sqlx::FromRow + serde,含 version 字段
audit.rs # 审计写入:log_tx事务内/ log保留备用
commands/
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
search.rs # search 命令:多条件查询,-f/-o/--summary/--limit/--offset/--sort
delete.rs # delete 命令
update.rs # update 命令:增量更新(合并 tags/metadata/encrypted
search.rs # search 命令:多条件查询,公开 fetch_rows / build_env_map
delete.rs # delete 命令:事务化,含历史快照
update.rs # update 命令:增量更新CAS 并发保护,含历史快照
rollback.rs # rollback / history 命令:版本回滚与历史查看
run.rs # inject / run 命令:临时环境变量注入
scripts/
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
.gitea/workflows/
@@ -46,12 +48,30 @@ secrets (
tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"]
metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location...
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(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
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
kv_config (
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 \
-m host=mqtt.ricnsmart.com -m port=1883 \
-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 — 配置管理(无需主密钥)
```bash
@@ -366,6 +485,7 @@ cargo fmt -- --check && cargo clippy -- -D warnings && cargo test
- 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制到 Gitea Release
- 通知:飞书 Webhook`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]]
name = "secrets"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"aes-gcm",
"anyhow",

View File

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

View File

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

View File

@@ -7,7 +7,9 @@
# - secrets.RELEASE_TOKEN (必选) Release 上传用,值为 Gitea PAT
# - 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
@@ -108,11 +110,13 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━
echo ""
# 1. 创建 Secret: RELEASE_TOKEN
# 注意: Gitea Actions API 的 data 字段需传入原始值,不要使用 base64 编码
echo "1. 创建 Secret: RELEASE_TOKEN"
secret_payload=$(jq -n --arg t "$GITEA_TOKEN" '{data: $t}')
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"data\":\"${GITEA_TOKEN}\"}" \
-d "$secret_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN")
http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d')
@@ -126,14 +130,16 @@ else
fi
# 2. 创建/更新 Variable: WEBHOOK_URL可选
# 注意: Secret 和 Variable 均使用原始值,不要 base64 编码
WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}"
if [[ -n "$WEBHOOK_VALUE" ]]; then
echo ""
echo "2. 创建/更新 Variable: WEBHOOK_URL"
var_payload=$(jq -n --arg v "$WEBHOOK_VALUE" '{value: $v}')
resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \
-d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d')
@@ -145,7 +151,7 @@ if [[ -n "$WEBHOOK_VALUE" ]]; then
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \
-d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1)
if [[ "$http_code" == "200" || "$http_code" == "204" ]]; then

View File

@@ -1,9 +1,39 @@
use anyhow::Result;
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
/// and do not interrupt the main flow.
/// Write an audit entry within an existing transaction.
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(
pool: &PgPool,
action: &str,

View File

@@ -4,13 +4,30 @@ use sqlx::PgPool;
use std::fs;
use crate::crypto;
use crate::db;
use crate::output::OutputMode;
/// Parse "key=value" entries. Value starting with '@' reads from file.
pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> {
/// Parse "key=value" or "key:=<json>" entries.
/// - `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(|| {
anyhow::anyhow!(
"Invalid format '{}'. Expected: key=value or key=@file",
"Invalid format '{}'. Expected: key=value, key=@file, or key:=<json>",
entry
)
})?;
@@ -22,14 +39,14 @@ pub(crate) fn parse_kv(entry: &str) -> Result<(String, 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> {
let mut map = Map::new();
for entry in entries {
let (key, value) = parse_kv(entry)?;
map.insert(key, Value::String(value));
map.insert(key, value);
}
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<()> {
let metadata = build_json(args.meta_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)?;
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(
r#"
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
ON CONFLICT (namespace, kind, name)
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
encrypted = EXCLUDED.encrypted,
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
encrypted = EXCLUDED.encrypted,
version = secrets.version + 1,
updated_at = NOW()
"#,
)
@@ -71,22 +139,11 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
.bind(args.tags)
.bind(&metadata)
.bind(&encrypted_bytes)
.execute(pool)
.execute(&mut *tx)
.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,
crate::audit::log_tx(
&mut tx,
"add",
args.namespace,
args.kind,
@@ -99,6 +156,8 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
)
.await;
tx.commit().await?;
let result_json = json!({
"action": "added",
"namespace": args.namespace,

View File

@@ -1,9 +1,20 @@
use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
use serde_json::{Value, json};
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
use crate::db;
use crate::output::OutputMode;
#[derive(FromRow)]
struct DeleteRow {
id: Uuid,
version: i64,
tags: Vec<String>,
metadata: Value,
encrypted: Vec<u8>,
}
pub async fn run(
pool: &PgPool,
namespace: &str,
@@ -13,15 +24,21 @@ pub async fn run(
) -> 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)
.bind(kind)
.bind(name)
.execute(pool)
.await?;
let mut tx = pool.begin().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");
match output {
OutputMode::Json => println!(
@@ -38,23 +55,53 @@ pub async fn run(
),
_ => println!("Not found: [{}/{}] {}", namespace, kind, name),
}
} else {
crate::audit::log(pool, "delete", namespace, kind, name, json!({})).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),
}
return Ok(());
};
// Snapshot before physical delete so the row can be restored via rollback.
if let Err(e) = db::snapshot_history(
&mut tx,
db::SnapshotParams {
secret_id: row.id,
namespace,
kind,
name,
version: row.version,
action: "delete",
tags: &row.tags,
metadata: &row.metadata,
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(())
}

View File

@@ -2,5 +2,7 @@ pub mod add;
pub mod config;
pub mod delete;
pub mod init;
pub mod rollback;
pub mod run;
pub mod search;
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 serde_json::{Value, json};
use sqlx::PgPool;
use std::collections::HashMap;
use crate::crypto;
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<()> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if args.namespace.is_some() {
conditions.push(format!("namespace = ${}", idx));
idx += 1;
}
if args.kind.is_some() {
conditions.push(format!("kind = ${}", idx));
idx += 1;
}
if args.name.is_some() {
conditions.push(format!("name = ${}", idx));
idx += 1;
}
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?;
let rows = fetch_rows_paged(
pool,
PagedFetchArgs {
namespace: args.namespace,
kind: args.kind,
name: args.name,
tags: args.tags,
query: args.query,
sort: args.sort,
limit: args.limit,
offset: args.offset,
},
)
.await?;
// -f/--field: extract specific field values directly
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() {
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 {
eprintln!("No records found.");
}
@@ -158,8 +96,195 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
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.
/// 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> {
if row.encrypted.is_empty() {
return Ok(Value::Object(Default::default()));
@@ -211,10 +336,12 @@ fn to_json(
"tags": row.tags,
"metadata": row.metadata,
"secrets": secrets_val,
"version": row.version,
"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(),
})
}
fn print_text(
row: &Secret,
show_secrets: bool,
@@ -267,30 +394,9 @@ fn print_text(
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`.
fn print_fields(rows: &[Secret], fields: &[String], master_key: Option<&[u8; 32]>) -> Result<()> {
for row in rows {
// Decrypt once per row if any field requires it
let decrypted: Option<Value> = if fields
.iter()
.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 crate::crypto;
use crate::db;
use crate::output::OutputMode;
#[derive(FromRow)]
struct UpdateRow {
id: Uuid,
version: i64,
tags: Vec<String>,
metadata: Value,
encrypted: Vec<u8>,
@@ -29,17 +31,18 @@ pub struct UpdateArgs<'a> {
}
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(
r#"
SELECT id, tags, metadata, encrypted
FROM secrets
WHERE namespace = $1 AND kind = $2 AND name = $3
"#,
"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(pool)
.fetch_optional(&mut *tx)
.await?;
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
let mut tags: Vec<String> = row.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 {
let (key, value) = parse_kv(entry)?;
meta_map.insert(key, Value::String(value));
meta_map.insert(key, value);
}
for key in args.remove_meta {
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 {
let (key, value) = parse_kv(entry)?;
enc_map.insert(key, Value::String(value));
enc_map.insert(key, value);
}
for key in args.remove_secrets {
enc_map.remove(key);
@@ -101,33 +124,43 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
"updating record"
);
sqlx::query(
r#"
UPDATE secrets
SET tags = $1, metadata = $2, encrypted = $3, updated_at = NOW()
WHERE id = $4
"#,
// CAS: update only if version hasn't changed (FOR UPDATE lock ensures this).
let result = sqlx::query(
"UPDATE secrets \
SET tags = $1, metadata = $2, encrypted = $3, version = version + 1, updated_at = NOW() \
WHERE id = $4 AND version = $5",
)
.bind(&tags)
.bind(metadata)
.bind(encrypted_bytes)
.bind(&metadata)
.bind(&encrypted_bytes)
.bind(row.id)
.execute(pool)
.bind(row.version)
.execute(&mut *tx)
.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
.meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.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))
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
.collect();
crate::audit::log(
pool,
crate::audit::log_tx(
&mut tx,
"update",
args.namespace,
args.kind,
@@ -143,6 +176,8 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
)
.await;
tx.commit().await?;
let result_json = json!({
"action": "updated",
"namespace": args.namespace,

View File

@@ -25,6 +25,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
encrypted BYTEA NOT NULL DEFAULT '\x',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
@@ -36,6 +37,11 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
EXCEPTION WHEN OTHERS THEN NULL;
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.
-- After migration, old plaintext rows will have their JSONB data
-- 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_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)
@@ -87,6 +113,47 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
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.
/// Returns None if not yet initialized.
pub async fn load_argon2_salt(pool: &PgPool) -> Result<Option<Vec<u8>>> {

View File

@@ -282,6 +282,112 @@ EXAMPLES:
#[command(subcommand)]
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)]
@@ -445,6 +551,89 @@ async fn main() -> Result<()> {
)
.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(())

View File

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