fix(secrets-mcp 0.5.20): code review plan — export secret types, env map, rollback, API key, MCP tools, web session & validation

- Export/import: optional secret_types map; AddResult includes entry_id
- env_map: dot→__ segment encoding; collision errors
- rollback: FOR UPDATE + txn-consistent snapshot; restore name from history
- regenerate_api_key: rows_affected guard
- MCP: find count propagates errors; add uses entry_id for relations; rollback no encryption key
- Web: load_session_user_strict + JSON handlers key_version; PATCH length limits
- Tests: ExportEntry serde, env segment
This commit is contained in:
voson
2026-04-11 17:10:16 +08:00
parent 2c7dbf890b
commit d772066210
13 changed files with 266 additions and 141 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets-mcp"
version = "0.5.19"
version = "0.5.20"
edition.workspace = true
[[bin]]

View File

@@ -345,15 +345,6 @@ impl SecretsService {
Self::extract_enc_key(ctx)
}
/// Require both user_id and encryption key (header only, no arg fallback).
fn require_user_and_key(
ctx: &RequestContext<RoleServer>,
) -> Result<(Uuid, [u8; 32]), rmcp::ErrorData> {
let user_id = Self::require_user_id(ctx)?;
let key = Self::extract_enc_key(ctx)?;
Ok((user_id, key))
}
/// Require both user_id and encryption key, preferring an explicit argument
/// value over the X-Encryption-Key header.
fn require_user_and_key_or_arg(
@@ -801,10 +792,7 @@ impl SecretsService {
let total_count = secrets_core::service::search::count_entries(&self.pool, &count_params)
.await
.inspect_err(
|e| tracing::warn!(tool = "secrets_find", error = %e, "count_entries failed"),
)
.unwrap_or(0);
.map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
let relation_map = get_relations_for_entries(
&self.pool,
&result
@@ -1135,11 +1123,8 @@ impl SecretsService {
.await
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
let created_entry = resolve_entry(&self.pool, &input.name, Some(folder), Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_add", Some(user_id), e))?;
for parent_id in parent_ids {
add_parent_relation(&self.pool, parent_id, created_entry.id, Some(user_id))
add_parent_relation(&self.pool, parent_id, result.entry_id, Some(user_id))
.await
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
}
@@ -1420,7 +1405,7 @@ impl SecretsService {
}
#[tool(
description = "Rollback an entry to a previous version. Requires X-Encryption-Key header. \
description = "Rollback an entry to a previous version. Requires Bearer API key only (no encryption key). \
Omit to_version to restore the most recent snapshot. \
Optionally pass 'id' (from secrets_find) to target directly.",
annotations(title = "Rollback Secret Entry", destructive_hint = true)
@@ -1431,7 +1416,7 @@ impl SecretsService {
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, _user_key) = Self::require_user_and_key(&ctx)?;
let user_id = Self::require_user_id(&ctx)?;
tracing::info!(
tool = "secrets_rollback",
?user_id,

View File

@@ -11,7 +11,7 @@ use secrets_core::service::{
use crate::AppState;
use super::{SESSION_KEY_VERSION, current_user_id, render_template, require_valid_user};
use super::{SESSION_KEY_VERSION, load_session_user_strict, render_template, require_valid_user};
#[derive(Template)]
#[template(path = "dashboard.html")]
@@ -92,17 +92,11 @@ pub(super) async fn api_key_salt(
State(state): State<AppState>,
session: Session,
) -> Result<Json<KeySaltResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = get_user_by_id(&state.pool, user_id)
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for key-salt API");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
if user.key_salt.is_none() {
return Ok(Json(KeySaltResponse {
@@ -126,19 +120,14 @@ pub(super) async fn api_key_setup(
session: Session,
Json(body): Json<KeySetupRequest>,
) -> Result<Json<KeySetupResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
// Guard: if a passphrase is already configured, reject and direct to /api/key-change
let user = get_user_by_id(&state.pool, user_id)
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for key-setup guard");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::UNAUTHORIZED)?;
if user.key_salt.is_some() {
tracing::warn!(%user_id, "key-setup called but passphrase already configured; use /api/key-change");
return Err(StatusCode::CONFLICT);
@@ -175,17 +164,12 @@ pub(super) async fn api_key_change(
session: Session,
Json(body): Json<KeyChangeRequest>,
) -> Result<Json<KeySetupResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = get_user_by_id(&state.pool, user_id)
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for key-change");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
// Must have an existing passphrase to change
let existing_key_check = user.key_check.ok_or_else(|| {
@@ -276,9 +260,12 @@ pub(super) async fn api_apikey_get(
State(state): State<AppState>,
session: Session,
) -> Result<Json<ApiKeyResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
let api_key = ensure_api_key(&state.pool, user_id).await.map_err(|e| {
tracing::error!(error = %e, %user_id, "ensure_api_key failed");
@@ -292,9 +279,12 @@ pub(super) async fn api_apikey_regenerate(
State(state): State<AppState>,
session: Session,
) -> Result<Json<ApiKeyResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
let api_key = regenerate_api_key(&state.pool, user_id)
.await

View File

@@ -25,8 +25,8 @@ use secrets_core::service::{
use crate::AppState;
use super::{
ENTRIES_PAGE_LIMIT, UiLang, current_user_id, paginate, render_template, request_ui_lang,
require_valid_user, tr,
ENTRIES_PAGE_LIMIT, UiLang, paginate, render_template, request_ui_lang, require_valid_user,
require_valid_user_json, tr,
};
// ── Template types ────────────────────────────────────────────────────────────
@@ -616,10 +616,8 @@ pub(super) async fn api_entry_patch(
Json(body): Json<EntryPatchBody>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let folder = body.folder.trim();
let entry_type = body.entry_type.trim();
@@ -635,6 +633,39 @@ pub(super) async fn api_entry_patch(
));
}
if folder.chars().count() > crate::validation::MAX_FOLDER_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "folder 长度不能超过 128 个字符", "folder 長度不能超過 128 個字元", "folder must be at most 128 characters") }),
),
));
}
if entry_type.chars().count() > crate::validation::MAX_ENTRY_TYPE_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "type 长度不能超过 64 个字符", "type 長度不能超過 64 個字元", "type must be at most 64 characters") }),
),
));
}
if name.chars().count() > crate::validation::MAX_NAME_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "name 长度不能超过 256 个字符", "name 長度不能超過 256 個字元", "name must be at most 256 characters") }),
),
));
}
if notes.chars().count() > crate::validation::MAX_NOTES_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "notes 长度不能超过 10000 个字符", "notes 長度不能超過 10000 個字元", "notes must be at most 10000 characters") }),
),
));
}
let tags: Vec<String> = body
.tags
.into_iter()
@@ -683,10 +714,8 @@ pub(super) async fn api_entry_options(
Query(q): Query<EntryOptionQuery>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let query =
q.q.as_deref()
@@ -738,10 +767,8 @@ pub(super) async fn api_entry_delete(
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
delete_by_id(&state.pool, entry_id, user_id)
.await
@@ -760,10 +787,8 @@ pub(super) async fn api_trash_restore(
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
restore_deleted_by_id(&state.pool, entry_id, user_id)
.await
@@ -782,10 +807,8 @@ pub(super) async fn api_trash_purge(
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
purge_deleted_by_id(&state.pool, entry_id, user_id)
.await
@@ -818,10 +841,8 @@ pub(super) async fn api_secret_check_name(
Query(params): Query<SecretCheckNameQuery>,
) -> Result<Json<SecretCheckNameResponse>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let name = params.name.trim();
if name.is_empty() {
@@ -914,10 +935,8 @@ pub(super) async fn api_secret_patch(
}
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let name = body.name.as_ref().map(|s| s.trim());
let secret_type = body.secret_type.as_ref().map(|s| s.trim());
@@ -1123,10 +1142,8 @@ pub(super) async fn api_entry_secret_unlink(
}
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let mut tx = state
.pool
@@ -1216,10 +1233,8 @@ pub(super) async fn api_entry_secrets_decrypt(
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let master_key = require_encryption_key(&headers, lang)?;

View File

@@ -1,10 +1,11 @@
use askama::Template;
use axum::{
Router,
Json, Router,
http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, patch, post},
};
use serde_json::json;
use tower_sessions::Session;
use uuid::Uuid;
@@ -34,7 +35,7 @@ const AUDIT_PAGE_LIMIT: i64 = 10;
// ── UI language ───────────────────────────────────────────────────────────────
#[derive(Clone, Copy)]
enum UiLang {
pub(super) enum UiLang {
ZhCn,
ZhTw,
En,
@@ -143,6 +144,71 @@ async fn require_valid_user(
Ok(user)
}
/// `Ok(None)` — unauthenticated or session invalidated (including `key_version` mismatch).
/// `Err(())` — database error loading the user.
pub(super) async fn load_session_user_strict(
pool: &sqlx::PgPool,
session: &Session,
) -> Result<Option<secrets_core::models::User>, ()> {
let Some(user_id) = current_user_id(session).await else {
return Ok(None);
};
let user = match secrets_core::service::user::get_user_by_id(pool, user_id).await {
Err(e) => {
tracing::error!(error = %e, %user_id, "load_session_user_strict: failed to load user");
return Err(());
}
Ok(None) => {
if let Err(e) = session.flush().await {
tracing::warn!(error = %e, "failed to flush stale session");
}
return Ok(None);
}
Ok(Some(u)) => u,
};
let session_kv: Option<i64> = match session.get::<i64>(SESSION_KEY_VERSION).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to read key_version from session; treating as missing");
None
}
};
if let Some(kv) = session_kv
&& kv != user.key_version
{
tracing::info!(%user_id, session_kv = kv, db_kv = user.key_version, "key_version mismatch; invalidating session (API)");
if let Err(e) = session.flush().await {
tracing::warn!(error = %e, "failed to flush outdated session");
}
return Ok(None);
}
Ok(Some(user))
}
/// JSON API equivalent of [`require_valid_user`]: returns `401` with a JSON body instead of redirecting.
pub(super) async fn require_valid_user_json(
pool: &sqlx::PgPool,
session: &Session,
lang: UiLang,
) -> Result<secrets_core::models::User, (StatusCode, Json<serde_json::Value>)> {
match load_session_user_strict(pool, session).await {
Ok(Some(user)) => Ok(user),
Ok(None) => Err((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
)),
Err(()) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") }),
),
)),
}
}
fn request_user_agent(headers: &HeaderMap) -> Option<String> {
headers
.get(header::USER_AGENT)