From a765dcc4281cbfefb96fa933b71f4568183e1882 Mon Sep 17 00:00:00 2001 From: voson Date: Thu, 19 Mar 2026 10:30:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=200.6.0=20=E2=80=94=20=E4=BA=8B=E5=8A=A1/?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8C=96/=E7=B1=BB=E5=9E=8B=E5=8C=96/inject/?= =?UTF-8?q?run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 写路径事务化:add/update/delete 与 audit 同事务,update CAS 并发保护 - 版本化与回滚:secrets_history 表、version 字段、history/rollback 命令 - 类型化字段:key:= 支持数字、布尔、数组、对象 - 临时 env 模式:inject 输出 KEY=VALUE,run 向子进程注入 - inject/run 至少需一个过滤条件;search -o env 使用 shell_quote;JSON 输出含 version Made-with: Cursor --- AGENTS.md | 134 +++++++++++++- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 1 + scripts/setup-gitea-actions.sh | 14 +- src/audit.rs | 38 +++- src/commands/add.rs | 111 +++++++++--- src/commands/delete.rs | 101 ++++++++--- src/commands/mod.rs | 2 + src/commands/rollback.rs | 245 +++++++++++++++++++++++++ src/commands/run.rs | 143 +++++++++++++++ src/commands/search.rs | 316 ++++++++++++++++++++++----------- src/commands/update.rs | 77 +++++--- src/db.rs | 67 +++++++ src/main.rs | 189 ++++++++++++++++++++ src/models.rs | 1 + 16 files changed, 1247 insertions(+), 196 deletions(-) create mode 100644 src/commands/rollback.rs create mode 100644 src/commands/run.rs diff --git a/AGENTS.md b/AGENTS.md index 8780539..ad7ccba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.toml(database_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:=)存储非字符串类型 +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 目标版本号(省略则恢复最近一次快照) + +# 撤销上次修改(回滚到最近一次快照) +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 变量名前缀 +# -- 执行的命令及参数 + +# 向脚本注入单条记录的 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-`)并上传二进制到 Gitea Release - 通知:飞书 Webhook(`vars.WEBHOOK_URL`) - 所需 secrets/vars:`RELEASE_TOKEN`(Release 上传,Gitea PAT)、`vars.WEBHOOK_URL`(通知,可选) +- **注意**:Gitea Actions 的 Secret/Variable 创建时,`data`/`value` 字段需传入**原始值**,不要使用 base64 编码 ## 环境变量 diff --git a/Cargo.lock b/Cargo.lock index e6a3e9a..95f670e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1473,7 +1473,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrets" -version = "0.5.0" +version = "0.6.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index a254292..f9f661f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets" -version = "0.5.0" +version = "0.6.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 669a170..6f5c956 100644 --- a/README.md +++ b/README.md @@ -202,5 +202,6 @@ scripts/ - `RELEASE_TOKEN`(Secret):Gitea PAT,用于创建 Release 上传二进制 - `WEBHOOK_URL`(Variable):飞书通知,可选 +- **注意**:Secret/Variable 的 `data`/`value` 字段需传入原始值,不要 base64 编码 详见 [AGENTS.md](AGENTS.md)。 diff --git a/scripts/setup-gitea-actions.sh b/scripts/setup-gitea-actions.sh index c0327be..085a516 100755 --- a/scripts/setup-gitea-actions.sh +++ b/scripts/setup-gitea-actions.sh @@ -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 diff --git a/src/audit.rs b/src/audit.rs index fab1008..620388b 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -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, diff --git a/src/commands/add.rs b/src/commands/add.rs index 9801a1b..4dcfe69 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -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:=" entries. +/// - `key=value` → stores the literal string `value` +/// - `key:=` → parses `` 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:= + 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:=", 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 { 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, + metadata: serde_json::Value, + encrypted: Vec, + } + let existing: Option = 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, diff --git a/src/commands/delete.rs b/src/commands/delete.rs index 87ab646..d97b21b 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -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, + metadata: Value, + encrypted: Vec, +} + 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 = 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(()) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3a98606..ee9a8b9 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -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; diff --git a/src/commands/rollback.rs b/src/commands/rollback.rs new file mode 100644 index 0000000..e984cb6 --- /dev/null +++ b/src/commands/rollback.rs @@ -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, + metadata: Value, + encrypted: Vec, +} + +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, + pub output: OutputMode, +} + +pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -> Result<()> { + let snap: Option = 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, + metadata: Value, + encrypted: Vec, + } + let live: Option = 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, + } + + let rows: Vec = 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 = 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 ` to restore)"); + } + } + + Ok(()) +} diff --git a/src/commands/run.rs b/src/commands/run.rs new file mode 100644 index 0000000..711fd9e --- /dev/null +++ b/src/commands/run.rs @@ -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: `_` (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> { + 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 = 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 = 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] -- [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('\'', "'\\''")) +} diff --git a/src/commands/search.rs b/src/commands/search.rs index 523751a..8e93d28 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -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 = 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 = 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> { + 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> { + let mut conditions: Vec = 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 = 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: `_` (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> { + 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 { 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 = if fields .iter() .any(|f| f.starts_with("secret") || f.starts_with("encrypted")) diff --git a/src/commands/update.rs b/src/commands/update.rs index 4bb0b33..3cb6ecf 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -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, metadata: Value, encrypted: Vec, @@ -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 = 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 = 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, diff --git a/src/db.rs b/src/db.rs index fc29d3d..89eb00f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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>> { diff --git a/src/main.rs b/src/main.rs index acc5321..faf6435 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, + }, + + /// 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, + /// Output format: text (default on TTY), json, json-compact + #[arg(short, long = "output")] + output: Option, + }, + + /// 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, + #[arg(long)] + kind: Option, + #[arg(long)] + name: Option, + #[arg(long)] + tag: Vec, + /// 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, + }, + + /// 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, + #[arg(long)] + kind: Option, + #[arg(long)] + name: Option, + #[arg(long)] + tag: Vec, + /// 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, + }, } #[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(()) diff --git a/src/models.rs b/src/models.rs index fb7e35b..7a09c13 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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, + pub version: i64, pub created_at: DateTime, pub updated_at: DateTime, }