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, metadata: Value, encrypted: Vec, } 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 = 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 = 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 = 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 = 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(()) }