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
8use std::sync::Arc;
9
10use chrono::Utc;
11use futu_auth::{CheckCtx, KeyRecord, LimitOutcome, Scope};
12use sha2::{Digest, Sha256};
13
14use crate::state::ServerState;
15
16/// 从 KeyStore 里按 id 取**当前**的 KeyRecord(对齐 SIGHUP 热重载)。
17///
18/// 如果 startup 时的 authed_key 已被 remove_key 删掉,返回 None → 调用方拒绝。
19/// 否则返回存储中最新版的 KeyRecord(scope / limits / expires_at 全新鲜)。
20fn current_authed_key(state: &ServerState) -> Option<Arc<KeyRecord>> {
21    let startup = state.authed_key.as_ref()?;
22    // legacy 模式下 key_store 是 empty(),get_by_id 返回 None,
23    // 但 legacy 模式在 guard 入口就分支出去了,不会走到这里
24    state.key_store.get_by_id(&startup.id)
25}
26
27/// 工具需要的 scope 类别
28///
29/// Read 类的 scope(qot:read / acc:read)是静态确定的;Trade 类在运行时根据
30/// `env=real/simulate` 派发到 `Scope::TradeReal` / `Scope::TradeSimulate`。
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ToolScope {
33    /// 只读工具需要的 scope
34    Read(Scope),
35    /// 交易写工具(具体派发由 `require_trading` 根据 env 决定)
36    Trade,
37}
38
39/// 工具名 → 所需 scope 的**中央注册表**。
40///
41/// 新加工具时必须同步更新这里,否则 `require_tool_scope()` 返回 None,
42/// handler 会 fail-closed 地拒绝请求("unknown MCP tool")。
43/// 配套的 `all_known_tools_have_scopes` 测试也会失败。
44///
45/// 对应 REST 的 `futu_rest::auth::scope_for_path()`,保持一致的 fail-closed 策略。
46pub fn scope_for_tool(tool: &str) -> Option<ToolScope> {
47    match tool {
48        // ----- qot:read -----
49        "futu_ping" | "futu_get_quote" | "futu_get_snapshot" | "futu_get_kline"
50        | "futu_get_orderbook" | "futu_get_ticker" | "futu_get_rt" | "futu_get_static"
51        | "futu_get_broker" | "futu_list_plates" | "futu_plate_stocks" => {
52            Some(ToolScope::Read(Scope::QotRead))
53        }
54
55        // ----- acc:read -----
56        "futu_list_accounts" | "futu_get_funds" | "futu_get_positions" | "futu_get_orders"
57        | "futu_get_deals" => Some(ToolScope::Read(Scope::AccRead)),
58
59        // ----- trade:real / trade:simulate(运行时 env 派发) -----
60        "futu_place_order" | "futu_modify_order" | "futu_cancel_order" => Some(ToolScope::Trade),
61
62        // fail-closed
63        _ => None,
64    }
65}
66
67/// 基于注册表的只读 scope 守卫;若工具未登记则 fail-closed 拒绝。
68///
69/// 仅对 `ToolScope::Read(_)` 生效;Trade 类工具仍然走 `require_trading()`。
70pub fn require_tool_scope(state: &ServerState, tool: &'static str) -> GuardOutcome {
71    match scope_for_tool(tool) {
72        Some(ToolScope::Read(s)) => require_scope(state, tool, s),
73        Some(ToolScope::Trade) => {
74            // 防御性分支:调用方错把 trade 工具丢到 require_tool_scope 里。
75            audit(tool, None, "reject", "internal: trade tool misrouted");
76            GuardOutcome::Reject(format!(
77                "internal error: {tool} is a trade tool, must use require_trading"
78            ))
79        }
80        None => {
81            audit(tool, None, "reject", "unknown MCP tool");
82            GuardOutcome::Reject(format!("unknown MCP tool {tool:?}"))
83        }
84    }
85}
86
87/// 守卫结果:Allow 或携带拒绝原因的 JSON
88pub enum GuardOutcome {
89    Allow,
90    Reject(String),
91}
92
93impl GuardOutcome {
94    pub fn into_err_json(self) -> Option<String> {
95        match self {
96            GuardOutcome::Allow => None,
97            GuardOutcome::Reject(msg) => Some(serde_json::json!({ "error": msg }).to_string()),
98        }
99    }
100}
101
102/// 基础 scope 守卫(用于只读工具)
103///
104/// 返回 `GuardOutcome::Allow` 表示放行。legacy 模式下只读工具全放行。
105pub fn require_scope(state: &ServerState, tool: &'static str, needed: Scope) -> GuardOutcome {
106    if !state.is_scope_mode() {
107        // legacy 行为:只读工具不检查(旧用户兼容)
108        audit(tool, None, "allow", "legacy mode, no keys configured");
109        return GuardOutcome::Allow;
110    }
111
112    if state.authed_key.is_none() {
113        audit(tool, None, "reject", "no API key provided");
114        return GuardOutcome::Reject(
115            "API key required: set FUTU_MCP_API_KEY to a plaintext key listed in keys.json"
116                .to_string(),
117        );
118    }
119
120    // SIGHUP 热重载后用 id 重新 lookup,拿最新 scope/limits/expiry
121    let Some(key) = current_authed_key(state) else {
122        let id = state
123            .authed_key
124            .as_ref()
125            .map(|k| k.id.clone())
126            .unwrap_or_default();
127        audit(
128            tool,
129            Some(&id),
130            "reject",
131            "key revoked (not in current keys.json)",
132        );
133        return GuardOutcome::Reject(format!(
134            "API key {id:?} has been revoked (not in current keys.json)"
135        ));
136    };
137
138    // 过期再查一次(防止启动后过期,或 SIGHUP 后 expires_at 被改小)
139    if key.is_expired(Utc::now()) {
140        audit(tool, Some(&key.id), "reject", "key expired");
141        return GuardOutcome::Reject(format!(
142            "API key {:?} has expired (expires_at={:?})",
143            key.id, key.expires_at
144        ));
145    }
146
147    if !key.scopes.contains(&needed) {
148        audit(
149            tool,
150            Some(&key.id),
151            "reject",
152            &format!("missing scope {}", needed),
153        );
154        return GuardOutcome::Reject(format!(
155            "API key {:?} missing required scope {:?}",
156            key.id,
157            needed.as_str()
158        ));
159    }
160
161    audit(tool, Some(&key.id), "allow", "scope ok");
162    GuardOutcome::Allow
163}
164
165/// 交易写守卫:scope + legacy 兼容 + (可选)限额检查 + (可选)per-call key 覆盖
166///
167/// `env`:`"real"` / `"simulate"`;`ctx` 为 Some 时跑限额检查(下单路径)。
168///
169/// `override_key` 为 Some 时,本次调用使用这个 key(`KeyStore::verify` 一次性
170/// 拿最新 record)而不是 `state.authed_key`;典型用法:MCP 多租户,让 LLM 客户端
171/// 每个 tool call 带自己的 key。验证失败 → reject,不回落。若为 None → 用启动时
172/// 捕获的 `state.authed_key`(SIGHUP-aware fresh lookup)。
173pub fn require_trading(
174    state: &ServerState,
175    tool: &'static str,
176    env: &str,
177    ctx: Option<CheckCtx>,
178    override_key: Option<&str>,
179) -> GuardOutcome {
180    let is_real = crate::handlers::trade_write::is_real_env(env);
181    let needed_scope = if is_real {
182        Scope::TradeReal
183    } else {
184        Scope::TradeSimulate
185    };
186
187    if !state.is_scope_mode() {
188        // legacy:两级开关
189        if !state.enable_trading {
190            audit(tool, None, "reject", "legacy: --enable-trading off");
191            return GuardOutcome::Reject(
192                "trading tools are disabled. Start futu-mcp with --enable-trading to enable."
193                    .to_string(),
194            );
195        }
196        if is_real && !state.allow_real_trading {
197            audit(
198                tool,
199                None,
200                "reject",
201                "legacy: real env but --allow-real-trading off",
202            );
203            return GuardOutcome::Reject(
204                "real trading is not allowed. Use env=\"simulate\" or restart futu-mcp with --allow-real-trading."
205                    .to_string(),
206            );
207        }
208        // legacy 下 override_key 被忽略(没有 KeyStore 可 verify),
209        // 但这种配置本身就是"信任所有调用方",覆盖不覆盖不影响安全语义
210        audit(tool, None, "allow", "legacy trading allowed");
211        return GuardOutcome::Allow;
212    }
213
214    // scope 模式:先解析用哪把 key
215    let key = if let Some(plaintext) = override_key.filter(|p| !p.is_empty()) {
216        // per-call override:用传入的 plaintext 实时 verify,不走 startup 快照
217        match state.key_store.verify(plaintext) {
218            Some(rec) => rec,
219            None => {
220                audit(tool, None, "reject", "per-call api_key invalid");
221                return GuardOutcome::Reject(
222                    "per-call api_key is invalid (not in keys.json or expired/bound to wrong machine)"
223                        .to_string(),
224                );
225            }
226        }
227    } else {
228        if state.authed_key.is_none() {
229            audit(tool, None, "reject", "no API key");
230            return GuardOutcome::Reject(
231                "API key required for trading tools (set FUTU_MCP_API_KEY, or pass api_key in the tool call)"
232                    .to_string(),
233            );
234        }
235        // 同样走 SIGHUP-aware 的 fresh lookup
236        match current_authed_key(state) {
237            Some(k) => k,
238            None => {
239                let id = state
240                    .authed_key
241                    .as_ref()
242                    .map(|k| k.id.clone())
243                    .unwrap_or_default();
244                audit(tool, Some(&id), "reject", "key revoked");
245                return GuardOutcome::Reject(format!("API key {id:?} has been revoked"));
246            }
247        }
248    };
249
250    if key.is_expired(Utc::now()) {
251        audit(tool, Some(&key.id), "reject", "key expired");
252        return GuardOutcome::Reject(format!("API key {:?} has expired", key.id));
253    }
254
255    if !key.scopes.contains(&needed_scope) {
256        audit(
257            tool,
258            Some(&key.id),
259            "reject",
260            &format!("missing scope {}", needed_scope),
261        );
262        return GuardOutcome::Reject(format!(
263            "API key {:?} missing scope {:?}",
264            key.id,
265            needed_scope.as_str()
266        ));
267    }
268
269    // 限额检查(仅在提供了 ctx 时执行)
270    if let Some(ctx) = ctx {
271        match state
272            .counters
273            .check_and_commit(&key.id, &key.limits(), &ctx, Utc::now())
274        {
275            LimitOutcome::Allow => {
276                audit(tool, Some(&key.id), "allow", "scope + limits ok");
277            }
278            LimitOutcome::Reject(reason) => {
279                audit(tool, Some(&key.id), "reject", &format!("limit: {reason}"));
280                return GuardOutcome::Reject(format!("limit check failed: {reason}"));
281            }
282        }
283    } else {
284        audit(tool, Some(&key.id), "allow", "scope ok (no limits ctx)");
285    }
286
287    GuardOutcome::Allow
288}
289
290/// 审计日志:key_id / tool / result / reason
291fn audit(tool: &str, key_id: Option<&str>, result: &str, reason: &str) {
292    let key_id = key_id.unwrap_or("<none>");
293    if result == "reject" {
294        futu_auth::audit::reject("mcp", tool, key_id, reason);
295    } else {
296        futu_auth::audit::allow("mcp", tool, key_id, Some(reason));
297    }
298}
299
300/// 计算 args 的短哈希(前 8 hex),用于审计日志(不存原始敏感字段)
301pub fn args_short_hash(args: &impl serde::Serialize) -> String {
302    let j = match serde_json::to_vec(args) {
303        Ok(v) => v,
304        Err(_) => return "n/a".into(),
305    };
306    let h = Sha256::digest(&j);
307    hex::encode(&h[..4])
308}
309
310/// 交易工具执行完后的审计事件:解析 handler 返回的 JSON,success / failure
311/// 写入 audit JSONL。`key_id = None` 时用 "<none>" 占位(legacy 模式)。
312pub fn emit_trade_outcome(tool: &'static str, key_id: Option<&str>, args_hash: &str, result: &str) {
313    let key_id = key_id.unwrap_or("<none>");
314    let (outcome, reason) = match serde_json::from_str::<serde_json::Value>(result) {
315        Ok(v) => match v.get("error").and_then(|e| e.as_str()) {
316            Some(err) => ("failure", Some(err.to_string())),
317            None => ("success", None),
318        },
319        Err(_) => ("unknown", Some("non-json response".to_string())),
320    };
321    futu_auth::audit::trade("mcp", tool, key_id, args_hash, outcome, reason.as_deref());
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    /// "备忘锁":维护者加了新工具但忘了更新 `scope_for_tool`,这个测试会挂。
329    /// 对应 REST 的 `all_known_routes_have_scopes`。
330    #[test]
331    fn all_known_tools_have_scopes() {
332        let known: &[&str] = &[
333            // qot:read
334            "futu_ping",
335            "futu_get_quote",
336            "futu_get_snapshot",
337            "futu_get_kline",
338            "futu_get_orderbook",
339            "futu_get_ticker",
340            "futu_get_rt",
341            "futu_get_static",
342            "futu_get_broker",
343            "futu_list_plates",
344            "futu_plate_stocks",
345            // acc:read
346            "futu_list_accounts",
347            "futu_get_funds",
348            "futu_get_positions",
349            "futu_get_orders",
350            "futu_get_deals",
351            // trade
352            "futu_place_order",
353            "futu_modify_order",
354            "futu_cancel_order",
355        ];
356        for t in known {
357            assert!(
358                scope_for_tool(t).is_some(),
359                "tool {t:?} is declared but not mapped in scope_for_tool()"
360            );
361        }
362    }
363
364    #[test]
365    fn unknown_tool_fails_closed() {
366        assert!(scope_for_tool("futu_transfer_money").is_none());
367        assert!(scope_for_tool("").is_none());
368    }
369
370    // -------- per-call api_key override --------
371
372    use futu_auth::{KeyRecord, KeyStore};
373    use std::path::PathBuf;
374    use tempfile::TempDir;
375
376    /// 造一个带两把 key 的 KeyStore:k_sim (TradeSimulate), k_real (TradeReal)。
377    /// 返回 (store, k_sim_plaintext, k_real_plaintext, k_sim_id, k_real_id, tempdir guard)
378    fn mk_two_key_store() -> (Arc<KeyStore>, String, String, String, String, TempDir) {
379        let dir = tempfile::tempdir().unwrap();
380        let path: PathBuf = dir.path().join("keys.json");
381        let (pt_sim, r_sim) = KeyRecord::generate(
382            "bot-sim",
383            [Scope::TradeSimulate].into_iter().collect(),
384            None,
385            None,
386            None,
387        );
388        let (pt_real, r_real) = KeyRecord::generate(
389            "bot-real",
390            [Scope::TradeReal].into_iter().collect(),
391            None,
392            None,
393            None,
394        );
395        let sid = r_sim.id.clone();
396        let rid = r_real.id.clone();
397        futu_auth::store::append_key(&path, r_sim).unwrap();
398        futu_auth::store::append_key(&path, r_real).unwrap();
399        let store = Arc::new(KeyStore::load(&path).unwrap());
400        (store, pt_sim, pt_real, sid, rid, dir)
401    }
402
403    fn mk_state(store: Arc<KeyStore>, startup_plaintext: &str) -> ServerState {
404        let rec = store.verify(startup_plaintext);
405        ServerState::new("dummy:0".to_string())
406            .with_key_store(store)
407            .with_authed_key(rec)
408    }
409
410    #[test]
411    fn override_key_none_uses_startup_key() {
412        let (store, pt_sim, _pt_real, _, _, _dir) = mk_two_key_store();
413        let state = mk_state(store, &pt_sim);
414        // k_sim 只有 TradeSimulate;env=simulate → allow
415        let r = require_trading(&state, "futu_place_order", "simulate", None, None);
416        assert!(matches!(r, GuardOutcome::Allow));
417        // env=real → reject(k_sim 无 TradeReal)
418        let r = require_trading(&state, "futu_place_order", "real", None, None);
419        assert!(matches!(r, GuardOutcome::Reject(_)));
420    }
421
422    #[test]
423    fn override_key_some_valid_uses_that_key_scope() {
424        let (store, pt_sim, pt_real, _, _, _dir) = mk_two_key_store();
425        // 启动时用 k_sim(只能 simulate),但这次调用带 k_real 的 plaintext
426        // → 这次允许 real 交易
427        let state = mk_state(store, &pt_sim);
428        let r = require_trading(
429            &state,
430            "futu_place_order",
431            "real",
432            None,
433            Some(pt_real.as_str()),
434        );
435        assert!(matches!(r, GuardOutcome::Allow), "override 应让 real 通过");
436    }
437
438    #[test]
439    fn override_key_invalid_is_rejected_not_fallback() {
440        let (store, pt_sim, _pt_real, _, _, _dir) = mk_two_key_store();
441        let state = mk_state(store, &pt_sim);
442        // 传一个不在 keys.json 里的假 plaintext
443        let r = require_trading(
444            &state,
445            "futu_place_order",
446            "simulate",
447            None,
448            Some("deadbeef"),
449        );
450        // 必须拒绝,不能回落到 startup key —— 否则攻击者塞任何字串都能"升级"到 startup key
451        assert!(matches!(r, GuardOutcome::Reject(_)));
452        if let GuardOutcome::Reject(msg) = r {
453            assert!(
454                msg.contains("per-call api_key is invalid"),
455                "错误文案: {msg}"
456            );
457        }
458    }
459
460    #[test]
461    fn override_key_empty_string_treated_as_none() {
462        // 用户传了 api_key="" → 等同于没传,应该回落到 startup key
463        let (store, pt_sim, _pt_real, _, _, _dir) = mk_two_key_store();
464        let state = mk_state(store, &pt_sim);
465        let r = require_trading(&state, "futu_place_order", "simulate", None, Some(""));
466        assert!(matches!(r, GuardOutcome::Allow));
467    }
468
469    #[test]
470    fn override_key_scope_mismatch_is_rejected() {
471        // override 到 k_sim(只有 TradeSimulate)但想 trade real → reject
472        let (store, pt_sim, _pt_real, _, _, _dir) = mk_two_key_store();
473        let state = mk_state(store, &pt_sim);
474        let r = require_trading(
475            &state,
476            "futu_place_order",
477            "real",
478            None,
479            Some(pt_sim.as_str()),
480        );
481        assert!(matches!(r, GuardOutcome::Reject(_)));
482    }
483}