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