use anyhow::Result; use serde_json::{Map, Value, json}; use sqlx::PgPool; use uuid::Uuid; use super::add::{ collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path, parse_kv, remove_path, }; use crate::crypto; use crate::db; use crate::models::EntryRow; use crate::output::{OutputMode, print_json}; 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 \ FROM entries \ 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 entry state before modifying. if let Err(e) = db::snapshot_entry_history( &mut tx, db::EntrySnapshotParams { entry_id: row.id, namespace: args.namespace, kind: args.kind, name: args.name, version: row.version, action: "update", tags: &row.tags, metadata: &row.metadata, }, ) .await { tracing::warn!(error = %e, "failed to snapshot entry 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); // CAS update of the entry row. let result = sqlx::query( "UPDATE entries \ SET tags = $1, metadata = $2, version = version + 1, updated_at = NOW() \ WHERE id = $3 AND version = $4", ) .bind(&tags) .bind(&metadata) .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 new_version = row.version + 1; // ── Update secret fields ────────────────────────────────────────────────── for entry in args.secret_entries { let (path, field_value) = parse_kv(entry)?; // For nested paths (e.g. credentials:type), flatten into dot-separated names // and treat the sub-value as the individual field to store. let flat = flatten_json_fields("", &{ let mut m = Map::new(); insert_path(&mut m, &path, field_value)?; Value::Object(m) }); for (field_name, fv) in &flat { let encrypted = crypto::encrypt_json(master_key, fv)?; // Snapshot existing field before replacing. #[derive(sqlx::FromRow)] struct ExistingField { id: Uuid, encrypted: Vec, } let existing_field: Option = sqlx::query_as( "SELECT id, encrypted \ FROM secrets WHERE entry_id = $1 AND field_name = $2", ) .bind(row.id) .bind(field_name) .fetch_optional(&mut *tx) .await?; if let Some(ef) = &existing_field && let Err(e) = db::snapshot_secret_history( &mut tx, db::SecretSnapshotParams { entry_id: row.id, secret_id: ef.id, entry_version: row.version, field_name, encrypted: &ef.encrypted, action: "update", }, ) .await { tracing::warn!(error = %e, "failed to snapshot secret field history"); } sqlx::query( "INSERT INTO secrets (entry_id, field_name, encrypted) \ VALUES ($1, $2, $3) \ ON CONFLICT (entry_id, field_name) DO UPDATE SET \ encrypted = EXCLUDED.encrypted, \ version = secrets.version + 1, \ updated_at = NOW()", ) .bind(row.id) .bind(field_name) .bind(&encrypted) .execute(&mut *tx) .await?; } } // ── Remove secret fields ────────────────────────────────────────────────── for key in args.remove_secrets { let path = parse_key_path(key)?; // Dot-join the path to match flattened field_name storage. let field_name = path.join("."); // Snapshot before delete. #[derive(sqlx::FromRow)] struct FieldToDelete { id: Uuid, encrypted: Vec, } let field: Option = sqlx::query_as( "SELECT id, encrypted \ FROM secrets WHERE entry_id = $1 AND field_name = $2", ) .bind(row.id) .bind(&field_name) .fetch_optional(&mut *tx) .await?; if let Some(f) = field { if let Err(e) = db::snapshot_secret_history( &mut tx, db::SecretSnapshotParams { entry_id: row.id, secret_id: f.id, entry_version: new_version, field_name: &field_name, encrypted: &f.encrypted, action: "delete", }, ) .await { tracing::warn!(error = %e, "failed to snapshot secret field history before delete"); } sqlx::query("DELETE FROM secrets WHERE id = $1") .bind(f.id) .execute(&mut *tx) .await?; } } 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 | OutputMode::JsonCompact => { print_json(&result_json, &args.output)?; } _ => { 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(()) }