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    pub created_at: chrono::DateTime<chrono::Utc>,
102    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
103    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
104    pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
105}
106
107/// 列表响应 DTO:不含任何密钥材料,仅展示用元数据。
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct McpTokenSummary {
110    pub id: String,
111    pub name: String,
112    pub scope: TokenScope,
113    #[serde(default)]
114    pub notes: NoteGrant,
115    pub created_at: chrono::DateTime<chrono::Utc>,
116    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
117    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
118    pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
119}
120
121impl From<McpToken> for McpTokenSummary {
122    fn from(t: McpToken) -> Self {
123        Self {
124            id: t.id,
125            name: t.name,
126            scope: t.scope,
127            notes: t.notes,
128            created_at: t.created_at,
129            expires_at: t.expires_at,
130            last_used_at: t.last_used_at,
131            revoked_at: t.revoked_at,
132        }
133    }
134}
135
136/// 独立于文章权限的笔记授权。None 表示全部笔记本,空列表不授权任何笔记本。
137#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
138pub struct NoteGrant {
139    pub read: bool,
140    pub write: bool,
141    pub notebook_ids: Option<Vec<i32>>,
142}
143
144/// 签发令牌的响应:摘要 + 一次性明文(明文仅在签发/重查时返回,不持久明文)。
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
146pub struct CreateTokenResponse {
147    #[serde(flatten)]
148    pub summary: McpTokenSummary,
149    /// 完整 bearer 明文,形如 `ygg_<opaque>`;客户端写入 Authorization 头。
150    pub plaintext: String,
151}
152
153#[cfg(test)]
154mod tests {
155    #[test]
156    fn token_name_requires_non_whitespace_and_at_most_64_characters() {
157        for name in ["", " \t\n", "\u{3000}"] {
158            assert!(super::validate_token_name(name).is_err());
159        }
160        for name in [" claude-code-macbook ".to_string(), "名".repeat(64)] {
161            assert!(super::validate_token_name(&name).is_ok());
162        }
163        assert!(super::validate_token_name(&"名".repeat(65)).is_err());
164        assert!(super::validate_token_name(&"a".repeat(65)).is_err());
165    }
166
167    use super::*;
168
169    #[test]
170    fn scope_ranking_grants_chain() {
171        // read 仅满足 read
172        assert!(TokenScope::Read.grants(TokenScope::Read));
173        assert!(!TokenScope::Read.grants(TokenScope::Write));
174        assert!(!TokenScope::Read.grants(TokenScope::Admin));
175        // write 满足 read+write,不满足 admin
176        assert!(TokenScope::Write.grants(TokenScope::Read));
177        assert!(TokenScope::Write.grants(TokenScope::Write));
178        assert!(!TokenScope::Write.grants(TokenScope::Admin));
179        // admin 满足全部
180        assert!(TokenScope::Admin.grants(TokenScope::Read));
181        assert!(TokenScope::Admin.grants(TokenScope::Write));
182        assert!(TokenScope::Admin.grants(TokenScope::Admin));
183    }
184
185    #[test]
186    fn scope_ord_total_order() {
187        assert!(TokenScope::Read < TokenScope::Write);
188        assert!(TokenScope::Write < TokenScope::Admin);
189        assert!(TokenScope::Admin >= TokenScope::Read);
190        // 偏序完备(Ord 实现,无 PartialOrd 退化分支)
191        let mut v = [TokenScope::Admin, TokenScope::Read, TokenScope::Write];
192        v.sort();
193        assert_eq!(v, [TokenScope::Read, TokenScope::Write, TokenScope::Admin]);
194    }
195
196    #[test]
197    fn scope_db_roundtrip() {
198        for s in [TokenScope::Read, TokenScope::Write, TokenScope::Admin] {
199            assert_eq!(TokenScope::from_db(s.as_str()), Some(s));
200        }
201        assert_eq!(TokenScope::from_db("root"), None);
202        assert_eq!(TokenScope::from_db(""), None);
203    }
204
205    #[test]
206    fn scope_serde_lowercase() {
207        let s = serde_json::to_string(&TokenScope::Admin).unwrap();
208        assert_eq!(s, "\"admin\"");
209        let v: TokenScope = serde_json::from_str("\"read\"").unwrap();
210        assert_eq!(v, TokenScope::Read);
211    }
212}