Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m53s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m3s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 49s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Made-with: Cursor
224 lines
6.4 KiB
Rust
224 lines
6.4 KiB
Rust
use anyhow::Result;
|
|
use serde_json::{Map, Value, json};
|
|
use sqlx::{FromRow, PgPool};
|
|
use uuid::Uuid;
|
|
|
|
use super::add::{
|
|
collect_field_paths, collect_key_paths, insert_path, parse_key_path, parse_kv, remove_path,
|
|
};
|
|
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>,
|
|
}
|
|
|
|
pub struct UpdateArgs<'a> {
|
|
pub namespace: &'a str,
|
|
pub kind: &'a str,
|
|
pub name: &'a str,
|
|
pub add_tags: &'a [String],
|
|
pub remove_tags: &'a [String],
|
|
pub meta_entries: &'a [String],
|
|
pub remove_meta: &'a [String],
|
|
pub secret_entries: &'a [String],
|
|
pub remove_secrets: &'a [String],
|
|
pub output: OutputMode,
|
|
}
|
|
|
|
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(
|
|
"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?;
|
|
|
|
let row = row.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"Not found: [{}/{}] {}. Use `add` to create it first.",
|
|
args.namespace,
|
|
args.kind,
|
|
args.name
|
|
)
|
|
})?;
|
|
|
|
// 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 {
|
|
if !tags.contains(t) {
|
|
tags.push(t.clone());
|
|
}
|
|
}
|
|
tags.retain(|t| !args.remove_tags.contains(t));
|
|
|
|
// Merge metadata
|
|
let mut meta_map: Map<String, Value> = match row.metadata {
|
|
Value::Object(m) => m,
|
|
_ => Map::new(),
|
|
};
|
|
for entry in args.meta_entries {
|
|
let (path, value) = parse_kv(entry)?;
|
|
insert_path(&mut meta_map, &path, value)?;
|
|
}
|
|
for key in args.remove_meta {
|
|
let path = parse_key_path(key)?;
|
|
remove_path(&mut meta_map, &path)?;
|
|
}
|
|
let metadata = Value::Object(meta_map);
|
|
|
|
// Decrypt existing encrypted blob, merge changes, re-encrypt
|
|
let existing_json = if row.encrypted.is_empty() {
|
|
Value::Object(Map::new())
|
|
} else {
|
|
crypto::decrypt_json(master_key, &row.encrypted)?
|
|
};
|
|
let mut enc_map: Map<String, Value> = match existing_json {
|
|
Value::Object(m) => m,
|
|
_ => Map::new(),
|
|
};
|
|
for entry in args.secret_entries {
|
|
let (path, value) = parse_kv(entry)?;
|
|
insert_path(&mut enc_map, &path, value)?;
|
|
}
|
|
for key in args.remove_secrets {
|
|
let path = parse_key_path(key)?;
|
|
remove_path(&mut enc_map, &path)?;
|
|
}
|
|
let secret_json = Value::Object(enc_map);
|
|
let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?;
|
|
|
|
tracing::debug!(
|
|
namespace = args.namespace,
|
|
kind = args.kind,
|
|
name = args.name,
|
|
"updating record"
|
|
);
|
|
|
|
// 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(row.id)
|
|
.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 = collect_key_paths(args.meta_entries)?;
|
|
let remove_meta_keys = collect_field_paths(args.remove_meta)?;
|
|
let secret_keys = collect_key_paths(args.secret_entries)?;
|
|
let remove_secret_keys = collect_field_paths(args.remove_secrets)?;
|
|
|
|
crate::audit::log_tx(
|
|
&mut tx,
|
|
"update",
|
|
args.namespace,
|
|
args.kind,
|
|
args.name,
|
|
json!({
|
|
"add_tags": args.add_tags,
|
|
"remove_tags": args.remove_tags,
|
|
"meta_keys": meta_keys,
|
|
"remove_meta": remove_meta_keys,
|
|
"secret_keys": secret_keys,
|
|
"remove_secrets": remove_secret_keys,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
tx.commit().await?;
|
|
|
|
let result_json = json!({
|
|
"action": "updated",
|
|
"namespace": args.namespace,
|
|
"kind": args.kind,
|
|
"name": args.name,
|
|
"add_tags": args.add_tags,
|
|
"remove_tags": args.remove_tags,
|
|
"meta_keys": meta_keys,
|
|
"remove_meta": remove_meta_keys,
|
|
"secret_keys": secret_keys,
|
|
"remove_secrets": remove_secret_keys,
|
|
});
|
|
|
|
match args.output {
|
|
OutputMode::Json => {
|
|
println!("{}", serde_json::to_string_pretty(&result_json)?);
|
|
}
|
|
OutputMode::JsonCompact => {
|
|
println!("{}", serde_json::to_string(&result_json)?);
|
|
}
|
|
_ => {
|
|
println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name);
|
|
if !args.add_tags.is_empty() {
|
|
println!(" +tags: {}", args.add_tags.join(", "));
|
|
}
|
|
if !args.remove_tags.is_empty() {
|
|
println!(" -tags: {}", args.remove_tags.join(", "));
|
|
}
|
|
if !args.meta_entries.is_empty() {
|
|
println!(" +metadata: {}", meta_keys.join(", "));
|
|
}
|
|
if !args.remove_meta.is_empty() {
|
|
println!(" -metadata: {}", remove_meta_keys.join(", "));
|
|
}
|
|
if !args.secret_entries.is_empty() {
|
|
println!(" +secrets: {}", secret_keys.join(", "));
|
|
}
|
|
if !args.remove_secrets.is_empty() {
|
|
println!(" -secrets: {}", remove_secret_keys.join(", "));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|