From 7b9dfa7851f0c685986aecf4a2353a565ecd3218 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 28 Jul 2026 11:09:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20tracer=20bullet=20=E2=80=94=20/mcp?= =?UTF-8?q?=20=E7=AB=AF=E7=82=B9=E9=AA=A8=E6=9E=B6=E4=B8=8E=20search=5Fpos?= =?UTF-8?q?ts=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1 探针式实现,接通端到端链路并验证双目标编译: - 依赖:rmcp=3.0.0-beta.3(server/macros/transport-streamable-http-server/ transport-worker)+ aes-gcm,均经 server feature 门控。 - 迁移 017_mcp_tokens:UUID 主键、scope 约束、token_enc 密文 + token_hash 唯一索引(仅未撤销),users.id 外键级联。 - 模型 src/models/mcp_token.rs:TokenScope(read` +//! 提取器在工具内读取(见 `server.rs`)。 + +use axum::body::Body; +use axum::http::{HeaderMap, Request, StatusCode}; +use axum::middleware::Next; +use axum::response::Response; +use sha2::{Digest, Sha256}; + +use crate::db::pool::get_conn; +use crate::models::mcp_token::TokenScope; + +/// Bearer token 前缀(明文形式以 `ygg_` 起头,便于人眼识别与日志脱敏)。 +pub const TOKEN_PREFIX: &str = "ygg_"; + +/// 已认证主体:由中间件注入 request.extensions,工具经 Extension 提取器读取。 +#[derive(Clone, Debug)] +pub struct McpPrincipal { + pub user_id: i32, + pub scope: TokenScope, + /// 令牌 DB id(String,与 model 一致;用于审计/last_used_at 刷新)。 + pub token_id: String, +} + +/// 从 Authorization 头解析 bearer 明文(去前缀后返回完整 token 字符串)。 +/// 非 bearer、缺前缀、解码失败统一返回 None。 +pub fn extract_bearer(headers: &HeaderMap) -> Option { + let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?; + let scheme = raw.strip_prefix("Bearer ")?.trim(); + if scheme.starts_with(TOKEN_PREFIX) { + Some(scheme.to_string()) + } else { + None + } +} + +/// 明文 token → SHA-256 hex(与 mcp_tokens.token_hash 列一致,用于 DB 查找)。 +pub fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +/// MCP 鉴权中间件:无 bearer 或 token 无效 → 401;否则注入 McpPrincipal。 +/// +/// 注意:这是 T1 的最小实现——每请求同步查库 + 同步更新 last_used_at。 +/// T6 会把 last_used_at 刷新改为节流(批量/惰性)以减负,鉴权查询本身保持同步 +/// (这是认证的必要代价,无法乐观)。 +pub async fn mcp_auth_middleware(mut req: Request, next: Next) -> Result { + let token = match extract_bearer(req.headers()) { + Some(t) => t, + None => return Err(StatusCode::UNAUTHORIZED), + }; + match resolve_principal(&token).await { + Some(principal) => { + req.extensions_mut().insert(principal); + Ok(next.run(req).await) + } + None => Err(StatusCode::UNAUTHORIZED), + } +} + +/// 查库解析 token → 主体;未撤销、未过期才返回 Some。 +async fn resolve_principal(token: &str) -> Option { + let hash = hash_token(token); + let client = get_conn().await.ok()?; + // 一次查询取出 + 校验所有条件;row-level 过滤避免 TOCTOU。 + let row = client + .query_opt( + "SELECT id, user_id, scope, expires_at, revoked_at + FROM mcp_tokens + WHERE token_hash = $1 + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > NOW())", + &[&hash], + ) + .await + .ok()??; + + let token_id: uuid::Uuid = row.get(0); + let user_id: i32 = row.get(1); + let scope_str: &str = row.get(2); + let scope = TokenScope::from_db(scope_str)?; + + // best-effort 刷新 last_used_at:失败不影响鉴权(已在 Some 分支)。 + let _ = client + .execute( + "UPDATE mcp_tokens SET last_used_at = NOW() WHERE id = $1", + &[&token_id], + ) + .await; + + Some(McpPrincipal { + user_id, + scope, + token_id: token_id.to_string(), + }) +} diff --git a/src/mcp/crypto.rs b/src/mcp/crypto.rs new file mode 100644 index 0000000..d7caebe --- /dev/null +++ b/src/mcp/crypto.rs @@ -0,0 +1,193 @@ +//! MCP token 静态加密(AES-GCM-256)。 +//! +//! 设计要点: +//! - 明文 bearer token 永不裸存于 DB。存储列 `token_enc` 是 AES-GCM 密文 +//! (nonce ‖ ciphertext ‖ tag)的 hex 编码;管理员可在后台解密重查明文。 +//! - `token_hash`(明文 SHA-256 hex)用于每请求 O(1) 常量查找,见 `auth.rs`。 +//! - 主密钥来自环境变量 `MCP_TOKEN_ENC_KEY`(hex 编码的 32 字节,64 个 hex 字符; +//! 可用 `openssl rand -hex 32` 生成)。缺失或非法时 `mcp_enc_key()` 返回 None, +//! 调用方按「MCP 不可用」降级(拒绝签发/认证),不 panic——符合 AGENTS.md §16。 +//! +//! AES-GCM-256:12 字节随机 nonce(每次加密独立生成),认证标签隐含在密文尾部。 +//! nonce 复用是 AES-GCM 的唯一致命风险;这里每次 `encrypt_token` 都新取 nonce, +//! 且 nonce 与密文一同存储,解密时无需额外恢复。仅用 hex(已是直接依赖)编码, +//! 避免把 base64 由传递依赖提升为直接依赖。 + +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce}; + +/// 从环境读取并解码主密钥;缺失或非法返回 None(降级,不 panic)。 +/// +/// 接受 hex 编码的 32 字节(64 个 hex 字符)。解码后必须正好 32 字节(AES-256)。 +pub fn mcp_enc_key() -> Option<[u8; 32]> { + let raw = std::env::var("MCP_TOKEN_ENC_KEY").ok()?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let bytes = hex::decode(trimmed).ok()?; + if bytes.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Some(arr) + } else { + None + } +} + +/// 加密明文 token,返回 `nonce‖ct‖tag` 的 hex 字符串(存入 `token_enc`)。 +/// +/// 失败仅在不持有有效主密钥时(调用方应在签发前已检查 `mcp_enc_key().is_some()`)。 +pub fn encrypt_token(plaintext: &str) -> Option { + let key_bytes = mcp_enc_key()?; + let cipher = Aes256Gcm::new(Key::::from_slice(&key_bytes)); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); // 12 字节,每次独立 + let ct = cipher.encrypt(&nonce, plaintext.as_bytes()).ok()?; + let mut buf = Vec::with_capacity(nonce.len() + ct.len()); + buf.extend_from_slice(nonce.as_slice()); + buf.extend_from_slice(&ct); + Some(hex::encode(buf)) +} + +/// 解密 `token_enc`(`nonce‖ct‖tag` 的 hex)还原明文 token。 +/// +/// 失败(密钥缺失、hex 非法、密文被篡改、nonce 长度不符)统一返回 None—— +/// 调用方无法区分具体原因,按「该 token 不可解密」处理(等同于失效)。 +pub fn decrypt_token(enc_hex: &str) -> Option { + let key_bytes = mcp_enc_key()?; + let buf = hex::decode(enc_hex).ok()?; + // AES-GCM-256 nonce 固定 12 字节;短于此必然损坏。 + if buf.len() < 12 { + return None; + } + let (nonce_bytes, ct) = buf.split_at(12); + let cipher = Aes256Gcm::new(Key::::from_slice(&key_bytes)); + let nonce = Nonce::from_slice(nonce_bytes); + let pt = cipher.decrypt(nonce, ct).ok()?; + String::from_utf8(pt).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 测试用:把任意 32 字节映射到临时 hex 密钥环境,跑完闭包后还原 env。 + /// 返回闭包的结果,便于 `let enc = with_key(&key, || encrypt_token(...))`。 + /// 串行化(serial_test)避免跨用例污染进程级 env。 + fn with_key R, R>(key: &[u8; 32], body: F) -> R { + let prev = std::env::var("MCP_TOKEN_ENC_KEY").ok(); + std::env::set_var("MCP_TOKEN_ENC_KEY", hex::encode(key)); + // scope guard: 还原 env + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + match &self.0 { + Some(v) => std::env::set_var("MCP_TOKEN_ENC_KEY", v), + None => std::env::remove_var("MCP_TOKEN_ENC_KEY"), + } + } + } + let _r = Restore(prev); + body() + } + + #[test] + #[serial_test::serial] + fn roundtrip_basic() { + let key = [42u8; 32]; + with_key(&key, || { + let enc = encrypt_token("ygg_secret_token_value").expect("encrypt"); + let dec = decrypt_token(&enc).expect("decrypt"); + assert_eq!(dec, "ygg_secret_token_value"); + }); + } + + #[test] + #[serial_test::serial] + fn roundtrip_unicode_and_long() { + let key = [7u8; 32]; + let plaintext = "ygg_".to_string() + &"中文标题-漢字-🔑 ".repeat(500); + with_key(&key, || { + let enc = encrypt_token(&plaintext).expect("encrypt"); + assert_eq!(decrypt_token(&enc).expect("decrypt"), plaintext); + }); + } + + #[test] + #[serial_test::serial] + fn distinct_nonces_per_encryption() { + // 相同明文加密两次,密文必须不同(nonce 随机);都可解回同一明文。 + let key = [9u8; 32]; + with_key(&key, || { + let a = encrypt_token("same").expect("encrypt"); + let b = encrypt_token("same").expect("encrypt"); + assert_ne!(a, b, "nonce must differ → ciphertext differs"); + assert_eq!(decrypt_token(&a).unwrap(), "same"); + assert_eq!(decrypt_token(&b).unwrap(), "same"); + }); + } + + #[test] + #[serial_test::serial] + fn tampered_ciphertext_fails() { + let key = [1u8; 32]; + with_key(&key, || { + let mut enc = encrypt_token("payload").expect("encrypt"); + // 翻转最后一个字节(GCM tag 区域)→ 认证失败 + let mut bytes = hex::decode(&enc).unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 0xff; + enc = hex::encode(&bytes); + assert_eq!(decrypt_token(&enc), None); + }); + } + + #[test] + #[serial_test::serial] + fn missing_key_returns_none() { + std::env::remove_var("MCP_TOKEN_ENC_KEY"); + assert_eq!(mcp_enc_key(), None); + assert_eq!(encrypt_token("x"), None); + assert_eq!(decrypt_token("deadbeef"), None); + } + + #[test] + #[serial_test::serial] + fn wrong_key_cannot_decrypt() { + let enc = { + let key = [1u8; 32]; + with_key(&key, || encrypt_token("secret").unwrap()) + }; + // 换另一个密钥:解密必须失败(认证标签不匹配) + with_key(&[2u8; 32], || { + assert_eq!(decrypt_token(&enc), None); + }); + } + + #[test] + #[serial_test::serial] + fn accepts_hex_encoding() { + let key = [5u8; 32]; + std::env::set_var("MCP_TOKEN_ENC_KEY", hex::encode(key)); + assert_eq!(mcp_enc_key(), Some(key)); + std::env::remove_var("MCP_TOKEN_ENC_KEY"); + } + + #[test] + #[serial_test::serial] + fn rejects_odd_length_hex() { + // 奇数长度 hex 非法 → None + std::env::set_var("MCP_TOKEN_ENC_KEY", "abc"); + assert_eq!(mcp_enc_key(), None); + std::env::remove_var("MCP_TOKEN_ENC_KEY"); + } + + #[test] + #[serial_test::serial] + fn rejects_short_key() { + // 16 字节(32 hex 字符)非 AES-256 密钥,必须拒绝 + std::env::set_var("MCP_TOKEN_ENC_KEY", hex::encode([0u8; 16])); + assert_eq!(mcp_enc_key(), None, "16 字节非 AES-256 密钥,必须拒绝"); + std::env::remove_var("MCP_TOKEN_ENC_KEY"); + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 0000000..c5adaf7 --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,24 @@ +//! MCP(Model Context Protocol)服务器。 +//! +//! 把博客暴露为一个 MCP 服务器,管理员的 AI 客户端(Claude Code / Cursor / Cline) +//! 通过 `POST /mcp`(Streamable HTTP,无状态)以 bearer token 认证,可: +//! - 查询已发布文章作为知识库(read 作用域); +//! - 执行几乎所有后台操作:文章 CRUD、评论、标签、媒体、设置、代码运行器 +//! (write / admin 作用域)。 +//! +//! 传输:官方 `rmcp` crate 的 `StreamableHttpService`,挂载于 axum `/mcp`。 +//! 认证:每请求由 axum 中间件解析 `Authorization: Bearer ygg_...` → 注入 +//! `McpPrincipal` 到 request extensions;工具经 `Extension` +//! 提取器读取,按作用域鉴权。Origin→403 / 协议版本头 / 体积上限由 rmcp 内置。 +//! +//! 仅 server feature 编译;WASM 前端构建不含本模块(无 `#[cfg(not)]` stub 需求, +//! 整个模块用 `#[cfg(feature = "server")]` 门控,前端侧不引用任何 mcp 符号)。 + +#[cfg(feature = "server")] +pub mod auth; +#[cfg(feature = "server")] +pub mod crypto; +#[cfg(feature = "server")] +pub mod router; +#[cfg(feature = "server")] +pub mod server; diff --git a/src/mcp/router.rs b/src/mcp/router.rs new file mode 100644 index 0000000..1ba2f77 --- /dev/null +++ b/src/mcp/router.rs @@ -0,0 +1,47 @@ +//! 构建并返回挂载于 `/mcp` 的 axum 子路由。 +//! +//! 装配: +//! - rmcp `StreamableHttpService`(无状态、JSON 响应、Origin 白名单); +//! - 本 crate 的 `mcp_auth_middleware`(bearer → McpPrincipal,注入 extensions)。 +//! +//! Origin 白名单:优先 `APP_BASE_URL`;否则放空(rmcp 默认对缺 Origin 放行)。 +//! 生产部署 MUST 设置 `APP_BASE_URL`(见 docs/DEPLOYMENT.md),白名单才会生效。 +//! 协议版本头、体积上限(4MiB 默认)由 rmcp 内置,无需此处重复。 + +use axum::middleware::from_fn; +use axum::Router; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; + +use crate::mcp::auth::mcp_auth_middleware; +use crate::mcp::server::YggMcpServer; + +/// 构建 `/mcp` 子路由(供 main.rs 的 `.merge(mcp_route)` 合并)。 +/// +/// 仅在 server feature 下有意义;WASM 构建不会调用本函数。 +pub fn mcp_route() -> Router { + // 计算 Origin 白名单:APP_BASE_URL 规范化后加入;未设置则空(rmcp 对缺 Origin 放行)。 + let allowed_origins: Vec = std::env::var("APP_BASE_URL") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .into_iter() + .collect(); + + let mut config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) // 无状态(SEP-2567) + .with_json_response(true); // 简单工具用 application/json 直回 + if !allowed_origins.is_empty() { + config = config.with_allowed_origins(allowed_origins.iter().map(|s| s.as_str())); + } + + let service = StreamableHttpService::new( + || Ok::<_, std::io::Error>(YggMcpServer), + LocalSessionManager::default().into(), + config, + ); + + Router::new() + .nest_service("/mcp", service) + .layer(from_fn(mcp_auth_middleware)) +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs new file mode 100644 index 0000000..5903797 --- /dev/null +++ b/src/mcp/server.rs @@ -0,0 +1,126 @@ +//! MCP 服务器:rmcp `ServerHandler` 实现,暴露博客工具。 +//! +//! T1(tracer bullet)仅暴露一个工具 `search_posts`,证明: +//! - rmcp handler 经 `tool_router`/`tool_handler` 宏装配成功; +//! - 工具内能经 `Extension` 读取鉴权中间件注入的 `McpPrincipal`; +//! - 作用域鉴权可生效(此处 search_posts 要求 read)。 +//! +//! T3 会扩展为完整 read 工具集 + Resources;T4/T5 扩展 write/admin 工具。 +//! 搜索 SQL 与 `src/api/posts/search.rs` 的 server-fn 一致(pg_trgm word_similarity), +//! T3 会把这段查询抽成共享 helper 供两条路径复用,避免重复维护。 + +use rmcp::handler::server::tool::Extension; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, ContentBlock, TextContent}; +use rmcp::{schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler}; +use serde::Deserialize; + +use crate::mcp::auth::McpPrincipal; +use crate::models::mcp_token::TokenScope; + +/// search_posts 入参。 +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct SearchPostsParams { + /// 搜索关键词(会做 SQL 通配符转义;空串返回空结果)。 + pub query: String, +} + +/// MCP 服务器实例(无状态:每个请求由 service_factory 新建一份)。 +/// +/// 共享状态(DB 连接等)通过 `get_conn()` 全局池获取,无需在实例里持有。 +#[derive(Clone)] +pub struct YggMcpServer; + +#[tool_router] +impl YggMcpServer { + /// 搜索已发布文章(知识库)。要求 read 作用域。 + #[tool(description = "全文搜索已发布文章,作为知识库。返回标题/slug/摘要/标签。")] + async fn search_posts( + &self, + Parameters(SearchPostsParams { query }): Parameters, + Extension(parts): Extension, + ) -> Result { + // 鉴权 + 作用域校验:read 工具要求 token.scope >= read。 + let principal = parts + .extensions + .get::() + .ok_or_else(|| McpError::invalid_request("missing MCP principal", None))?; + if !principal.scope.grants(TokenScope::Read) { + return Err(McpError::invalid_request( + "insufficient_scope: search_posts requires read", + None, + )); + } + + let hits = search_published(&query) + .await + .map_err(|e| McpError::internal_error(format!("search failed: {e}"), None))?; + + // 输出为 JSON 文本块(客户端 LLM 可解析)。T3 会改用 resource_link + 结构化输出。 + let text = serde_json::to_string_pretty(&hits) + .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?; + Ok(CallToolResult::success(vec![ContentBlock::Text( + TextContent::new(text), + )])) + } +} + +#[tool_handler(name = "yggdrasil", version = "0.1.0")] +impl ServerHandler for YggMcpServer {} + +/// 已发布文章的精简命中(MCP 工具输出)。 +#[derive(Debug, serde::Serialize)] +struct SearchHit { + id: i32, + title: String, + slug: String, + summary: Option, + tags: Vec, +} + +/// 直查 DB:与 src/api/posts/search.rs 的 server-fn 一致的 pg_trgm 查询。 +/// 这里不复用 server-fn(后者依赖 FullstackContext 做限流,MCP 路径不走 cookie 鉴权), +/// 直接走连接池。T3 会抽出共享 helper。 +async fn search_published(query: &str) -> Result, String> { + let q = query.trim(); + if q.is_empty() || q.chars().count() > 200 { + return Ok(Vec::new()); + } + let client = crate::db::pool::get_conn() + .await + .map_err(|e| format!("db conn: {e}"))?; + + let escaped = q + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + + let rows = client + .query( + "SELECT p.id, p.title, p.slug, p.summary, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags + FROM posts p + LEFT JOIN post_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.status = 'published' AND p.deleted_at IS NULL + AND p.search_text ILIKE '%' || $1 || '%' ESCAPE '\\' + GROUP BY p.id, p.search_text + ORDER BY word_similarity(p.search_text, $2) DESC, p.published_at DESC + LIMIT 50", + &[&escaped, &q], + ) + .await + .map_err(|e| format!("query: {e}"))?; + + let hits = rows + .iter() + .map(|r| SearchHit { + id: r.get(0), + title: r.get(1), + slug: r.get(2), + summary: r.get(3), + tags: r.get(4), + }) + .collect(); + Ok(hits) +} diff --git a/src/models/mcp_token.rs b/src/models/mcp_token.rs new file mode 100644 index 0000000..af9fde1 --- /dev/null +++ b/src/models/mcp_token.rs @@ -0,0 +1,176 @@ +//! MCP 服务器访问令牌模型。 +//! +//! `mcp_tokens` 表承载管理员为 AI 客户端签发的 bearer 令牌:绑定用户、作用域、 +//! 可选过期时间。明文 token 经 AES-GCM 静态加密存储(`token_enc`),可由管理员 +//! 解密重查;同时存 SHA-256 哈希(`token_hash`)做每请求 O(1) 常量查找。 +//! +//! 与 assets 一致:id 以 String 承载(SQL 侧 `id::text` 读出、`$1::uuid` 写入), +//! 避免把 server-only 的 uuid crate 引入 WASM 前端构建。chrono 用于两端共享。 + +use serde::{Deserialize, Serialize}; + +/// 令牌作用域:read < write < admin,支持偏序比较用于工具调度鉴权。 +/// +/// - `read`:仅查询已发布文章(知识库)。 +/// - `write`:`read` + 文章 CRUD(含草稿)、评论、标签、媒体上传。 +/// - `admin`:`write` + 站点设置、代码运行器。 +/// +/// 比较语义:`scope >= required` 表示该令牌有权调用要求 `required` 作用域的工具。 +/// 例如 `admin` 令牌可调用 `read`/`write`/`admin` 任一工具。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum TokenScope { + Read, + Write, + Admin, +} + +impl TokenScope { + /// 数据库存储的字符串形式。 + pub fn as_str(self) -> &'static str { + match self { + TokenScope::Read => "read", + TokenScope::Write => "write", + TokenScope::Admin => "admin", + } + } + + /// 从数据库字符串解析;非法值返回 None(调用方按业务错误处理,不走 panic)。 + pub fn from_db(s: &str) -> Option { + match s { + "read" => Some(TokenScope::Read), + "write" => Some(TokenScope::Write), + "admin" => Some(TokenScope::Admin), + _ => None, + } + } + + /// 数值用于偏序比较:read=1 < write=2 < admin=3。 + fn rank(self) -> u8 { + match self { + TokenScope::Read => 1, + TokenScope::Write => 2, + TokenScope::Admin => 3, + } + } + + /// 该令牌是否满足某工具要求的作用域(`self.rank() >= required.rank()`)。 + pub fn grants(self, required: TokenScope) -> bool { + self.rank() >= required.rank() + } +} + +impl PartialOrd for TokenScope { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TokenScope { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.rank().cmp(&other.rank()) + } +} + +/// mcp_tokens 表一行(不含明文;密文在 `token_enc`)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct McpToken { + pub id: String, + pub user_id: i32, + pub name: String, + pub scope: TokenScope, + /// AES-GCM 密文 hex(nonce ‖ ct ‖ tag)。仅服务端解密使用,不向前端暴露。 + #[serde(skip)] + pub token_enc: String, + /// 明文 SHA-256 hex。仅服务端查找用,不向前端暴露。 + #[serde(skip)] + pub token_hash: String, + pub created_at: chrono::DateTime, + pub expires_at: Option>, + pub last_used_at: Option>, + pub revoked_at: Option>, +} + +/// 列表响应 DTO:不含任何密钥材料,仅展示用元数据。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct McpTokenSummary { + pub id: String, + pub name: String, + pub scope: TokenScope, + pub created_at: chrono::DateTime, + pub expires_at: Option>, + pub last_used_at: Option>, + pub revoked_at: Option>, +} + +impl From for McpTokenSummary { + fn from(t: McpToken) -> Self { + Self { + id: t.id, + name: t.name, + scope: t.scope, + created_at: t.created_at, + expires_at: t.expires_at, + last_used_at: t.last_used_at, + revoked_at: t.revoked_at, + } + } +} + +/// 签发令牌的响应:摘要 + 一次性明文(明文仅在签发/重查时返回,不持久明文)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CreateTokenResponse { + #[serde(flatten)] + pub summary: McpTokenSummary, + /// 完整 bearer 明文,形如 `ygg_`;客户端写入 Authorization 头。 + pub plaintext: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scope_ranking_grants_chain() { + // read 仅满足 read + assert!(TokenScope::Read.grants(TokenScope::Read)); + assert!(!TokenScope::Read.grants(TokenScope::Write)); + assert!(!TokenScope::Read.grants(TokenScope::Admin)); + // write 满足 read+write,不满足 admin + assert!(TokenScope::Write.grants(TokenScope::Read)); + assert!(TokenScope::Write.grants(TokenScope::Write)); + assert!(!TokenScope::Write.grants(TokenScope::Admin)); + // admin 满足全部 + assert!(TokenScope::Admin.grants(TokenScope::Read)); + assert!(TokenScope::Admin.grants(TokenScope::Write)); + assert!(TokenScope::Admin.grants(TokenScope::Admin)); + } + + #[test] + fn scope_ord_total_order() { + assert!(TokenScope::Read < TokenScope::Write); + assert!(TokenScope::Write < TokenScope::Admin); + assert!(TokenScope::Admin >= TokenScope::Read); + // 偏序完备(Ord 实现,无 PartialOrd 退化分支) + let mut v = [TokenScope::Admin, TokenScope::Read, TokenScope::Write]; + v.sort(); + assert_eq!(v, [TokenScope::Read, TokenScope::Write, TokenScope::Admin]); + } + + #[test] + fn scope_db_roundtrip() { + for s in [TokenScope::Read, TokenScope::Write, TokenScope::Admin] { + assert_eq!(TokenScope::from_db(s.as_str()), Some(s)); + } + assert_eq!(TokenScope::from_db("root"), None); + assert_eq!(TokenScope::from_db(""), None); + } + + #[test] + fn scope_serde_lowercase() { + let s = serde_json::to_string(&TokenScope::Admin).unwrap(); + assert_eq!(s, "\"admin\""); + let v: TokenScope = serde_json::from_str("\"read\"").unwrap(); + assert_eq!(v, TokenScope::Read); + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index dfad32c..4d7f1ab 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -5,6 +5,10 @@ /// 素材(图片)模型:assets 注册表与引用关联的 serde DTO。 pub mod asset; +/// MCP 服务器访问令牌模型与作用域枚举。 +/// allow(dead_code):T1 仅定义类型;T2 的 token 管理服务端函数才构造这些 DTO。 +#[allow(dead_code)] +pub mod mcp_token; /// 评论模型及其状态枚举。 pub mod comment; /// 文章模型、文章状态、标签与统计信息。