diff --git a/crates/secrets-core/src/db.rs b/crates/secrets-core/src/db.rs index 8d92947..4000f13 100644 --- a/crates/secrets-core/src/db.rs +++ b/crates/secrets-core/src/db.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use anyhow::{Context, Result}; -use serde_json::Value; +use serde_json::{Map, Value}; use sqlx::PgPool; use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; @@ -562,4 +562,75 @@ pub async fn snapshot_secret_history( Ok(()) } +pub const ENTRY_HISTORY_SECRETS_KEY: &str = "__secrets_snapshot_v1"; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct EntrySecretSnapshot { + pub name: String, + #[serde(rename = "type")] + pub secret_type: String, + pub encrypted_hex: String, +} + +pub async fn metadata_with_secret_snapshot( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + entry_id: uuid::Uuid, + metadata: &Value, +) -> Result { + #[derive(sqlx::FromRow)] + struct Row { + name: String, + #[sqlx(rename = "type")] + secret_type: String, + encrypted: Vec, + } + + let rows: Vec = sqlx::query_as( + "SELECT s.name, s.type, s.encrypted \ + FROM entry_secrets es \ + JOIN secrets s ON s.id = es.secret_id \ + WHERE es.entry_id = $1 \ + ORDER BY s.name ASC", + ) + .bind(entry_id) + .fetch_all(&mut **tx) + .await?; + + let snapshots: Vec = rows + .into_iter() + .map(|r| EntrySecretSnapshot { + name: r.name, + secret_type: r.secret_type, + encrypted_hex: ::hex::encode(r.encrypted), + }) + .collect(); + + let mut merged = match metadata.clone() { + Value::Object(obj) => obj, + _ => Map::new(), + }; + merged.insert( + ENTRY_HISTORY_SECRETS_KEY.to_string(), + serde_json::to_value(snapshots)?, + ); + Ok(Value::Object(merged)) +} + +pub fn strip_secret_snapshot_from_metadata(metadata: &Value) -> Value { + let mut m = match metadata.clone() { + Value::Object(obj) => obj, + _ => return metadata.clone(), + }; + m.remove(ENTRY_HISTORY_SECRETS_KEY); + Value::Object(m) +} + +pub fn entry_secret_snapshot_from_metadata(metadata: &Value) -> Option> { + let Value::Object(map) = metadata else { + return None; + }; + let raw = map.get(ENTRY_HISTORY_SECRETS_KEY)?; + serde_json::from_value(raw.clone()).ok() +} + // ── DB helpers ──────────────────────────────────────────────────────────────── diff --git a/crates/secrets-core/src/service/add.rs b/crates/secrets-core/src/service/add.rs index a8e50e4..edeff42 100644 --- a/crates/secrets-core/src/service/add.rs +++ b/crates/secrets-core/src/service/add.rs @@ -223,8 +223,16 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> .await? }; - if let Some(ref ex) = existing - && let Err(e) = db::snapshot_entry_history( + if let Some(ref ex) = existing { + let history_metadata = + match db::metadata_with_secret_snapshot(&mut tx, ex.id, &ex.metadata).await { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); + ex.metadata.clone() + } + }; + if let Err(e) = db::snapshot_entry_history( &mut tx, db::EntrySnapshotParams { entry_id: ex.id, @@ -235,12 +243,13 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> version: ex.version, action: "add", tags: &ex.tags, - metadata: &ex.metadata, + metadata: &history_metadata, }, ) .await - { - tracing::warn!(error = %e, "failed to snapshot entry history before upsert"); + { + tracing::warn!(error = %e, "failed to snapshot entry history before upsert"); + } } // Upsert the entry row. On conflict (existing entry with same user_id+folder+name), @@ -303,26 +312,6 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> .fetch_one(&mut *tx) .await?; - if existing.is_none() - && let Err(e) = db::snapshot_entry_history( - &mut tx, - db::EntrySnapshotParams { - entry_id, - user_id: params.user_id, - folder: params.folder, - entry_type, - name: params.name, - version: current_entry_version, - action: "create", - tags: params.tags, - metadata: &metadata, - }, - ) - .await - { - tracing::warn!(error = %e, "failed to snapshot entry history on create"); - } - if existing.is_some() { #[derive(sqlx::FromRow)] struct ExistingField { @@ -432,6 +421,35 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> } } + if existing.is_none() { + let history_metadata = + match db::metadata_with_secret_snapshot(&mut tx, entry_id, &metadata).await { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); + metadata.clone() + } + }; + if let Err(e) = db::snapshot_entry_history( + &mut tx, + db::EntrySnapshotParams { + entry_id, + user_id: params.user_id, + folder: params.folder, + entry_type, + name: params.name, + version: current_entry_version, + action: "create", + tags: params.tags, + metadata: &history_metadata, + }, + ) + .await + { + tracing::warn!(error = %e, "failed to snapshot entry history on create"); + } + } + crate::audit::log_tx( &mut tx, params.user_id, diff --git a/crates/secrets-core/src/service/delete.rs b/crates/secrets-core/src/service/delete.rs index d0db2a6..04737f8 100644 --- a/crates/secrets-core/src/service/delete.rs +++ b/crates/secrets-core/src/service/delete.rs @@ -441,6 +441,15 @@ async fn snapshot_and_delete( row: &EntryRow, user_id: Option, ) -> Result<()> { + let history_metadata = match db::metadata_with_secret_snapshot(tx, row.id, &row.metadata).await + { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); + row.metadata.clone() + } + }; + if let Err(e) = db::snapshot_entry_history( tx, db::EntrySnapshotParams { @@ -452,7 +461,7 @@ async fn snapshot_and_delete( version: row.version, action: "delete", tags: &row.tags, - metadata: &row.metadata, + metadata: &history_metadata, }, ) .await diff --git a/crates/secrets-core/src/service/history.rs b/crates/secrets-core/src/service/history.rs index 97514b8..460eb68 100644 --- a/crates/secrets-core/src/service/history.rs +++ b/crates/secrets-core/src/service/history.rs @@ -31,8 +31,11 @@ pub async fn run( let entry = resolve_entry(pool, name, folder, user_id).await?; let rows: Vec = sqlx::query_as( - "SELECT version, action, created_at FROM entries_history \ - WHERE entry_id = $1 ORDER BY id DESC LIMIT $2", + "SELECT DISTINCT ON (version) version, action, created_at \ + FROM entries_history \ + WHERE entry_id = $1 \ + ORDER BY version DESC, id DESC \ + LIMIT $2", ) .bind(entry.id) .bind(limit as i64) diff --git a/crates/secrets-core/src/service/rollback.rs b/crates/secrets-core/src/service/rollback.rs index 1767724..d053db8 100644 --- a/crates/secrets-core/src/service/rollback.rs +++ b/crates/secrets-core/src/service/rollback.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use anyhow::Result; use serde_json::Value; use sqlx::PgPool; @@ -122,7 +124,7 @@ pub async fn run( sqlx::query_as( "SELECT folder, type, version, action, tags, metadata \ FROM entries_history \ - WHERE entry_id = $1 AND version = $2 ORDER BY id DESC LIMIT 1", + WHERE entry_id = $1 AND version = $2 ORDER BY id ASC LIMIT 1", ) .bind(entry_id) .bind(ver) @@ -149,6 +151,9 @@ pub async fn run( ) })?; + let snap_secret_snapshot = db::entry_secret_snapshot_from_metadata(&snap.metadata); + let snap_metadata = db::strip_secret_snapshot_from_metadata(&snap.metadata); + let _ = master_key; let mut tx = pool.begin().await?; @@ -176,6 +181,15 @@ pub async fn run( .await?; let live_entry_id = if let Some(ref lr) = live { + let history_metadata = + match db::metadata_with_secret_snapshot(&mut tx, lr.id, &lr.metadata).await { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); + lr.metadata.clone() + } + }; + if let Err(e) = db::snapshot_entry_history( &mut tx, db::EntrySnapshotParams { @@ -187,7 +201,7 @@ pub async fn run( version: lr.version, action: "rollback", tags: &lr.tags, - metadata: &lr.metadata, + metadata: &history_metadata, }, ) .await @@ -234,7 +248,7 @@ pub async fn run( .bind(&snap.folder) .bind(&snap.entry_type) .bind(&snap.tags) - .bind(&snap.metadata) + .bind(&snap_metadata) .bind(lr.id) .execute(&mut *tx) .await?; @@ -252,7 +266,7 @@ pub async fn run( .bind(&snap.entry_type) .bind(name) .bind(&snap.tags) - .bind(&snap.metadata) + .bind(&snap_metadata) .bind(snap.version) .fetch_one(&mut *tx) .await? @@ -266,16 +280,16 @@ pub async fn run( .bind(&snap.entry_type) .bind(name) .bind(&snap.tags) - .bind(&snap.metadata) + .bind(&snap_metadata) .bind(snap.version) .fetch_one(&mut *tx) .await? } }; - // In N:N mode, rollback restores entry metadata/tags only. - // Secret snapshots are kept for audit but secret linkage/content is not rewritten here. - let _ = live_entry_id; + if let Some(secret_snapshot) = snap_secret_snapshot { + restore_entry_secrets(&mut tx, live_entry_id, user_id, &secret_snapshot).await?; + } crate::audit::log_tx( &mut tx, @@ -300,3 +314,144 @@ pub async fn run( restored_version: snap.version, }) } + +async fn restore_entry_secrets( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + entry_id: Uuid, + user_id: Option, + snapshot: &[db::EntrySecretSnapshot], +) -> Result<()> { + #[derive(sqlx::FromRow)] + struct LinkedSecret { + id: Uuid, + name: String, + encrypted: Vec, + } + + let linked: Vec = sqlx::query_as( + "SELECT s.id, s.name, s.encrypted \ + FROM entry_secrets es \ + JOIN secrets s ON s.id = es.secret_id \ + WHERE es.entry_id = $1", + ) + .bind(entry_id) + .fetch_all(&mut **tx) + .await?; + + let target_names: HashSet<&str> = snapshot.iter().map(|s| s.name.as_str()).collect(); + + for s in &linked { + if target_names.contains(s.name.as_str()) { + continue; + } + if let Err(e) = db::snapshot_secret_history( + tx, + db::SecretSnapshotParams { + secret_id: s.id, + name: &s.name, + encrypted: &s.encrypted, + action: "rollback", + }, + ) + .await + { + tracing::warn!(error = %e, "failed to snapshot secret before rollback unlink"); + } + + sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2") + .bind(entry_id) + .bind(s.id) + .execute(&mut **tx) + .await?; + + sqlx::query( + "DELETE FROM secrets s \ + WHERE s.id = $1 \ + AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", + ) + .bind(s.id) + .execute(&mut **tx) + .await?; + } + + for snap in snapshot { + let encrypted = ::hex::decode(&snap.encrypted_hex).map_err(|e| { + anyhow::anyhow!("invalid secret snapshot data for '{}': {}", snap.name, e) + })?; + + #[derive(sqlx::FromRow)] + struct ExistingSecret { + id: Uuid, + encrypted: Vec, + } + + let existing: Option = if let Some(uid) = user_id { + sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id = $1 AND name = $2") + .bind(uid) + .bind(&snap.name) + .fetch_optional(&mut **tx) + .await? + } else { + sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id IS NULL AND name = $1") + .bind(&snap.name) + .fetch_optional(&mut **tx) + .await? + }; + + let secret_id = if let Some(ex) = existing { + if ex.encrypted != encrypted + && let Err(e) = db::snapshot_secret_history( + tx, + db::SecretSnapshotParams { + secret_id: ex.id, + name: &snap.name, + encrypted: &ex.encrypted, + action: "rollback", + }, + ) + .await + { + tracing::warn!(error = %e, "failed to snapshot secret before rollback restore"); + } + sqlx::query( + "UPDATE secrets SET type = $1, encrypted = $2, version = version + 1, updated_at = NOW() \ + WHERE id = $3", + ) + .bind(&snap.secret_type) + .bind(&encrypted) + .bind(ex.id) + .execute(&mut **tx) + .await?; + ex.id + } else if let Some(uid) = user_id { + sqlx::query_scalar( + "INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id", + ) + .bind(uid) + .bind(&snap.name) + .bind(&snap.secret_type) + .bind(&encrypted) + .fetch_one(&mut **tx) + .await? + } else { + sqlx::query_scalar( + "INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, $2, $3) RETURNING id", + ) + .bind(&snap.name) + .bind(&snap.secret_type) + .bind(&encrypted) + .fetch_one(&mut **tx) + .await? + }; + + sqlx::query( + "INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(entry_id) + .bind(secret_id) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} diff --git a/crates/secrets-core/src/service/update.rs b/crates/secrets-core/src/service/update.rs index d53a461..f2f760b 100644 --- a/crates/secrets-core/src/service/update.rs +++ b/crates/secrets-core/src/service/update.rs @@ -112,6 +112,15 @@ pub async fn run( } }; + let history_metadata = + match db::metadata_with_secret_snapshot(&mut tx, row.id, &row.metadata).await { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); + row.metadata.clone() + } + }; + if let Err(e) = db::snapshot_entry_history( &mut tx, db::EntrySnapshotParams { @@ -123,7 +132,7 @@ pub async fn run( version: row.version, action: "update", tags: &row.tags, - metadata: &row.metadata, + metadata: &history_metadata, }, ) .await @@ -481,6 +490,15 @@ pub async fn update_fields_by_id( } }; + let history_metadata = + match db::metadata_with_secret_snapshot(&mut tx, row.id, &row.metadata).await { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); + row.metadata.clone() + } + }; + if let Err(e) = db::snapshot_entry_history( &mut tx, db::EntrySnapshotParams { @@ -492,7 +510,7 @@ pub async fn update_fields_by_id( version: row.version, action: "update", tags: &row.tags, - metadata: &row.metadata, + metadata: &history_metadata, }, ) .await