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/// 令牌作用域:read < write < admin,支持偏序比较用于工具调度鉴权。
13///
14/// - `read`:仅查询已发布文章(知识库)。
15/// - `write`:`read` + 文章 CRUD(含草稿)、评论、标签、媒体上传。
16/// - `admin`:`write` + 站点设置、代码运行器。
17///
18/// 比较语义:`scope >= required` 表示该令牌有权调用要求 `required` 作用域的工具。
19/// 例如 `admin` 令牌可调用 `read`/`write`/`admin` 任一工具。
20#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
21#[serde(rename_all = "lowercase")]
22pub enum TokenScope {
23    Read,
24    Write,
25    Admin,
26}
27
28impl TokenScope {
29    /// 数据库存储的字符串形式。
30    pub fn as_str(self) -> &'static str {
31        match self {
32            TokenScope::Read => "read",
33            TokenScope::Write => "write",
34            TokenScope::Admin => "admin",
35        }
36    }
37
38    /// 从数据库字符串解析;非法值返回 None(调用方按业务错误处理,不走 panic)。
39    pub fn from_db(s: &str) -> Option<Self> {
40        match s {
41            "read" => Some(TokenScope::Read),
42            "write" => Some(TokenScope::Write),
43            "admin" => Some(TokenScope::Admin),
44            _ => None,
45        }
46    }
47
48    /// 数值用于偏序比较:read=1 < write=2 < admin=3。
49    fn rank(self) -> u8 {
50        match self {
51            TokenScope::Read => 1,
52            TokenScope::Write => 2,
53            TokenScope::Admin => 3,
54        }
55    }
56
57    /// 该令牌是否满足某工具要求的作用域(`self.rank() >= required.rank()`)。
58    pub fn grants(self, required: TokenScope) -> bool {
59        self.rank() >= required.rank()
60    }
61}
62
63impl PartialOrd for TokenScope {
64    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
65        Some(self.cmp(other))
66    }
67}
68
69impl Ord for TokenScope {
70    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
71        self.rank().cmp(&other.rank())
72    }
73}
74
75/// mcp_tokens 表一行(不含明文;密文在 `token_enc`)。
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct McpToken {
78    pub id: String,
79    pub user_id: i32,
80    pub name: String,
81    pub scope: TokenScope,
82    /// AES-GCM 密文 hex(nonce ‖ ct ‖ tag)。仅服务端解密使用,不向前端暴露。
83    #[serde(skip)]
84    pub token_enc: String,
85    /// 明文 SHA-256 hex。仅服务端查找用,不向前端暴露。
86    #[serde(skip)]
87    pub token_hash: String,
88    pub created_at: chrono::DateTime<chrono::Utc>,
89    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
90    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
91    pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
92}
93
94/// 列表响应 DTO:不含任何密钥材料,仅展示用元数据。
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct McpTokenSummary {
97    pub id: String,
98    pub name: String,
99    pub scope: TokenScope,
100    pub created_at: chrono::DateTime<chrono::Utc>,
101    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
102    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
103    pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
104}
105
106impl From<McpToken> for McpTokenSummary {
107    fn from(t: McpToken) -> Self {
108        Self {
109            id: t.id,
110            name: t.name,
111            scope: t.scope,
112            created_at: t.created_at,
113            expires_at: t.expires_at,
114            last_used_at: t.last_used_at,
115            revoked_at: t.revoked_at,
116        }
117    }
118}
119
120/// 签发令牌的响应:摘要 + 一次性明文(明文仅在签发/重查时返回,不持久明文)。
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122pub struct CreateTokenResponse {
123    #[serde(flatten)]
124    pub summary: McpTokenSummary,
125    /// 完整 bearer 明文,形如 `ygg_<opaque>`;客户端写入 Authorization 头。
126    pub plaintext: String,
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn scope_ranking_grants_chain() {
135        // read 仅满足 read
136        assert!(TokenScope::Read.grants(TokenScope::Read));
137        assert!(!TokenScope::Read.grants(TokenScope::Write));
138        assert!(!TokenScope::Read.grants(TokenScope::Admin));
139        // write 满足 read+write,不满足 admin
140        assert!(TokenScope::Write.grants(TokenScope::Read));
141        assert!(TokenScope::Write.grants(TokenScope::Write));
142        assert!(!TokenScope::Write.grants(TokenScope::Admin));
143        // admin 满足全部
144        assert!(TokenScope::Admin.grants(TokenScope::Read));
145        assert!(TokenScope::Admin.grants(TokenScope::Write));
146        assert!(TokenScope::Admin.grants(TokenScope::Admin));
147    }
148
149    #[test]
150    fn scope_ord_total_order() {
151        assert!(TokenScope::Read < TokenScope::Write);
152        assert!(TokenScope::Write < TokenScope::Admin);
153        assert!(TokenScope::Admin >= TokenScope::Read);
154        // 偏序完备(Ord 实现,无 PartialOrd 退化分支)
155        let mut v = [TokenScope::Admin, TokenScope::Read, TokenScope::Write];
156        v.sort();
157        assert_eq!(v, [TokenScope::Read, TokenScope::Write, TokenScope::Admin]);
158    }
159
160    #[test]
161    fn scope_db_roundtrip() {
162        for s in [TokenScope::Read, TokenScope::Write, TokenScope::Admin] {
163            assert_eq!(TokenScope::from_db(s.as_str()), Some(s));
164        }
165        assert_eq!(TokenScope::from_db("root"), None);
166        assert_eq!(TokenScope::from_db(""), None);
167    }
168
169    #[test]
170    fn scope_serde_lowercase() {
171        let s = serde_json::to_string(&TokenScope::Admin).unwrap();
172        assert_eq!(s, "\"admin\"");
173        let v: TokenScope = serde_json::from_str("\"read\"").unwrap();
174        assert_eq!(v, TokenScope::Read);
175    }
176}