Skip to main content

futu_mcp/tool_auth/
policy.rs

1use std::collections::HashSet;
2use std::sync::Arc;
3
4use rmcp::{RoleServer, service::RequestContext};
5
6/// Caller authenticated identity snapshot returned by MCP auth guards.
7/// Captured once at auth time; subsequent response filtering / push subscriber
8/// registration / visibility uses this snapshot rather than re-resolving from
9/// Bearer/startup (防 SIGHUP reload race / drift between auth decision and side
10/// effect).
11///
12/// `rec=None` 表示 legacy mode (KeyStore 未 configured) — 全放行,
13/// allowed_acc_ids = None (无限制).
14#[derive(Clone)]
15pub(crate) struct CallerSnapshot {
16    /// caller's KeyRecord at auth time. legacy mode -> None.
17    pub rec: Option<Arc<futu_auth::KeyRecord>>,
18    /// caller's key_id (legacy mode -> None).
19    pub key_id: Option<String>,
20    /// caller's allowed_acc_ids snapshot (HashSet clone, owned).
21    /// None = 无限制 (无 KeyRecord 或 KeyRecord 没设).
22    pub allowed_acc_ids: Option<HashSet<u64>>,
23    /// HTTP Authorization Bearer token snapshot. legacy mode / stdio -> None.
24    pub bearer_token: Option<String>,
25}
26
27/// Compute the audit key id from the same snapshot used by the write precheck.
28/// This prevents SIGHUP reload between daemon dispatch and audit emission from
29/// re-attributing an outcome to the startup key or `<none>`.
30pub(crate) fn outcome_key_id_from_snapshot<'a>(
31    caller_key_rec: Option<&'a Arc<futu_auth::KeyRecord>>,
32    authed_key_at_precheck: Option<&'a Arc<futu_auth::KeyRecord>>,
33) -> Option<&'a str> {
34    caller_key_rec
35        .map(|r| r.id.as_str())
36        .or_else(|| authed_key_at_precheck.map(|k| k.id.as_str()))
37}
38
39/// Pure decision logic for early trade-scope check.
40///
41/// Pulled out of `FutuServer::require_trading_scope_only` so unit tests can
42/// exercise the policy without instantiating a full FutuServer.
43#[derive(Debug, PartialEq, Eq)]
44pub(crate) enum EarlyTradeScopeDecision {
45    /// 放行 (legacy mode, 或 caller 含所需 scope)
46    Allow,
47    /// caller key snapshot 缺失 (防御性 reject)
48    RejectMissingCallerKey,
49    /// 缺所需 trade scope
50    RejectMissingScope {
51        needed: futu_auth::Scope,
52        key_id: String,
53    },
54}
55
56pub(crate) fn decide_early_trade_scope(
57    env: &str,
58    is_scope_mode: bool,
59    caller_key_rec: Option<&Arc<futu_auth::KeyRecord>>,
60) -> EarlyTradeScopeDecision {
61    // legacy 模式 (无 keys.json) 由 `require_trading` 后续 gate 处理
62    // (legacy toggle + allow_real_trading), 此处放行.
63    if !is_scope_mode {
64        return EarlyTradeScopeDecision::Allow;
65    }
66
67    let is_real = crate::handlers::trade_write::is_real_env(env);
68    let needed_scope = if is_real {
69        futu_auth::Scope::TradeReal
70    } else {
71        futu_auth::Scope::TradeSimulate
72    };
73
74    let Some(rec) = caller_key_rec else {
75        return EarlyTradeScopeDecision::RejectMissingCallerKey;
76    };
77
78    if !rec.scopes.contains(&needed_scope) {
79        return EarlyTradeScopeDecision::RejectMissingScope {
80            needed: needed_scope,
81            key_id: rec.id.clone(),
82        };
83    }
84
85    EarlyTradeScopeDecision::Allow
86}
87
88/// Scope enum -> human-readable label for early-reject error messages.
89pub(super) fn scope_label(s: futu_auth::Scope) -> &'static str {
90    match s {
91        futu_auth::Scope::TradeReal => "trade:real",
92        futu_auth::Scope::TradeSimulate => "trade:simulate",
93        _ => "trade",
94    }
95}
96
97/// Extract HTTP `Authorization: Bearer <token>` from rmcp `RequestContext`.
98///
99/// Only HTTP transport has `http::request::Parts` in `ctx.extensions`; stdio
100/// returns None.  Auth scheme parsing is shared with other surfaces through
101/// `futu_auth_pipeline::parse_bearer_scheme`.
102pub(crate) fn http_bearer_token(ctx: &RequestContext<RoleServer>) -> Option<String> {
103    let parts = ctx.extensions.get::<http::request::Parts>()?;
104    let v = parts
105        .headers
106        .get("authorization")
107        .and_then(|v| v.to_str().ok())?;
108    futu_auth_pipeline::parse_bearer_scheme(v).map(|t| t.to_string())
109}
110
111/// Build the audit correlation context for one MCP JSON-RPC request.
112///
113/// rmcp exposes a stable request id for both stdio and HTTP transports, but it
114/// does not expose the socket peer address at this layer. Do not synthesize a
115/// fake IP; the request id is still enough to correlate all audit events caused
116/// by one tool call.
117pub(super) fn mcp_audit_context(
118    req_ctx: &RequestContext<RoleServer>,
119) -> futu_auth::audit::AuditContext {
120    let session_id = format!("mcp:{}", req_ctx.id);
121    futu_auth::audit::AuditContext::new(None::<&str>, Some(session_id.as_str()))
122}
123
124pub(super) fn audit_reject_with_context(
125    ctx: &futu_auth::audit::AuditContext,
126    tool: &str,
127    key_id: &str,
128    reason: &str,
129) {
130    futu_auth::audit::with_context(ctx.clone(), || {
131        futu_auth::audit::reject("mcp", tool, key_id, reason);
132    });
133}