Skip to main content

futu_mcp/
guard.rs

1//! Scope 守卫 + 限额检查 + 审计日志
2//!
3//! 两种模式:
4//! - **scope 模式**(`state.is_scope_mode()`):必须持有 api-key,且 scope 匹配
5//! - **legacy 模式**(没配 keys-file):读工具全放行;写工具走 `enable_trading` /
6//!   `allow_real_trading` 两级开关
7
8#[cfg(test)]
9use std::sync::Arc;
10
11#[cfg(test)]
12use chrono::Utc;
13use futu_auth::Scope;
14#[cfg(test)]
15use futu_auth::{CheckCtx, KeyRecord};
16use sha2::{Digest, Sha256};
17
18#[cfg(test)]
19use crate::state::ServerState;
20
21/// 从 KeyStore 里按 id 取**当前**的 KeyRecord(对齐 SIGHUP 热重载 + machine binding)。
22///
23/// 如果 startup 时的 authed_key 已被 remove_key 删掉,返回 None → 调用方拒绝。
24/// 否则返回存储中最新版的 KeyRecord(scope / limits / expires_at / machine binding 全新鲜)。
25///
26/// v1.4.106 codex 0608 F2 (P1): 用 `get_by_id_for_current_machine` 替代裸
27/// `get_by_id`, 让 SIGHUP 收紧 `allowed_machines` 后能立即 reject (避免
28/// silent-unrestricted, 反模式 D / pitfall #45).
29#[cfg(test)]
30fn current_authed_key(state: &ServerState) -> Option<Arc<KeyRecord>> {
31    let startup = state.authed_key()?;
32    // legacy 模式下 key_store 是 empty(),get_by_id_for_current_machine 返回 None,
33    // 但 legacy 模式在 guard 入口就分支出去了,不会走到这里
34    state.key_store().get_by_id_for_current_machine(&startup.id)
35}
36
37/// 工具需要的 scope 类别
38///
39/// Read 类的 scope(qot:read / acc:read)是静态确定的;Trade 类在运行时根据
40/// `env=real/simulate` 派发到 `Scope::TradeReal` / `Scope::TradeSimulate`。
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum ToolScope {
44    /// 只读工具需要的 scope
45    Read(Scope),
46    /// 交易写工具(具体派发由 `require_trading` 根据 env 决定)
47    Trade,
48}
49
50/// 工具名 → 所需 scope。
51///
52/// v1.4.110 Surface Spec v2: MCP scope 完全由 `EndpointSpec` 派生。
53/// 新增 MCP tool 必须先在 surface manifest 声明完整 binding;生成 catalog
54/// 未收录的工具会返回 None,handler 将 fail-closed 拒绝请求("unknown MCP tool")。
55pub fn scope_for_tool(tool: &str) -> Option<ToolScope> {
56    Some(match crate::tools::generated_mcp_scope(tool)? {
57        futu_auth::Scope::TradeReal | futu_auth::Scope::TradeSimulate => ToolScope::Trade,
58        scope => ToolScope::Read(scope),
59    })
60}
61
62/// 基于注册表的只读 scope 守卫;若工具未登记则 fail-closed 拒绝.
63///
64/// 仅对 `ToolScope::Read(_)` 生效;Trade 类工具仍然走 `require_trading()`.
65///
66/// v1.4.104 codex round 1 F2 (P1) 后, 生产路径已迁 `tools.rs::require_acc_read_with_acc_id`
67/// (caller-specific via Bearer/api_key). 本 fn 只剩 unit test refs.
68#[cfg(test)]
69pub fn require_tool_scope(state: &ServerState, tool: &'static str) -> GuardOutcome {
70    match scope_for_tool(tool) {
71        Some(ToolScope::Read(s)) => require_scope(state, tool, s),
72        Some(ToolScope::Trade) => {
73            // 防御性分支:调用方错把 trade 工具丢到 require_tool_scope 里。
74            audit(tool, None, "reject", "internal: trade tool misrouted");
75            GuardOutcome::Reject(format!(
76                "internal error: {tool} is a trade tool, must use require_trading"
77            ))
78        }
79        None => {
80            audit(tool, None, "reject", "unknown MCP tool");
81            GuardOutcome::Reject(format!("unknown MCP tool {tool:?}"))
82        }
83    }
84}
85
86#[cfg(test)]
87/// 守卫结果:Allow 或携带拒绝原因的 JSON
88#[non_exhaustive]
89pub enum GuardOutcome {
90    /// 鉴权 / scope / 限额全过,handler 可以继续执行
91    Allow,
92    /// 拒绝放行,`String` 为可直接返给 MCP 客户端的 JSON error payload
93    Reject(String),
94}
95
96#[cfg(test)]
97impl GuardOutcome {
98    /// 把 `Reject(msg)` 转成 `Some(msg)`;`Allow` 返 `None`. 供 handler
99    /// 直接 `.into_err_json()?` 做早 return.
100    ///
101    /// v1.4.109 后仅保留在 `#[cfg(test)]` reference guard 中,作为旧
102    /// guard 输出 shape 的漂移报警;production 鉴权走 `tool_auth`.
103    pub fn into_err_json(self) -> Option<String> {
104        match self {
105            GuardOutcome::Allow => None,
106            // MED-NEW-2(2nd review):加 `status: error` 让 scope-reject error shape
107            // 与 `tool_err` / `client_or_err` 对齐(所有 error JSON 都含 error + status)
108            GuardOutcome::Reject(msg) => {
109                Some(serde_json::json!({ "error": msg, "status": "error" }).to_string())
110            }
111        }
112    }
113}
114
115/// 基础 scope 守卫(用于只读工具)
116///
117/// 返回 `GuardOutcome::Allow` 表示放行. legacy 模式下只读工具全放行.
118///
119/// v1.4.104 codex round 1 F2 (P1) 后只剩 unit test refs (regression guard).
120#[cfg(test)]
121pub fn require_scope(state: &ServerState, tool: &'static str, needed: Scope) -> GuardOutcome {
122    if !state.is_scope_mode() {
123        // legacy 行为:只读工具不检查(旧用户兼容)
124        audit(tool, None, "allow", "legacy mode, no keys configured");
125        return GuardOutcome::Allow;
126    }
127
128    if state.authed_key().is_none() {
129        audit(tool, None, "reject", "no API key provided");
130        return GuardOutcome::Reject(
131            "API key required: set FUTU_MCP_API_KEY to a plaintext key listed in keys.json"
132                .to_string(),
133        );
134    }
135
136    // SIGHUP 热重载后用 id 重新 lookup,拿最新 scope/limits/expiry
137    let Some(key) = current_authed_key(state) else {
138        let id = state.authed_key().map(|k| k.id.clone()).unwrap_or_default();
139        audit(
140            tool,
141            Some(&id),
142            "reject",
143            "key revoked (not in current keys.json)",
144        );
145        return GuardOutcome::Reject(format!(
146            "API key {id:?} has been revoked (not in current keys.json)"
147        ));
148    };
149
150    // 过期再查一次(防止启动后过期,或 SIGHUP 后 expires_at 被改小)
151    if key.is_expired(Utc::now()) {
152        audit(tool, Some(&key.id), "reject", "key expired");
153        return GuardOutcome::Reject(format!(
154            "API key {:?} has expired (expires_at={:?})",
155            key.id, key.expires_at
156        ));
157    }
158
159    if !key.scopes.contains(&needed) {
160        audit(
161            tool,
162            Some(&key.id),
163            "reject",
164            &format!("missing scope {}", needed),
165        );
166        return GuardOutcome::Reject(format!(
167            "API key {:?} missing required scope {:?}",
168            key.id,
169            needed.as_str()
170        ));
171    }
172
173    audit(tool, Some(&key.id), "allow", "scope ok");
174    GuardOutcome::Allow
175}
176
177/// 交易写守卫:scope + legacy 兼容 + (可选)限额检查 + (可选)per-call key 覆盖
178///
179/// `env`:`"real"` / `"simulate"`;`ctx` 为 Some 时跑限额检查(下单路径)。
180///
181/// `override_key` 为 Some 时,本次调用使用这个 key(`KeyStore::verify` 一次性
182/// 拿最新 record)而不是 `state.authed_key`;典型用法:MCP 多租户,让 LLM 客户端
183/// 每个 tool call 带自己的 key。验证失败 → reject,不回落。若为 None → 用启动时
184/// 捕获的 `state.authed_key`(SIGHUP-aware fresh lookup)。
185/// v1.4.104 阶段 7-4: 生产路径已委托 `tools.rs::require_trading` →
186/// `futu_auth_pipeline::authenticate_request`. 本函数保留作:
187///
188/// 1. 21 个 unit test 的 reference 实现 (regression guard 防 pipeline 漂移)
189/// 2. 历史文档 (legacy 2 级开关 + per-call override + scope/expiry/rate
190///    具体逻辑可读)
191///
192/// 删除条件:`tool_auth` / auth-pipeline 的 integration coverage 能等价覆盖上述
193/// reference 行为;删除前必须同步删掉依赖本函数的 regression tests。
194#[cfg(test)]
195pub fn require_trading(
196    state: &ServerState,
197    tool: &'static str,
198    env: &str,
199    ctx: Option<CheckCtx>,
200    override_key: Option<&str>,
201) -> GuardOutcome {
202    let is_real = crate::handlers::trade_write::is_real_env(env);
203    let needed_scope = futu_auth::trade_scope_for_env_is_real(is_real);
204
205    if !state.is_scope_mode() {
206        // legacy:两级开关
207        if !state.enable_trading() {
208            audit(tool, None, "reject", "legacy: --enable-trading off");
209            return GuardOutcome::Reject(
210                "trading tools are disabled. Start futu-mcp with --enable-trading to enable."
211                    .to_string(),
212            );
213        }
214        if is_real && !state.allow_real_trading() {
215            audit(
216                tool,
217                None,
218                "reject",
219                "legacy: real env but --allow-real-trading off",
220            );
221            return GuardOutcome::Reject(
222                "real trading is not allowed. Use env=\"simulate\" or restart futu-mcp with --allow-real-trading."
223                    .to_string(),
224            );
225        }
226        // legacy 下 override_key 被忽略(没有 KeyStore 可 verify),
227        // 但这种配置本身就是"信任所有调用方",覆盖不覆盖不影响安全语义
228        audit(tool, None, "allow", "legacy trading allowed");
229        return GuardOutcome::Allow;
230    }
231
232    // scope 模式:先解析用哪把 key
233    let key = if let Some(plaintext) = override_key.filter(|p| !p.is_empty()) {
234        // per-call override:用传入的 plaintext 实时 verify,不走 startup 快照
235        match state.key_store().verify(plaintext) {
236            Some(rec) => rec,
237            None => {
238                audit(tool, None, "reject", "per-call api_key invalid");
239                return GuardOutcome::Reject(
240                    "per-call api_key is invalid (not in keys.json or expired/bound to wrong machine)"
241                        .to_string(),
242                );
243            }
244        }
245    } else {
246        if state.authed_key().is_none() {
247            audit(tool, None, "reject", "no API key");
248            return GuardOutcome::Reject(
249                "API key required for trading tools (set FUTU_MCP_API_KEY, or pass api_key in the tool call)"
250                    .to_string(),
251            );
252        }
253        // 同样走 SIGHUP-aware 的 fresh lookup
254        match current_authed_key(state) {
255            Some(k) => k,
256            None => {
257                let id = state.authed_key().map(|k| k.id.clone()).unwrap_or_default();
258                audit(tool, Some(&id), "reject", "key revoked");
259                return GuardOutcome::Reject(format!("API key {id:?} has been revoked"));
260            }
261        }
262    };
263
264    if key.is_expired(Utc::now()) {
265        audit(tool, Some(&key.id), "reject", "key expired");
266        return GuardOutcome::Reject(format!("API key {:?} has expired", key.id));
267    }
268
269    if !key.scopes.contains(&needed_scope) {
270        audit(
271            tool,
272            Some(&key.id),
273            "reject",
274            &format!("missing scope {}", needed_scope),
275        );
276        return GuardOutcome::Reject(format!(
277            "API key {:?} missing scope {:?}",
278            key.id,
279            needed_scope.as_str()
280        ));
281    }
282
283    // 限额检查(仅在提供了 ctx 时执行)
284    // v1.4.36 Bug #1:Reject variant 拆成 Throughput / Whitelist / Value,
285    // 用 `reason()` helper 统一获取消息。MCP 没有 HTTP status 概念,所有
286    // reject 统一返 GuardOutcome::Reject(客户端显示原因字符串)。
287    if let Some(ctx) = ctx {
288        let outcome = state
289            .counters()
290            .check_and_commit(&key.id, key.as_ref(), &ctx, Utc::now());
291        if outcome.is_allow() {
292            audit(tool, Some(&key.id), "allow", "scope + limits ok");
293        } else {
294            let reason = outcome
295                .reason()
296                .unwrap_or_else(|| "limit check failed".to_string());
297            audit(tool, Some(&key.id), "reject", &format!("limit: {reason}"));
298            return GuardOutcome::Reject(format!("limit check failed: {reason}"));
299        }
300    } else {
301        audit(tool, Some(&key.id), "allow", "scope ok (no limits ctx)");
302    }
303
304    GuardOutcome::Allow
305}
306
307/// 审计日志:key_id / tool / result / reason
308#[cfg(test)]
309fn audit(tool: &str, key_id: Option<&str>, result: &str, reason: &str) {
310    let key_id = key_id.unwrap_or("<none>");
311    if result == "reject" {
312        futu_auth::audit::reject("mcp", tool, key_id, reason);
313    } else {
314        futu_auth::audit::allow("mcp", tool, key_id, Some(reason));
315    }
316}
317
318/// 计算 args 的短哈希(前 8 hex),用于审计日志(不存原始敏感字段)
319pub fn args_short_hash(args: &impl serde::Serialize) -> String {
320    let j = match serde_json::to_vec(args) {
321        Ok(v) => v,
322        Err(_) => return "n/a".into(),
323    };
324    let h = Sha256::digest(&j);
325    hex::encode(&h[..4])
326}
327
328/// 交易工具执行完后的审计事件:解析 handler 返回的 JSON,success / failure
329/// 写入 audit JSONL。`key_id = None` 时用 "<none>" 占位(legacy 模式)。
330pub fn emit_trade_outcome(tool: &'static str, key_id: Option<&str>, args_hash: &str, result: &str) {
331    let key_id = key_id.unwrap_or("<none>");
332    let (outcome, reason) = match serde_json::from_str::<serde_json::Value>(result) {
333        Ok(v) => match v.get("error").and_then(|e| e.as_str()) {
334            Some(err) => ("failure", Some(err.to_string())),
335            None => ("success", None),
336        },
337        Err(_) => ("unknown", Some("non-json response".to_string())),
338    };
339    futu_auth::audit::trade("mcp", tool, key_id, args_hash, outcome, reason.as_deref());
340}
341
342#[cfg(test)]
343mod tests;