From c6fb4577346e3f391e6be2acd98c4893901de4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=9D=BE?= Date: Fri, 3 Apr 2026 17:37:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(nn):=20entry=E2=80=93secret=20N:N,=20uniqu?= =?UTF-8?q?e=20secret=20names,=20web=20unlink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump secrets-mcp to 0.3.8 (tag 0.3.7 already used). - Junction table entry_secrets; secrets user-scoped with type - Per-user unique secrets.name; link_secret_names on add - Manual migrations + migrate script; MCP/tool and Web updates Made-with: Cursor --- .gitignore | 3 +- Cargo.lock | 2 +- crates/secrets-core/src/db.rs | 52 ++- crates/secrets-core/src/models.rs | 9 +- crates/secrets-core/src/service/add.rs | 347 ++++++++++++++++-- crates/secrets-core/src/service/delete.rs | 95 +++-- crates/secrets-core/src/service/env_map.rs | 6 +- crates/secrets-core/src/service/export.rs | 2 +- crates/secrets-core/src/service/get_secret.rs | 8 +- crates/secrets-core/src/service/import.rs | 1 + crates/secrets-core/src/service/rollback.rs | 73 +--- crates/secrets-core/src/service/search.rs | 51 ++- crates/secrets-core/src/service/update.rs | 69 ++-- crates/secrets-mcp/Cargo.toml | 2 +- crates/secrets-mcp/src/tools.rs | 46 ++- crates/secrets-mcp/src/web.rs | 131 ++++++- crates/secrets-mcp/templates/entries.html | 130 ++++++- migrations/001_nn_schema.sql | 126 +++++++ migrations/002_data_cleanup.sql | 67 ++++ scripts/migrate-db-prod-to-nn-test.sh | 81 ++++ 20 files changed, 1103 insertions(+), 198 deletions(-) create mode 100644 migrations/001_nn_schema.sql create mode 100644 migrations/002_data_cleanup.sql create mode 100755 scripts/migrate-db-prod-to-nn-test.sh diff --git a/.gitignore b/.gitignore index cab6270..00776a9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ .cursor/ # Google OAuth 下载的 JSON 凭据文件 client_secret_*.apps.googleusercontent.com.json -*.pem \ No newline at end of file +*.pem +tmp/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 959e305..fb51fec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1968,7 +1968,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.3.7" +version = "0.3.8" dependencies = [ "anyhow", "askama", diff --git a/crates/secrets-core/src/db.rs b/crates/secrets-core/src/db.rs index 5c150b9..72304c3 100644 --- a/crates/secrets-core/src/db.rs +++ b/crates/secrets-core/src/db.rs @@ -83,16 +83,30 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { -- ── secrets: one row per encrypted field ───────────────────────────────── CREATE TABLE IF NOT EXISTS secrets ( id UUID PRIMARY KEY DEFAULT uuidv7(), - entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, - field_name VARCHAR(256) NOT NULL, + user_id UUID, + name VARCHAR(256) NOT NULL, + type VARCHAR(64) NOT NULL DEFAULT 'text', encrypted BYTEA NOT NULL DEFAULT '\x', version BIGINT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(entry_id, field_name) + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); - CREATE INDEX IF NOT EXISTS idx_secrets_entry_id ON secrets(entry_id); + CREATE INDEX IF NOT EXISTS idx_secrets_user_id ON secrets(user_id) WHERE user_id IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_unique_user_name + ON secrets(user_id, name) WHERE user_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name); + CREATE INDEX IF NOT EXISTS idx_secrets_type ON secrets(type); + + -- ── entry_secrets: N:N relation ──────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS entry_secrets ( + entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + secret_id UUID NOT NULL REFERENCES secrets(id) ON DELETE CASCADE, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY(entry_id, secret_id) + ); + CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id); -- ── audit_log: append-only operation log ───────────────────────────────── CREATE TABLE IF NOT EXISTS audit_log ( @@ -141,17 +155,13 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { -- ── secrets_history: field-level snapshot ──────────────────────────────── CREATE TABLE IF NOT EXISTS secrets_history ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - entry_id UUID NOT NULL, secret_id UUID NOT NULL, - entry_version BIGINT NOT NULL, - field_name VARCHAR(256) NOT NULL, + name VARCHAR(256) NOT NULL, encrypted BYTEA NOT NULL DEFAULT '\x', action VARCHAR(16) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); - CREATE INDEX IF NOT EXISTS idx_secrets_history_entry_id - ON secrets_history(entry_id, entry_version DESC); CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id ON secrets_history(secret_id); @@ -210,6 +220,16 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { END IF; END $$; + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_secrets_user_id' + ) THEN + ALTER TABLE secrets + ADD CONSTRAINT fk_secrets_user_id + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + END IF; + END $$; + DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'fk_audit_log_user_id' @@ -499,10 +519,8 @@ pub async fn snapshot_entry_history( // ── Secret field-level history snapshot ────────────────────────────────────── pub struct SecretSnapshotParams<'a> { - pub entry_id: uuid::Uuid, pub secret_id: uuid::Uuid, - pub entry_version: i64, - pub field_name: &'a str, + pub name: &'a str, pub encrypted: &'a [u8], pub action: &'a str, } @@ -513,13 +531,11 @@ pub async fn snapshot_secret_history( ) -> Result<()> { sqlx::query( "INSERT INTO secrets_history \ - (entry_id, secret_id, entry_version, field_name, encrypted, action) \ - VALUES ($1, $2, $3, $4, $5, $6)", + (secret_id, name, encrypted, action) \ + VALUES ($1, $2, $3, $4)", ) - .bind(p.entry_id) .bind(p.secret_id) - .bind(p.entry_version) - .bind(p.field_name) + .bind(p.name) .bind(p.encrypted) .bind(p.action) .execute(&mut **tx) diff --git a/crates/secrets-core/src/models.rs b/crates/secrets-core/src/models.rs index da31efa..42c924d 100644 --- a/crates/secrets-core/src/models.rs +++ b/crates/secrets-core/src/models.rs @@ -27,8 +27,11 @@ pub struct Entry { #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct SecretField { pub id: Uuid, - pub entry_id: Uuid, - pub field_name: String, + pub user_id: Option, + pub name: String, + #[serde(rename = "type")] + #[sqlx(rename = "type")] + pub secret_type: String, /// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag pub encrypted: Vec, pub version: i64, @@ -83,7 +86,7 @@ impl From<&EntryWriteRow> for EntryRow { #[derive(Debug, sqlx::FromRow)] pub struct SecretFieldRow { pub id: Uuid, - pub field_name: String, + pub name: String, pub encrypted: Vec, } diff --git a/crates/secrets-core/src/service/add.rs b/crates/secrets-core/src/service/add.rs index cfc754e..65b77cf 100644 --- a/crates/secrets-core/src/service/add.rs +++ b/crates/secrets-core/src/service/add.rs @@ -1,6 +1,7 @@ use anyhow::Result; use serde_json::{Map, Value}; use sqlx::PgPool; +use std::collections::{BTreeSet, HashSet}; use std::fs; use uuid::Uuid; @@ -176,6 +177,7 @@ pub struct AddParams<'a> { pub tags: &'a [String], pub meta_entries: &'a [String], pub secret_entries: &'a [String], + pub link_secret_names: &'a [String], /// Optional user_id for multi-user isolation (None = single-user CLI mode) pub user_id: Option, } @@ -185,6 +187,11 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> let secret_json = build_json(params.secret_entries)?; let meta_keys = collect_key_paths(params.meta_entries)?; let secret_keys = collect_key_paths(params.secret_entries)?; + let flat_fields = flatten_json_fields("", &secret_json); + let new_secret_names: BTreeSet = + flat_fields.iter().map(|(name, _)| name.clone()).collect(); + let link_secret_names = + validate_link_secret_names(params.link_secret_names, &new_secret_names)?; let mut tx = pool.begin().await?; @@ -279,10 +286,11 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> .await? }; - let new_entry_version: i64 = sqlx::query_scalar("SELECT version FROM entries WHERE id = $1") - .bind(entry_id) - .fetch_one(&mut *tx) - .await?; + let current_entry_version: i64 = + sqlx::query_scalar("SELECT version FROM entries WHERE id = $1") + .bind(entry_id) + .fetch_one(&mut *tx) + .await?; if existing.is_none() && let Err(e) = db::snapshot_entry_history( @@ -293,7 +301,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> folder: params.folder, entry_type: params.entry_type, name: params.name, - version: new_entry_version, + version: current_entry_version, action: "create", tags: params.tags, metadata: &metadata, @@ -308,23 +316,25 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> #[derive(sqlx::FromRow)] struct ExistingField { id: Uuid, - field_name: String, + name: String, encrypted: Vec, } - let existing_fields: Vec = - sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1") - .bind(entry_id) - .fetch_all(&mut *tx) - .await?; + let existing_fields: 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?; for f in &existing_fields { if let Err(e) = db::snapshot_secret_history( &mut tx, db::SecretSnapshotParams { - entry_id, secret_id: f.id, - entry_version: new_entry_version - 1, - field_name: &f.field_name, + name: &f.name, encrypted: &f.encrypted, action: "add", }, @@ -335,21 +345,68 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> } } - sqlx::query("DELETE FROM secrets WHERE entry_id = $1") + sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1") .bind(entry_id) .execute(&mut *tx) .await?; + + sqlx::query( + "DELETE FROM secrets s \ + WHERE NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", + ) + .execute(&mut *tx) + .await?; + } + + for (field_name, field_value) in &flat_fields { + let encrypted = crypto::encrypt_json(master_key, field_value)?; + let secret_id: Uuid = sqlx::query_scalar( + "INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id", + ) + .bind(params.user_id) + .bind(field_name) + .bind(infer_secret_type(field_name)) + .bind(&encrypted) + .fetch_one(&mut *tx) + .await?; + sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)") + .bind(entry_id) + .bind(secret_id) + .execute(&mut *tx) + .await?; } - let flat_fields = flatten_json_fields("", &secret_json); - for (field_name, field_value) in &flat_fields { - let encrypted = crypto::encrypt_json(master_key, field_value)?; - sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)") - .bind(entry_id) - .bind(field_name) - .bind(&encrypted) - .execute(&mut *tx) - .await?; + for link_name in &link_secret_names { + let secret_ids: Vec = if let Some(uid) = params.user_id { + sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2") + .bind(uid) + .bind(link_name) + .fetch_all(&mut *tx) + .await? + } else { + sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1") + .bind(link_name) + .fetch_all(&mut *tx) + .await? + }; + + match secret_ids.len() { + 0 => anyhow::bail!("Not found: secret named '{}'", link_name), + 1 => { + sqlx::query( + "INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(entry_id) + .bind(secret_ids[0]) + .execute(&mut *tx) + .await?; + } + n => anyhow::bail!( + "Ambiguous: {} secrets named '{}' found. Please deduplicate names first.", + n, + link_name + ), + } } crate::audit::log_tx( @@ -379,9 +436,56 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> }) } +pub(crate) fn infer_secret_type(name: &str) -> &'static str { + match name { + "ssh_key" => "pem", + "password" => "password", + "phone" | "phone_2" => "phone", + "webhook_url" | "address" => "url", + "access_key_id" + | "access_key_secret" + | "global_api_key" + | "api_key" + | "secret_key" + | "personal_access_token" + | "runner_token" + | "GOOGLE_CLIENT_ID" + | "GOOGLE_CLIENT_SECRET" => "token", + _ => "text", + } +} + +fn validate_link_secret_names( + link_secret_names: &[String], + new_secret_names: &BTreeSet, +) -> Result> { + let mut deduped = Vec::new(); + let mut seen = HashSet::new(); + + for raw in link_secret_names { + let trimmed = raw.trim(); + if trimmed.is_empty() { + anyhow::bail!("link_secret_names contains an empty name"); + } + if new_secret_names.contains(trimmed) { + anyhow::bail!( + "Conflict: secret '{}' is provided both in secrets/secrets_obj and link_secret_names", + trimmed + ); + } + if seen.insert(trimmed.to_string()) { + deduped.push(trimmed.to_string()); + } + } + + Ok(deduped) +} + #[cfg(test)] mod tests { use super::*; + use sqlx::PgPool; + use std::collections::BTreeSet; #[test] fn parse_nested_file_shorthand() { @@ -410,4 +514,199 @@ mod tests { assert_eq!(fields[1].0, "credentials.type"); assert_eq!(fields[2].0, "username"); } + + #[test] + fn validate_link_secret_names_conflict_with_new_secret() { + let mut new_names = BTreeSet::new(); + new_names.insert("password".to_string()); + let err = validate_link_secret_names(&[String::from("password")], &new_names) + .expect_err("must fail on overlap"); + assert!( + err.to_string() + .contains("provided both in secrets/secrets_obj and link_secret_names") + ); + } + + #[test] + fn validate_link_secret_names_dedup_and_trim() { + let names = vec![ + " shared_key ".to_string(), + "shared_key".to_string(), + "runner_token".to_string(), + ]; + let deduped = validate_link_secret_names(&names, &BTreeSet::new()).unwrap(); + assert_eq!(deduped, vec!["shared_key", "runner_token"]); + } + + async fn maybe_test_pool() -> Option { + let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else { + eprintln!("skip add linkage tests: SECRETS_DATABASE_URL is not set"); + return None; + }; + let Ok(pool) = PgPool::connect(&url).await else { + eprintln!("skip add linkage tests: cannot connect to database"); + return None; + }; + if let Err(e) = crate::db::migrate(&pool).await { + eprintln!("skip add linkage tests: migrate failed: {e}"); + return None; + } + Some(pool) + } + + async fn cleanup_test_rows(pool: &PgPool, marker: &str) -> Result<()> { + sqlx::query( + "DELETE FROM entries WHERE user_id IS NULL AND (name LIKE $1 OR folder LIKE $1)", + ) + .bind(format!("%{marker}%")) + .execute(pool) + .await?; + sqlx::query( + "DELETE FROM secrets WHERE user_id IS NULL AND name LIKE $1 \ + AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = secrets.id)", + ) + .bind(format!("%{marker}%")) + .execute(pool) + .await?; + Ok(()) + } + + #[tokio::test] + async fn add_links_existing_secret_by_unique_name() -> Result<()> { + let Some(pool) = maybe_test_pool().await else { + return Ok(()); + }; + let suffix = Uuid::from_u128(rand::random()).to_string(); + let marker = format!("link_unique_{}", &suffix[..8]); + let secret_name = format!("{}_secret", marker); + let entry_name = format!("{}_entry", marker); + + cleanup_test_rows(&pool, &marker).await?; + + let secret_id: Uuid = sqlx::query_scalar( + "INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2) RETURNING id", + ) + .bind(&secret_name) + .bind(vec![1_u8, 2, 3]) + .fetch_one(&pool) + .await?; + + run( + &pool, + AddParams { + name: &entry_name, + folder: &marker, + entry_type: "service", + notes: "", + tags: &[], + meta_entries: &[], + secret_entries: &[], + link_secret_names: std::slice::from_ref(&secret_name), + user_id: None, + }, + &[0_u8; 32], + ) + .await?; + + let linked: bool = sqlx::query_scalar( + "SELECT EXISTS( \ + SELECT 1 FROM entry_secrets es \ + JOIN entries e ON e.id = es.entry_id \ + WHERE e.user_id IS NULL AND e.name = $1 AND es.secret_id = $2 \ + )", + ) + .bind(&entry_name) + .bind(secret_id) + .fetch_one(&pool) + .await?; + assert!(linked); + + cleanup_test_rows(&pool, &marker).await?; + Ok(()) + } + + #[tokio::test] + async fn add_link_secret_name_not_found_fails() -> Result<()> { + let Some(pool) = maybe_test_pool().await else { + return Ok(()); + }; + let suffix = Uuid::from_u128(rand::random()).to_string(); + let marker = format!("link_missing_{}", &suffix[..8]); + let secret_name = format!("{}_secret", marker); + let entry_name = format!("{}_entry", marker); + + cleanup_test_rows(&pool, &marker).await?; + + let err = run( + &pool, + AddParams { + name: &entry_name, + folder: &marker, + entry_type: "service", + notes: "", + tags: &[], + meta_entries: &[], + secret_entries: &[], + link_secret_names: std::slice::from_ref(&secret_name), + user_id: None, + }, + &[0_u8; 32], + ) + .await + .expect_err("must fail when linked secret is not found"); + assert!(err.to_string().contains("Not found: secret named")); + + cleanup_test_rows(&pool, &marker).await?; + Ok(()) + } + + #[tokio::test] + async fn add_link_secret_name_ambiguous_fails() -> Result<()> { + let Some(pool) = maybe_test_pool().await else { + return Ok(()); + }; + let suffix = Uuid::from_u128(rand::random()).to_string(); + let marker = format!("link_amb_{}", &suffix[..8]); + let secret_name = format!("{}_dup_secret", marker); + let entry_name = format!("{}_entry", marker); + + cleanup_test_rows(&pool, &marker).await?; + + sqlx::query( + "INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)", + ) + .bind(&secret_name) + .bind(vec![1_u8]) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)", + ) + .bind(&secret_name) + .bind(vec![2_u8]) + .execute(&pool) + .await?; + + let err = run( + &pool, + AddParams { + name: &entry_name, + folder: &marker, + entry_type: "service", + notes: "", + tags: &[], + meta_entries: &[], + secret_entries: &[], + link_secret_names: std::slice::from_ref(&secret_name), + user_id: None, + }, + &[0_u8; 32], + ) + .await + .expect_err("must fail on ambiguous linked secret name"); + assert!(err.to_string().contains("Ambiguous:")); + + cleanup_test_rows(&pool, &marker).await?; + Ok(()) + } } diff --git a/crates/secrets-core/src/service/delete.rs b/crates/secrets-core/src/service/delete.rs index 15159f3..b162cc4 100644 --- a/crates/secrets-core/src/service/delete.rs +++ b/crates/secrets-core/src/service/delete.rs @@ -130,20 +130,20 @@ async fn migrate_key_refs_if_needed( let owner = &refs[0]; let owner_path = ref_path(owner); - let key_fields: Vec = - sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1") - .bind(key_row.id) - .fetch_all(&mut **tx) - .await?; + let key_fields: 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(key_row.id) + .fetch_all(&mut **tx) + .await?; for f in &key_fields { - sqlx::query( - "INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3) \ - ON CONFLICT (entry_id, field_name) DO NOTHING", - ) + sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING") .bind(owner.id) - .bind(&f.field_name) - .bind(&f.encrypted) + .bind(f.id) .execute(&mut **tx) .await?; } @@ -200,7 +200,7 @@ async fn migrate_key_refs_if_needed( Ok(refs.iter().map(ref_label).collect()) } -/// Delete a single entry by id (multi-tenant: `user_id` must match). Cascades `secrets` via FK. +/// Delete a single entry by id (multi-tenant: `user_id` must match). pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result { let mut tx = pool.begin().await?; let row: Option = sqlx::query_as( @@ -615,20 +615,22 @@ async fn snapshot_and_delete( tracing::warn!(error = %e, "failed to snapshot entry history before delete"); } - let fields: Vec = - sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1") - .bind(row.id) - .fetch_all(&mut **tx) - .await?; + let fields: 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(row.id) + .fetch_all(&mut **tx) + .await?; for f in &fields { if let Err(e) = db::snapshot_secret_history( tx, db::SecretSnapshotParams { - entry_id: row.id, secret_id: f.id, - entry_version: row.version, - field_name: &f.field_name, + name: &f.name, encrypted: &f.encrypted, action: "delete", }, @@ -644,6 +646,13 @@ async fn snapshot_and_delete( .execute(&mut **tx) .await?; + sqlx::query( + "DELETE FROM secrets s \ + WHERE NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", + ) + .execute(&mut **tx) + .await?; + Ok(()) } @@ -692,6 +701,31 @@ mod tests { Ok(()) } + async fn insert_secret_for_entry( + pool: &PgPool, + user_id: Uuid, + entry_id: Uuid, + name: &str, + secret_type: &str, + encrypted: Vec, + ) -> Result<()> { + let secret_id: Uuid = sqlx::query_scalar( + "INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id", + ) + .bind(user_id) + .bind(name) + .bind(secret_type) + .bind(encrypted) + .fetch_one(pool) + .await?; + sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)") + .bind(entry_id) + .bind(secret_id) + .execute(pool) + .await?; + Ok(()) + } + #[tokio::test] async fn delete_shared_key_dry_run_reports_migration_without_writes() -> Result<()> { let Some(pool) = maybe_test_pool().await else { @@ -713,12 +747,7 @@ mod tests { json!({}), ) .await?; - sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)") - .bind(key_id) - .bind("pem") - .bind(vec![1_u8, 2, 3]) - .execute(&pool) - .await?; + insert_secret_for_entry(&pool, user_id, key_id, "pem", "pem", vec![1_u8, 2, 3]).await?; insert_entry( &pool, @@ -808,12 +837,7 @@ mod tests { json!({}), ) .await?; - sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)") - .bind(key_id) - .bind("pem") - .bind(vec![7_u8, 8, 9]) - .execute(&pool) - .await?; + insert_secret_for_entry(&pool, user_id, key_id, "pem", "pem", vec![7_u8, 8, 9]).await?; // owner candidate (sorted first by folder) insert_entry( @@ -893,7 +917,12 @@ mod tests { assert_eq!(ref_c_key_ref.as_deref(), Some("afolder/srv-a")); let owner_has_copied: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM secrets WHERE entry_id = $1 AND field_name = 'pem')", + "SELECT EXISTS( \ + SELECT 1 \ + FROM entry_secrets es \ + JOIN secrets s ON s.id = es.secret_id \ + WHERE es.entry_id = $1 AND s.name = 'pem' \ + )", ) .bind(ref_a) .fetch_one(&pool) diff --git a/crates/secrets-core/src/service/env_map.rs b/crates/secrets-core/src/service/env_map.rs index 0111fa6..37cd1d2 100644 --- a/crates/secrets-core/src/service/env_map.rs +++ b/crates/secrets-core/src/service/env_map.rs @@ -51,7 +51,7 @@ async fn build_entry_env_map( } else { all_fields .iter() - .filter(|f| only_fields.contains(&f.field_name)) + .filter(|f| only_fields.contains(&f.name)) .collect() }; @@ -63,7 +63,7 @@ async fn build_entry_env_map( let key = format!( "{}_{}", effective_prefix, - f.field_name.to_uppercase().replace(['-', '.'], "_") + f.name.to_uppercase().replace(['-', '.'], "_") ); map.insert(key, json_to_env_string(&decrypted)); } @@ -97,7 +97,7 @@ async fn build_entry_env_map( let key_var = format!( "{}_{}", key_prefix, - f.field_name.to_uppercase().replace(['-', '.'], "_") + f.name.to_uppercase().replace(['-', '.'], "_") ); map.insert(key_var, json_to_env_string(&decrypted)); } diff --git a/crates/secrets-core/src/service/export.rs b/crates/secrets-core/src/service/export.rs index 5d5fd3b..f1d4f72 100644 --- a/crates/secrets-core/src/service/export.rs +++ b/crates/secrets-core/src/service/export.rs @@ -55,7 +55,7 @@ pub async fn export( let mut map = BTreeMap::new(); for f in fields { let decrypted = crypto::decrypt_json(mk, &f.encrypted)?; - map.insert(f.field_name.clone(), decrypted); + map.insert(f.name.clone(), decrypted); } Some(map) } diff --git a/crates/secrets-core/src/service/get_secret.rs b/crates/secrets-core/src/service/get_secret.rs index 4cf9726..d27b83b 100644 --- a/crates/secrets-core/src/service/get_secret.rs +++ b/crates/secrets-core/src/service/get_secret.rs @@ -25,7 +25,7 @@ pub async fn get_secret_field( let field = fields .iter() - .find(|f| f.field_name == field_name) + .find(|f| f.name == field_name) .ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?; crypto::decrypt_json(master_key, &field.encrypted) @@ -49,7 +49,7 @@ pub async fn get_all_secrets( let mut map = HashMap::new(); for f in fields { let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?; - map.insert(f.field_name.clone(), decrypted); + map.insert(f.name.clone(), decrypted); } Ok(map) } @@ -72,7 +72,7 @@ pub async fn get_secret_field_by_id( let field = fields .iter() - .find(|f| f.field_name == field_name) + .find(|f| f.name == field_name) .ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?; crypto::decrypt_json(master_key, &field.encrypted) @@ -98,7 +98,7 @@ pub async fn get_all_secrets_by_id( let mut map = HashMap::new(); for f in fields { let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?; - map.insert(f.field_name.clone(), decrypted); + map.insert(f.name.clone(), decrypted); } Ok(map) } diff --git a/crates/secrets-core/src/service/import.rs b/crates/secrets-core/src/service/import.rs index 6b723bf..77c5318 100644 --- a/crates/secrets-core/src/service/import.rs +++ b/crates/secrets-core/src/service/import.rs @@ -85,6 +85,7 @@ pub async fn run( tags: &entry.tags, meta_entries: &meta_entries, secret_entries: &secret_entries, + link_secret_names: &[], user_id: params.user_id, }, master_key, diff --git a/crates/secrets-core/src/service/rollback.rs b/crates/secrets-core/src/service/rollback.rs index d562e7d..425cfcb 100644 --- a/crates/secrets-core/src/service/rollback.rs +++ b/crates/secrets-core/src/service/rollback.rs @@ -3,7 +3,6 @@ use serde_json::Value; use sqlx::PgPool; use uuid::Uuid; -use crate::crypto; use crate::db; #[derive(Debug, serde::Serialize)] @@ -27,7 +26,6 @@ pub async fn run( ) -> Result { #[derive(sqlx::FromRow)] struct EntryHistoryRow { - entry_id: Uuid, folder: String, #[sqlx(rename = "type")] entry_type: String, @@ -122,7 +120,7 @@ pub async fn run( let snap: Option = if let Some(ver) = to_version { sqlx::query_as( - "SELECT entry_id, folder, type, version, action, tags, metadata \ + "SELECT folder, type, version, action, tags, metadata \ FROM entries_history \ WHERE entry_id = $1 AND version = $2 ORDER BY id DESC LIMIT 1", ) @@ -132,7 +130,7 @@ pub async fn run( .await? } else { sqlx::query_as( - "SELECT entry_id, folder, type, version, action, tags, metadata \ + "SELECT folder, type, version, action, tags, metadata \ FROM entries_history \ WHERE entry_id = $1 ORDER BY id DESC LIMIT 1", ) @@ -151,33 +149,7 @@ pub async fn run( ) })?; - #[derive(sqlx::FromRow)] - struct SecretHistoryRow { - field_name: String, - encrypted: Vec, - action: String, - } - - let field_snaps: Vec = sqlx::query_as( - "SELECT field_name, encrypted, action FROM secrets_history \ - WHERE entry_id = $1 AND entry_version = $2 ORDER BY field_name", - ) - .bind(snap.entry_id) - .bind(snap.version) - .fetch_all(pool) - .await?; - - for f in &field_snaps { - if f.action != "delete" && !f.encrypted.is_empty() { - crypto::decrypt_json(master_key, &f.encrypted).map_err(|e| { - anyhow::anyhow!( - "Cannot decrypt snapshot for field '{}': {}", - f.field_name, - e - ) - })?; - } - } + let _ = master_key; let mut tx = pool.begin().await?; @@ -226,23 +198,25 @@ pub async fn run( #[derive(sqlx::FromRow)] struct LiveField { id: Uuid, - field_name: String, + name: String, encrypted: Vec, } - let live_fields: Vec = - sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1") - .bind(lr.id) - .fetch_all(&mut *tx) - .await?; + let live_fields: 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(lr.id) + .fetch_all(&mut *tx) + .await?; for f in &live_fields { if let Err(e) = db::snapshot_secret_history( &mut tx, db::SecretSnapshotParams { - entry_id: lr.id, secret_id: f.id, - entry_version: lr.version, - field_name: &f.field_name, + name: &f.name, encrypted: &f.encrypted, action: "rollback", }, @@ -297,22 +271,9 @@ pub async fn run( } }; - sqlx::query("DELETE FROM secrets WHERE entry_id = $1") - .bind(live_entry_id) - .execute(&mut *tx) - .await?; - - for f in &field_snaps { - if f.action == "delete" { - continue; - } - sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)") - .bind(live_entry_id) - .bind(&f.field_name) - .bind(&f.encrypted) - .execute(&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; crate::audit::log_tx( &mut tx, diff --git a/crates/secrets-core/src/service/search.rs b/crates/secrets-core/src/service/search.rs index 2a8e06f..0e9cdbf 100644 --- a/crates/secrets-core/src/service/search.rs +++ b/crates/secrets-core/src/service/search.rs @@ -210,8 +210,12 @@ pub async fn fetch_secret_schemas( if entry_ids.is_empty() { return Ok(HashMap::new()); } - let fields: Vec = sqlx::query_as( - "SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name", + let fields: Vec = sqlx::query_as( + "SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \ + FROM entry_secrets es \ + JOIN secrets s ON s.id = es.secret_id \ + WHERE es.entry_id = ANY($1) \ + ORDER BY es.entry_id, es.sort_order, s.name", ) .bind(entry_ids) .fetch_all(pool) @@ -219,7 +223,8 @@ pub async fn fetch_secret_schemas( let mut map: HashMap> = HashMap::new(); for f in fields { - map.entry(f.entry_id).or_default().push(f); + let entry_id = f.entry_id; + map.entry(entry_id).or_default().push(f.secret()); } Ok(map) } @@ -232,8 +237,12 @@ pub async fn fetch_secrets_for_entries( if entry_ids.is_empty() { return Ok(HashMap::new()); } - let fields: Vec = sqlx::query_as( - "SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name", + let fields: Vec = sqlx::query_as( + "SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \ + FROM entry_secrets es \ + JOIN secrets s ON s.id = es.secret_id \ + WHERE es.entry_id = ANY($1) \ + ORDER BY es.entry_id, es.sort_order, s.name", ) .bind(entry_ids) .fetch_all(pool) @@ -241,7 +250,8 @@ pub async fn fetch_secrets_for_entries( let mut map: HashMap> = HashMap::new(); for f in fields { - map.entry(f.entry_id).or_default().push(f); + let entry_id = f.entry_id; + map.entry(entry_id).or_default().push(f.secret()); } Ok(map) } @@ -345,3 +355,32 @@ impl From for Entry { } } } + +#[derive(sqlx::FromRow)] +struct EntrySecretRow { + entry_id: Uuid, + id: Uuid, + user_id: Option, + name: String, + #[sqlx(rename = "type")] + secret_type: String, + encrypted: Vec, + version: i64, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +impl EntrySecretRow { + fn secret(self) -> SecretField { + SecretField { + id: self.id, + user_id: self.user_id, + name: self.name, + secret_type: self.secret_type, + encrypted: self.encrypted, + version: self.version, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} diff --git a/crates/secrets-core/src/service/update.rs b/crates/secrets-core/src/service/update.rs index 1426983..63e9b11 100644 --- a/crates/secrets-core/src/service/update.rs +++ b/crates/secrets-core/src/service/update.rs @@ -7,8 +7,8 @@ use crate::crypto; use crate::db; use crate::models::{EntryRow, EntryWriteRow}; use crate::service::add::{ - collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path, - parse_kv, remove_path, + collect_field_paths, collect_key_paths, flatten_json_fields, infer_secret_type, insert_path, + parse_key_path, parse_kv, remove_path, }; #[derive(Debug, serde::Serialize)] @@ -173,8 +173,6 @@ pub async fn run( ); } - let new_version = row.version + 1; - for entry in params.secret_entries { let (path, field_value) = parse_kv(entry)?; let flat = flatten_json_fields("", &{ @@ -192,7 +190,10 @@ pub async fn run( encrypted: Vec, } let ef: Option = sqlx::query_as( - "SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2", + "SELECT s.id, s.encrypted \ + FROM entry_secrets es \ + JOIN secrets s ON s.id = es.secret_id \ + WHERE es.entry_id = $1 AND s.name = $2", ) .bind(row.id) .bind(field_name) @@ -203,10 +204,8 @@ pub async fn run( && 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, + name: field_name, encrypted: &ef.encrypted, action: "update", }, @@ -216,16 +215,30 @@ pub async fn run( 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?; + if let Some(ef) = ef { + sqlx::query( + "UPDATE secrets SET encrypted = $1, version = version + 1, updated_at = NOW() WHERE id = $2", + ) + .bind(&encrypted) + .bind(ef.id) + .execute(&mut *tx) + .await?; + } else { + let secret_id: Uuid = sqlx::query_scalar( + "INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id", + ) + .bind(params.user_id) + .bind(field_name) + .bind(infer_secret_type(field_name)) + .bind(&encrypted) + .fetch_one(&mut *tx) + .await?; + sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)") + .bind(row.id) + .bind(secret_id) + .execute(&mut *tx) + .await?; + } } } @@ -239,7 +252,10 @@ pub async fn run( encrypted: Vec, } let field: Option = sqlx::query_as( - "SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2", + "SELECT s.id, s.encrypted \ + FROM entry_secrets es \ + JOIN secrets s ON s.id = es.secret_id \ + WHERE es.entry_id = $1 AND s.name = $2", ) .bind(row.id) .bind(&field_name) @@ -250,10 +266,8 @@ pub async fn run( 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, + name: &field_name, encrypted: &f.encrypted, action: "delete", }, @@ -262,10 +276,19 @@ pub async fn run( { tracing::warn!(error = %e, "failed to snapshot secret field history before delete"); } - sqlx::query("DELETE FROM secrets WHERE id = $1") + sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2") + .bind(row.id) .bind(f.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(f.id) + .execute(&mut *tx) + .await?; } } diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 8034d6e..387d6a7 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.3.7" +version = "0.3.8" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/tools.rs b/crates/secrets-mcp/src/tools.rs index 4842d64..24aa0c0 100644 --- a/crates/secrets-mcp/src/tools.rs +++ b/crates/secrets-mcp/src/tools.rs @@ -225,12 +225,18 @@ struct AddInput { description = "Metadata fields as a JSON object {\"key\": value}. Merged with 'meta' if both provided." )] meta_obj: Option>, - #[schemars(description = "Secret fields as 'key=value' strings")] + #[schemars( + description = "Secret fields as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets." + )] secrets: Option>, #[schemars( - description = "Secret fields as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided." + description = "Secret fields as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address." )] secrets_obj: Option>, + #[schemars( + description = "Link existing secrets by secret name. Names must resolve uniquely under current user." + )] + link_secret_names: Option>, } #[derive(Debug, Deserialize, JsonSchema)] @@ -259,10 +265,12 @@ struct UpdateInput { meta_obj: Option>, #[schemars(description = "Metadata field keys to remove")] remove_meta: Option>, - #[schemars(description = "Secret fields to update/add as 'key=value' strings")] + #[schemars( + description = "Secret fields to update/add as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets." + )] secrets: Option>, #[schemars( - description = "Secret fields to update/add as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided." + description = "Secret fields to update/add as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address." )] secrets_obj: Option>, #[schemars(description = "Secret field keys to remove")] @@ -429,10 +437,20 @@ impl SecretsService { .entries .iter() .map(|e| { - let schema: Vec<&str> = result + let schema: Vec = result .secret_schemas .get(&e.id) - .map(|f| f.iter().map(|s| s.field_name.as_str()).collect()) + .map(|f| { + f.iter() + .map(|s| { + serde_json::json!({ + "id": s.id, + "name": s.name, + "type": s.secret_type, + }) + }) + .collect() + }) .unwrap_or_default(); serde_json::json!({ "id": e.id, @@ -517,10 +535,20 @@ impl SecretsService { "updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), }) } else { - let schema: Vec<&str> = result + let schema: Vec = result .secret_schemas .get(&e.id) - .map(|f| f.iter().map(|s| s.field_name.as_str()).collect()) + .map(|f| { + f.iter() + .map(|s| { + serde_json::json!({ + "id": s.id, + "name": s.name, + "type": s.secret_type, + }) + }) + .collect() + }) .unwrap_or_default(); serde_json::json!({ "id": e.id, @@ -639,6 +667,7 @@ impl SecretsService { if let Some(obj) = input.secrets_obj { secrets.extend(map_to_kv_strings(obj)); } + let link_secret_names = input.link_secret_names.unwrap_or_default(); let folder = input.folder.as_deref().unwrap_or(""); let entry_type = input.entry_type.as_deref().unwrap_or(""); let notes = input.notes.as_deref().unwrap_or(""); @@ -653,6 +682,7 @@ impl SecretsService { tags: &tags, meta_entries: &meta, secret_entries: &secrets, + link_secret_names: &link_secret_names, user_id: Some(user_id), }, &user_key, diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index c319ee9..f536fa5 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -21,7 +21,7 @@ use secrets_core::service::{ api_key::{ensure_api_key, regenerate_api_key}, audit_log::list_for_user, delete::delete_by_id, - search::{SearchParams, count_entries, list_entries}, + search::{SearchParams, count_entries, fetch_secret_schemas, list_entries}, update::{UpdateEntryFieldsByIdParams, update_fields_by_id}, user::{ OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id, @@ -105,10 +105,17 @@ struct EntryListItemView { notes: String, tags: String, metadata: String, + secrets: Vec, /// RFC3339 UTC for `