feat: entry update links existing secrets (link_secret_names)
- 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:
@@ -13,11 +13,65 @@ use rmcp::{
|
||||
tool, tool_handler, tool_router,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Deserializer, de};
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Serde helpers for numeric parameters that may arrive as strings ──────────
|
||||
|
||||
mod deser {
|
||||
use super::*;
|
||||
|
||||
/// Deserialize a value that may come as a JSON number or a JSON string.
|
||||
pub fn option_u32_from_string<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum NumOrStr {
|
||||
Num(u32),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
match Option::<NumOrStr>::deserialize(deserializer)? {
|
||||
None => Ok(None),
|
||||
Some(NumOrStr::Num(n)) => Ok(Some(n)),
|
||||
Some(NumOrStr::Str(s)) => {
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
s.parse::<u32>().map(Some).map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize an i64 that may come as a JSON number or a JSON string.
|
||||
pub fn option_i64_from_string<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum NumOrStr {
|
||||
Num(i64),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
match Option::<NumOrStr>::deserialize(deserializer)? {
|
||||
None => Ok(None),
|
||||
Some(NumOrStr::Num(n)) => Ok(Some(n)),
|
||||
Some(NumOrStr::Str(s)) => {
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
s.parse::<i64>().map(Some).map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use secrets_core::models::ExportFormat;
|
||||
use secrets_core::service::{
|
||||
add::{AddParams, run as svc_add},
|
||||
@@ -188,6 +242,7 @@ struct FindInput {
|
||||
#[schemars(description = "Tag filters (all must match)")]
|
||||
tags: Option<Vec<String>>,
|
||||
#[schemars(description = "Max results (default 20)")]
|
||||
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -215,8 +270,10 @@ struct SearchInput {
|
||||
#[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")]
|
||||
sort: Option<String>,
|
||||
#[schemars(description = "Max results (default 20)")]
|
||||
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
|
||||
limit: Option<u32>,
|
||||
#[schemars(description = "Pagination offset (default 0)")]
|
||||
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
|
||||
offset: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -307,6 +364,14 @@ struct UpdateInput {
|
||||
secret_types: Option<Map<String, Value>>,
|
||||
#[schemars(description = "Secret field keys to remove")]
|
||||
remove_secrets: Option<Vec<String>>,
|
||||
#[schemars(
|
||||
description = "Link existing secrets by name to this entry. Names must resolve uniquely under current user."
|
||||
)]
|
||||
link_secret_names: Option<Vec<String>>,
|
||||
#[schemars(
|
||||
description = "Unlink secrets by name from this entry. Orphaned secrets are auto-deleted."
|
||||
)]
|
||||
unlink_secret_names: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -341,6 +406,7 @@ struct HistoryInput {
|
||||
)]
|
||||
id: Option<String>,
|
||||
#[schemars(description = "Max history entries to return (default 20)")]
|
||||
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -357,6 +423,7 @@ struct RollbackInput {
|
||||
)]
|
||||
id: Option<String>,
|
||||
#[schemars(description = "Target version number. Omit to restore the most recent snapshot.")]
|
||||
#[serde(default, deserialize_with = "deser::option_i64_from_string")]
|
||||
to_version: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -643,7 +710,7 @@ impl SecretsService {
|
||||
let value =
|
||||
get_secret_field_by_id(&self.pool, entry_id, field_name, &user_key, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_get", None, e))?;
|
||||
.map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?;
|
||||
|
||||
tracing::info!(
|
||||
tool = "secrets_get",
|
||||
@@ -657,7 +724,7 @@ impl SecretsService {
|
||||
} else {
|
||||
let secrets = get_all_secrets_by_id(&self.pool, entry_id, &user_key, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_get", None, e))?;
|
||||
.map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?;
|
||||
|
||||
tracing::info!(
|
||||
tool = "secrets_get",
|
||||
@@ -793,6 +860,8 @@ impl SecretsService {
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
|
||||
.collect();
|
||||
let remove_secrets = input.remove_secrets.unwrap_or_default();
|
||||
let link_secret_names = input.link_secret_names.unwrap_or_default();
|
||||
let unlink_secret_names = input.unlink_secret_names.unwrap_or_default();
|
||||
|
||||
let result = svc_update(
|
||||
&self.pool,
|
||||
@@ -807,6 +876,8 @@ impl SecretsService {
|
||||
secret_entries: &secrets,
|
||||
secret_types: &secret_types_map,
|
||||
remove_secrets: &remove_secrets,
|
||||
link_secret_names: &link_secret_names,
|
||||
unlink_secret_names: &unlink_secret_names,
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
&user_key,
|
||||
@@ -1048,7 +1119,7 @@ impl SecretsService {
|
||||
Some(&user_key),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?;
|
||||
.map_err(|e| mcp_err_from_anyhow("secrets_export", Some(user_id), e))?;
|
||||
|
||||
let fmt = format.parse::<ExportFormat>().map_err(|e| {
|
||||
tracing::warn!(
|
||||
@@ -1064,7 +1135,7 @@ impl SecretsService {
|
||||
})?;
|
||||
let serialized = fmt
|
||||
.serialize(&data)
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?;
|
||||
.map_err(|e| mcp_err_from_anyhow("secrets_export", Some(user_id), e))?;
|
||||
|
||||
tracing::info!(
|
||||
tool = "secrets_export",
|
||||
@@ -1115,7 +1186,7 @@ impl SecretsService {
|
||||
Some(user_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_env_map", Some(user_id), e))?;
|
||||
.map_err(|e| mcp_err_from_anyhow("secrets_env_map", Some(user_id), e))?;
|
||||
|
||||
let entry_count = env_map.len();
|
||||
tracing::info!(
|
||||
|
||||
Reference in New Issue
Block a user