feat(core): entry 历史附带关联 secret 密文快照,rollback 可恢复 N:N 与密文

- db: metadata_with_secret_snapshot / strip / parse 辅助
- add/update/delete/rollback 在写 entries_history 前合并快照
- rollback: 按历史快照同步 entry_secrets、更新或插入 secrets
- 满足 clippy collapsible_if
This commit is contained in:
voson
2026-04-05 16:00:26 +08:00
parent 63cb3a8216
commit 7c53bfb782
6 changed files with 313 additions and 39 deletions

View File

@@ -1,7 +1,7 @@
use std::str::FromStr; use std::str::FromStr;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde_json::Value; use serde_json::{Map, Value};
use sqlx::PgPool; use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
@@ -562,4 +562,75 @@ pub async fn snapshot_secret_history(
Ok(()) 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<Value> {
#[derive(sqlx::FromRow)]
struct Row {
name: String,
#[sqlx(rename = "type")]
secret_type: String,
encrypted: Vec<u8>,
}
let rows: Vec<Row> = 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<EntrySecretSnapshot> = 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<Vec<EntrySecretSnapshot>> {
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 ──────────────────────────────────────────────────────────────── // ── DB helpers ────────────────────────────────────────────────────────────────

View File

@@ -223,8 +223,16 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
.await? .await?
}; };
if let Some(ref ex) = existing if let Some(ref ex) = existing {
&& let Err(e) = db::snapshot_entry_history( 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, &mut tx,
db::EntrySnapshotParams { db::EntrySnapshotParams {
entry_id: ex.id, entry_id: ex.id,
@@ -235,13 +243,14 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
version: ex.version, version: ex.version,
action: "add", action: "add",
tags: &ex.tags, tags: &ex.tags,
metadata: &ex.metadata, metadata: &history_metadata,
}, },
) )
.await .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), // Upsert the entry row. On conflict (existing entry with same user_id+folder+name),
// the entry columns are replaced wholesale. The old secret associations are torn down // the entry columns are replaced wholesale. The old secret associations are torn down
@@ -303,26 +312,6 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .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() { if existing.is_some() {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct ExistingField { 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( crate::audit::log_tx(
&mut tx, &mut tx,
params.user_id, params.user_id,

View File

@@ -441,6 +441,15 @@ async fn snapshot_and_delete(
row: &EntryRow, row: &EntryRow,
user_id: Option<Uuid>, user_id: Option<Uuid>,
) -> Result<()> { ) -> 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( if let Err(e) = db::snapshot_entry_history(
tx, tx,
db::EntrySnapshotParams { db::EntrySnapshotParams {
@@ -452,7 +461,7 @@ async fn snapshot_and_delete(
version: row.version, version: row.version,
action: "delete", action: "delete",
tags: &row.tags, tags: &row.tags,
metadata: &row.metadata, metadata: &history_metadata,
}, },
) )
.await .await

View File

@@ -31,8 +31,11 @@ pub async fn run(
let entry = resolve_entry(pool, name, folder, user_id).await?; let entry = resolve_entry(pool, name, folder, user_id).await?;
let rows: Vec<Row> = sqlx::query_as( let rows: Vec<Row> = sqlx::query_as(
"SELECT version, action, created_at FROM entries_history \ "SELECT DISTINCT ON (version) version, action, created_at \
WHERE entry_id = $1 ORDER BY id DESC LIMIT $2", FROM entries_history \
WHERE entry_id = $1 \
ORDER BY version DESC, id DESC \
LIMIT $2",
) )
.bind(entry.id) .bind(entry.id)
.bind(limit as i64) .bind(limit as i64)

View File

@@ -1,3 +1,5 @@
use std::collections::HashSet;
use anyhow::Result; use anyhow::Result;
use serde_json::Value; use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
@@ -122,7 +124,7 @@ pub async fn run(
sqlx::query_as( sqlx::query_as(
"SELECT folder, type, version, action, tags, metadata \ "SELECT folder, type, version, action, tags, metadata \
FROM entries_history \ 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(entry_id)
.bind(ver) .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 _ = master_key;
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
@@ -176,6 +181,15 @@ pub async fn run(
.await?; .await?;
let live_entry_id = if let Some(ref lr) = live { 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( if let Err(e) = db::snapshot_entry_history(
&mut tx, &mut tx,
db::EntrySnapshotParams { db::EntrySnapshotParams {
@@ -187,7 +201,7 @@ pub async fn run(
version: lr.version, version: lr.version,
action: "rollback", action: "rollback",
tags: &lr.tags, tags: &lr.tags,
metadata: &lr.metadata, metadata: &history_metadata,
}, },
) )
.await .await
@@ -234,7 +248,7 @@ pub async fn run(
.bind(&snap.folder) .bind(&snap.folder)
.bind(&snap.entry_type) .bind(&snap.entry_type)
.bind(&snap.tags) .bind(&snap.tags)
.bind(&snap.metadata) .bind(&snap_metadata)
.bind(lr.id) .bind(lr.id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
@@ -252,7 +266,7 @@ pub async fn run(
.bind(&snap.entry_type) .bind(&snap.entry_type)
.bind(name) .bind(name)
.bind(&snap.tags) .bind(&snap.tags)
.bind(&snap.metadata) .bind(&snap_metadata)
.bind(snap.version) .bind(snap.version)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await? .await?
@@ -266,16 +280,16 @@ pub async fn run(
.bind(&snap.entry_type) .bind(&snap.entry_type)
.bind(name) .bind(name)
.bind(&snap.tags) .bind(&snap.tags)
.bind(&snap.metadata) .bind(&snap_metadata)
.bind(snap.version) .bind(snap.version)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await? .await?
} }
}; };
// In N:N mode, rollback restores entry metadata/tags only. if let Some(secret_snapshot) = snap_secret_snapshot {
// Secret snapshots are kept for audit but secret linkage/content is not rewritten here. restore_entry_secrets(&mut tx, live_entry_id, user_id, &secret_snapshot).await?;
let _ = live_entry_id; }
crate::audit::log_tx( crate::audit::log_tx(
&mut tx, &mut tx,
@@ -300,3 +314,144 @@ pub async fn run(
restored_version: snap.version, restored_version: snap.version,
}) })
} }
async fn restore_entry_secrets(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
entry_id: Uuid,
user_id: Option<Uuid>,
snapshot: &[db::EntrySecretSnapshot],
) -> Result<()> {
#[derive(sqlx::FromRow)]
struct LinkedSecret {
id: Uuid,
name: String,
encrypted: Vec<u8>,
}
let linked: Vec<LinkedSecret> = 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<u8>,
}
let existing: Option<ExistingSecret> = 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(())
}

View File

@@ -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( if let Err(e) = db::snapshot_entry_history(
&mut tx, &mut tx,
db::EntrySnapshotParams { db::EntrySnapshotParams {
@@ -123,7 +132,7 @@ pub async fn run(
version: row.version, version: row.version,
action: "update", action: "update",
tags: &row.tags, tags: &row.tags,
metadata: &row.metadata, metadata: &history_metadata,
}, },
) )
.await .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( if let Err(e) = db::snapshot_entry_history(
&mut tx, &mut tx,
db::EntrySnapshotParams { db::EntrySnapshotParams {
@@ -492,7 +510,7 @@ pub async fn update_fields_by_id(
version: row.version, version: row.version,
action: "update", action: "update",
tags: &row.tags, tags: &row.tags,
metadata: &row.metadata, metadata: &history_metadata,
}, },
) )
.await .await