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