Skip to main content

yggdrasil/models/
mcp_token.rs

1//! MCP 服务器访问令牌模型。
2//!
3//! `mcp_tokens` 表承载管理员为 AI 客户端签发的 bearer 令牌:绑定用户、作用域、
4//! 可选过期时间。明文 token 经 AES-GCM 静态加密存储(`token_enc`),可由管理员
5//! 解密重查;同时存 SHA-256 哈希(`token_hash`)做每请求 O(1) 常量查找。
6//!
7//! 与 assets 一致:id 以 String 承载(SQL 侧 `id::text` 读出、`$1::uuid` 写入),
8//! 避免把 server-only 的 uuid crate 引入 WASM 前端构建。chrono 用于两端共享。
9
10use serde::{Deserialize, Serialize};
11
12/// 前后端共用的令牌名称校验,按去除首尾空白后的字符数计算。
13pub fn validate_token_name(name: &str) -> Result<(), &'static str> {
14    let name = name.trim();
15    if name.is_empty() {
16        Err("请输入令牌名称,不能只包含空白字符")
17    } else if name.chars().count() > 64 {
18        Err("令牌名称不能超过 64 个字符")
19    } else {
20        Ok(())
21    }
22}
23
24/// 令牌作用域:read < write < admin,支持偏序比较用于工具调度鉴权。
25///
26/// - `read`:仅查询已发布文章(知识库)。
27/// - `write`:`read` + 文章 CRUD(含草稿)、评论、标签、媒体上传。
28/// - `admin`:`write` + 站点设置、代码运行器。
29///
30/// 比较语义:`scope >= required` 表示该令牌有权调用要求 `required` 作用域的工具。
31/// 例如 `admin` 令牌可调用 `read`/`write`/`admin` 任一工具。
32#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
33#[serde(rename_all = "lowercase")]
34pub enum TokenScope {
35    Read,
36    Write,
37    Admin,
38}
39
40impl TokenScope {
41    /// 数据库存储的字符串形式。
42    pub fn as_str(self) -> &'static str {
43        match self {
44            TokenScope::Read => "read",
45            TokenScope::Write => "write",
46            TokenScope::Admin => "admin",
47        }
48    }
49
50    /// 从数据库字符串解析;非法值返回 None(调用方按业务错误处理,不走 panic)。
51    pub fn from_db(s: &str) -> Option<Self> {
52        match s {
53            "read" => Some(TokenScope::Read),
54            "write" => Some(TokenScope::Write),
55            "admin" => Some(TokenScope::Admin),
56            _ => None,
57        }
58    }
59
60    /// 数值用于偏序比较:read=1 < write=2 < admin=3。
61    fn rank(self) -> u8 {
62        match self {
63            TokenScope::Read => 1,
64            TokenScope::Write => 2,
65            TokenScope::Admin => 3,
66        }
67    }
68
69    /// 该令牌是否满足某工具要求的作用域(`self.rank() >= required.rank()`)。
70    pub fn grants(self, required: TokenScope) -> bool {
71        self.rank() >= required.rank()
72    }
73}
74
75impl PartialOrd for TokenScope {
76    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
77        Some(self.cmp(other))
78    }
79}
80
81impl Ord for TokenScope {
82    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
83        self.rank().cmp(&other.rank())
84    }
85}
86
87/// mcp_tokens 表一行(不含明文;密文在 `token_enc`)。
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
89pub struct McpToken {
90    pub id: String,
91    pub user_id: i32,
92    pub name: String,
93    pub scope: TokenScope,
94    pub notes: NoteGrant,
95    /// AES-GCM 密文 hex(nonce ‖ ct ‖ tag)。仅服务端解密使用,不向前端暴露。
96    #[serde(skip)]
97    pub token_enc: String,
98    /// 明文 SHA-256 hex。仅服务端查找用,不向前端暴露。
99    #[serde(skip)]
100    pub token_hash: String,
101    #[cfg_attr(
102        target_arch = "wasm32",
103        serde(deserialize_with = "crate::models::rfc3339::deserialize")
104    )]
105    pub created_at: chrono::DateTime<chrono::Utc>,
106    #[cfg_attr(
107        target_arch = "wasm32",
108        serde(
109            default,
110            deserialize_with = "crate::models::rfc3339::deserialize_option"
111        )
112    )]
113    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
114    #[cfg_attr(
115        target_arch = "wasm32",
116        serde(
117            default,
118            deserialize_with = "crate::models::rfc3339::deserialize_option"
119        )
120    )]
121    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
122    #[cfg_attr(
123        target_arch = "wasm32",
124        serde(
125            default,
126            deserialize_with = "crate::models::rfc3339::deserialize_option"
127        )
128    )]
129    pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
130}
131
132/// 列表响应 DTO:不含任何密钥材料,仅展示用元数据。
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
134pub struct McpTokenSummary {
135    pub id: String,
136    pub name: String,
137    pub scope: TokenScope,
138    #[serde(default)]
139    pub notes: NoteGrant,
140    #[cfg_attr(
141        target_arch = "wasm32",
142        serde(deserialize_with = "crate::models::rfc3339::deserialize")
143    )]
144    pub created_at: chrono::DateTime<chrono::Utc>,
145    #[cfg_attr(
146        target_arch = "wasm32",
147        serde(
148            default,
149            deserialize_with = "crate::models::rfc3339::deserialize_option"
150        )
151    )]
152    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
153    #[cfg_attr(
154        target_arch = "wasm32",
155        serde(
156            default,
157            deserialize_with = "crate::models::rfc3339::deserialize_option"
158        )
159    )]
160    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
161    #[cfg_attr(
162        target_arch = "wasm32",
163        serde(
164            default,
165            deserialize_with = "crate::models::rfc3339::deserialize_option"
166        )
167    )]
168    pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
169}
170
171impl From<McpToken> for McpTokenSummary {
172    fn from(t: McpToken) -> Self {
173        Self {
174            id: t.id,
175            name: t.name,
176            scope: t.scope,
177            notes: t.notes,
178            created_at: t.created_at,
179            expires_at: t.expires_at,
180            last_used_at: t.last_used_at,
181            revoked_at: t.revoked_at,
182        }
183    }
184}
185
186/// 独立于文章权限的笔记授权。None 表示全部笔记本,空列表不授权任何笔记本。
187#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
188pub struct NoteGrant {
189    pub read: bool,
190    pub write: bool,
191    pub notebook_ids: Option<Vec<i32>>,
192}
193
194/// 签发令牌的响应:摘要 + 一次性明文(明文仅在签发/重查时返回,不持久明文)。
195///
196/// 线格式把摘要字段平铺在顶层(序列化用 `flatten`);反序列化手写,避免
197/// `flatten` 派生在 WASM 里引入 `Content` 缓冲。
198#[derive(Debug, Clone, Serialize, PartialEq)]
199pub struct CreateTokenResponse {
200    #[serde(flatten)]
201    pub summary: McpTokenSummary,
202    /// 完整 bearer 明文,形如 `ygg_<opaque>`;客户端写入 Authorization 头。
203    pub plaintext: String,
204}
205
206impl<'de> Deserialize<'de> for CreateTokenResponse {
207    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
208        use chrono::{DateTime, Utc};
209
210        #[derive(Deserialize)]
211        struct Wire {
212            id: String,
213            name: String,
214            scope: TokenScope,
215            #[serde(default)]
216            notes: NoteGrant,
217            #[cfg_attr(
218                target_arch = "wasm32",
219                serde(deserialize_with = "crate::models::rfc3339::deserialize")
220            )]
221            created_at: DateTime<Utc>,
222            #[cfg_attr(
223                target_arch = "wasm32",
224                serde(
225                    default,
226                    deserialize_with = "crate::models::rfc3339::deserialize_option"
227                )
228            )]
229            expires_at: Option<DateTime<Utc>>,
230            #[cfg_attr(
231                target_arch = "wasm32",
232                serde(
233                    default,
234                    deserialize_with = "crate::models::rfc3339::deserialize_option"
235                )
236            )]
237            last_used_at: Option<DateTime<Utc>>,
238            #[cfg_attr(
239                target_arch = "wasm32",
240                serde(
241                    default,
242                    deserialize_with = "crate::models::rfc3339::deserialize_option"
243                )
244            )]
245            revoked_at: Option<DateTime<Utc>>,
246            plaintext: String,
247        }
248
249        let w = Wire::deserialize(d)?;
250        Ok(CreateTokenResponse {
251            summary: McpTokenSummary {
252                id: w.id,
253                name: w.name,
254                scope: w.scope,
255                notes: w.notes,
256                created_at: w.created_at,
257                expires_at: w.expires_at,
258                last_used_at: w.last_used_at,
259                revoked_at: w.revoked_at,
260            },
261            plaintext: w.plaintext,
262        })
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    #[test]
269    fn token_name_requires_non_whitespace_and_at_most_64_characters() {
270        for name in ["", " \t\n", "\u{3000}"] {
271            assert!(super::validate_token_name(name).is_err());
272        }
273        for name in [" claude-code-macbook ".to_string(), "名".repeat(64)] {
274            assert!(super::validate_token_name(&name).is_ok());
275        }
276        assert!(super::validate_token_name(&"名".repeat(65)).is_err());
277        assert!(super::validate_token_name(&"a".repeat(65)).is_err());
278    }
279
280    use super::*;
281
282    #[test]
283    fn scope_ranking_grants_chain() {
284        // read 仅满足 read
285        assert!(TokenScope::Read.grants(TokenScope::Read));
286        assert!(!TokenScope::Read.grants(TokenScope::Write));
287        assert!(!TokenScope::Read.grants(TokenScope::Admin));
288        // write 满足 read+write,不满足 admin
289        assert!(TokenScope::Write.grants(TokenScope::Read));
290        assert!(TokenScope::Write.grants(TokenScope::Write));
291        assert!(!TokenScope::Write.grants(TokenScope::Admin));
292        // admin 满足全部
293        assert!(TokenScope::Admin.grants(TokenScope::Read));
294        assert!(TokenScope::Admin.grants(TokenScope::Write));
295        assert!(TokenScope::Admin.grants(TokenScope::Admin));
296    }
297
298    #[test]
299    fn scope_ord_total_order() {
300        assert!(TokenScope::Read < TokenScope::Write);
301        assert!(TokenScope::Write < TokenScope::Admin);
302        assert!(TokenScope::Admin >= TokenScope::Read);
303        // 偏序完备(Ord 实现,无 PartialOrd 退化分支)
304        let mut v = [TokenScope::Admin, TokenScope::Read, TokenScope::Write];
305        v.sort();
306        assert_eq!(v, [TokenScope::Read, TokenScope::Write, TokenScope::Admin]);
307    }
308
309    #[test]
310    fn scope_db_roundtrip() {
311        for s in [TokenScope::Read, TokenScope::Write, TokenScope::Admin] {
312            assert_eq!(TokenScope::from_db(s.as_str()), Some(s));
313        }
314        assert_eq!(TokenScope::from_db("root"), None);
315        assert_eq!(TokenScope::from_db(""), None);
316    }
317
318    #[test]
319    fn scope_serde_lowercase() {
320        let s = serde_json::to_string(&TokenScope::Admin).unwrap();
321        assert_eq!(s, "\"admin\"");
322        let v: TokenScope = serde_json::from_str("\"read\"").unwrap();
323        assert_eq!(v, TokenScope::Read);
324    }
325
326    #[test]
327    fn create_token_response_round_trips_flat_json() {
328        let response = CreateTokenResponse {
329            summary: McpTokenSummary {
330                id: "00000000-0000-0000-0000-000000000001".into(),
331                name: "claude-code".into(),
332                scope: TokenScope::Write,
333                notes: NoteGrant {
334                    read: true,
335                    write: false,
336                    notebook_ids: Some(vec![3]),
337                },
338                created_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
339                expires_at: None,
340                last_used_at: None,
341                revoked_at: None,
342            },
343            plaintext: "ygg_secret".into(),
344        };
345        let json = serde_json::to_value(&response).unwrap();
346        assert_eq!(json["plaintext"], "ygg_secret");
347        assert_eq!(json["id"], "00000000-0000-0000-0000-000000000001");
348        assert_eq!(
349            serde_json::from_value::<CreateTokenResponse>(json).unwrap(),
350            response
351        );
352    }
353}