feat: entry update links existing secrets (link_secret_names)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s

- secrets-core: update flow validates and applies secret links
- secrets-mcp: MCP tool params and UI for managing links on edit
- Align errors and templates; minor crypto/.gitignore tweaks

Made-with: Cursor
This commit is contained in:
voson
2026-04-04 20:30:32 +08:00
parent 4a1654c820
commit 0ffb81e57f
10 changed files with 321 additions and 19 deletions

View File

@@ -25,6 +25,8 @@ pub struct UpdateResult {
pub remove_meta: Vec<String>,
pub secret_keys: Vec<String>,
pub remove_secrets: Vec<String>,
pub linked_secrets: Vec<String>,
pub unlinked_secrets: Vec<String>,
}
pub struct UpdateParams<'a> {
@@ -39,6 +41,8 @@ pub struct UpdateParams<'a> {
pub secret_entries: &'a [String],
pub secret_types: &'a std::collections::HashMap<String, String>,
pub remove_secrets: &'a [String],
pub link_secret_names: &'a [String],
pub unlink_secret_names: &'a [String],
pub user_id: Option<Uuid>,
}
@@ -295,6 +299,101 @@ pub async fn run(
}
}
// Link existing secrets by name
let mut linked_secrets = Vec::new();
for link_name in params.link_secret_names {
let link_name = link_name.trim();
if link_name.is_empty() {
anyhow::bail!("link_secret_names contains an empty name");
}
let secret_ids: Vec<Uuid> = 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(row.id)
.bind(secret_ids[0])
.execute(&mut *tx)
.await?;
linked_secrets.push(link_name.to_string());
}
n => anyhow::bail!(
"Ambiguous: {} secrets named '{}' found. Please deduplicate names first.",
n,
link_name
),
}
}
// Unlink secrets by name
let mut unlinked_secrets = Vec::new();
for unlink_name in params.unlink_secret_names {
let unlink_name = unlink_name.trim();
if unlink_name.is_empty() {
continue;
}
#[derive(sqlx::FromRow)]
struct SecretToUnlink {
id: Uuid,
encrypted: Vec<u8>,
}
let secret: Option<SecretToUnlink> = sqlx::query_as(
"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(unlink_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(s) = secret {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
secret_id: s.id,
name: unlink_name,
encrypted: &s.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history before unlink");
}
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
.bind(row.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?;
unlinked_secrets.push(unlink_name.to_string());
}
}
let meta_keys = collect_key_paths(params.meta_entries)?;
let remove_meta_keys = collect_field_paths(params.remove_meta)?;
let secret_keys = collect_key_paths(params.secret_entries)?;
@@ -314,6 +413,8 @@ pub async fn run(
"remove_meta": remove_meta_keys,
"secret_keys": secret_keys,
"remove_secrets": remove_secret_keys,
"linked_secrets": linked_secrets,
"unlinked_secrets": unlinked_secrets,
}),
)
.await;
@@ -330,6 +431,8 @@ pub async fn run(
remove_meta: remove_meta_keys,
secret_keys,
remove_secrets: remove_secret_keys,
linked_secrets,
unlinked_secrets,
})
}