Skip to main content

futu_mcp/state/
push_subscribers.rs

1use std::collections::HashSet;
2use std::time::Instant;
3
4use rmcp::{RoleServer, service::Peer};
5
6use super::ServerState;
7use super::push_filter::subscriber_visible_to_caller;
8
9/// v1.4.38 Phase 5: 订阅了 push 通知的 MCP 客户端 session 记录。
10///
11/// 每个 session 用 `futu_sub_acc_push` 注册时登记一条。daemon push 事件来到
12/// MCP server 时,按 `acc_ids` 过滤后用 `peer.notify_logging_message()` 推回。
13///
14/// v1.4.38 100%:`acc_ids` 过滤已生效(state.rs drain loop 实装)。
15/// caller key ownership / scope 快照在注册时解析并保存,后续 push 分发不再
16/// 重新读取 bearer 明文。
17#[derive(Clone)]
18pub(super) struct PushSubscriber {
19    /// rmcp 对 MCP session 的抽象。clone 便宜(内部 Arc)。
20    pub peer: Peer<RoleServer>,
21    /// 该 session 关心的 acc_id 列表。空集合表示"不过滤"(接收所有 acc 的 push)。
22    pub acc_ids: HashSet<u64>,
23    /// v1.4.39 per-key acc_id 白名单**注册时快照**(非 live-reload)。
24    ///
25    /// Some(set) + non-empty → push 的 acc_id 必须在 set 里才推。
26    /// Some(empty) / None → 不做 key 级过滤(兼容无 allowed_acc_ids 约束的 key
27    /// 或 stdio / legacy 模式)。
28    ///
29    /// 快照语义:注册后 SIGHUP 重载 keys.json 修改 allowed_acc_ids 不会立即
30    /// 反映到已注册订阅者。用户需重新 `futu_sub_acc_push` 才应用新 scope。
31    /// 这是 defense-in-depth 层(主 auth 在 tool 调用时 guard.rs),可接受。
32    pub allowed_acc_ids_snapshot: Option<HashSet<u64>>,
33    /// v1.4.105 T-C2: per-key `allowed_markets` 注册时快照, Layer 3 trade push gate.
34    ///
35    /// `Some(set)` + non-empty → push event 的 `trd_market` 必须 ∈ set 才推 (按
36    /// `Trd_Common.TrdMarket` int → 字符串映射, 与 `keys.json::allowed_markets`
37    /// 配置字符串一致, e.g. "HK"/"US"/"FUTURES").
38    /// `None` / `Some(empty)` → 不做 market gate (兼容 stdio / legacy / 未配
39    /// allowed_markets 的 key).
40    ///
41    /// 与 `allowed_acc_ids_snapshot` 同样**注册时快照**, SIGHUP 重载不影响
42    /// 已注册订阅者. 用户需重新 `futu_sub_acc_push` 才应用新 scope.
43    /// 配套 main auth (guard.rs / require_acc_read_with_acc_id) 仍在 tool 调用
44    /// 时 enforce, 这是 defense-in-depth 层 (push 走 server-initiated channel
45    /// 绕过 tool 调用 → 必须独立 enforce).
46    pub allowed_markets_snapshot: Option<HashSet<String>>,
47    /// 注册时间(用于 session 硬上限清理,4h 默认 TTL)
48    pub registered_at: Instant,
49    /// v1.4.103 (codex 50 F6 / 53 F4 — B8): owner key id (KeyRecord.id).
50    /// 注册时填的 caller key id (HTTP Bearer 或 startup key); 用于 unsub
51    /// ownership check — 任何 caller 拿到 session_id 后想 unsub 必须 key id
52    /// 匹配 owner_key_id (admin scope 例外).
53    ///
54    /// None = legacy / stdio 模式无 key (ownership 退化为 "anyone can unsub",
55    /// 与本来 v1.4.102 行为一致).
56    pub owner_key_id: Option<String>,
57}
58
59impl ServerState {
60    /// v1.4.38 Phase 5: 注册当前 session 接收指定 acc_id 的 push。返回 session
61    /// UUID(调用方存着,后续可 unregister)。
62    ///
63    /// v1.4.38: 已 wire 到 `futu_sub_acc_push` tool。tool 被调用时拿到
64    /// `RequestContext.peer`,`acc_ids` 从工具 args 解析,注册完成后
65    /// state.rs 的 push drain loop 会按 acc_ids filter 转 notify 给该 peer。
66    /// v1.4.103 (codex 50 F5 / 53 F2 / 58 F3 — B7) + (codex 50 F6 / 53 F4 — B8):
67    /// 注册当前 session 接收指定 acc_id 的 push。
68    ///
69    /// `owner_key_id_override` 由 caller 传入 (例如从 HTTP Bearer 解析得到 key id);
70    /// 若 None → fall back 到 bearer_token 解析 → fall back 到 startup `authed_key.id`.
71    /// `allowed_acc_ids_snapshot` 同样 fall back 链: bearer → startup.
72    pub async fn register_push_subscriber_with_owner(
73        &self,
74        peer: Peer<RoleServer>,
75        acc_ids: HashSet<u64>,
76        bearer_token: Option<String>,
77        owner_key_id_override: Option<String>,
78    ) -> String {
79        let session_id = format!("sub-{}", rand::random::<u64>());
80
81        // 解析 caller-specific KeyRecord (HTTP Bearer 优先, fallback startup key).
82        // 用于 (a) allowed_acc_ids_snapshot (B7) (b) owner_key_id (B8).
83        //
84        // v1.4.106 codex 0608 F2 (P1): startup fallback 路径用
85        // `get_by_id_for_current_machine` 替代裸 `get_by_id`, 与 verify (Bearer 路径
86        // 自带 machine 校验) 行为对称, 让 SIGHUP 收紧 allowed_machines 后能立即拒绝.
87        let bearer_key_rec = bearer_token
88            .as_deref()
89            .filter(|pt| !pt.is_empty())
90            .and_then(|pt| self.key_store.verify(pt));
91        let startup_key_rec = self
92            .authed_key
93            .as_ref()
94            .and_then(|k| self.key_store.get_by_id_for_current_machine(&k.id));
95        let effective_key_rec = bearer_key_rec.as_ref().or(startup_key_rec.as_ref());
96
97        // v1.4.103 (B7): allowed_acc_ids_snapshot 优先 HTTP Bearer 解析,
98        // 否则 fall back startup key.allowed_acc_ids; 都无 → None (无限制).
99        let allowed_acc_ids_snapshot =
100            effective_key_rec.and_then(|rec| rec.allowed_acc_ids.clone());
101
102        // v1.4.105 T-C2: allowed_markets_snapshot 同链 — 与 acc_ids_snapshot
103        // 同样从 effective_key_rec (Bearer / startup) 取 allowed_markets.
104        // 用于 push event Layer 3 market gate (state.rs::subscriber_should_receive_with_market).
105        let allowed_markets_snapshot =
106            effective_key_rec.and_then(|rec| rec.allowed_markets.clone());
107
108        // v1.4.103 (B8): owner_key_id 优先 caller 显式传入, 否则 effective_key_rec.id.
109        let owner_key_id =
110            owner_key_id_override.or_else(|| effective_key_rec.map(|rec| rec.id.clone()));
111
112        let subscriber = PushSubscriber {
113            peer,
114            acc_ids,
115            allowed_acc_ids_snapshot,
116            allowed_markets_snapshot,
117            registered_at: Instant::now(),
118            owner_key_id,
119        };
120        self.push_subscribers
121            .lock()
122            .await
123            .insert(session_id.clone(), subscriber);
124        session_id
125    }
126
127    /// v1.4.103 (codex 50 F6 / 53 F4 — B8): unsub session ownership check.
128    ///
129    /// 行为:
130    /// - 无 caller_key_id (legacy / stdio): 退化为旧行为 (任何 caller 可 unsub).
131    /// - 有 caller_key_id + subscriber.owner_key_id 匹配: 删除, 返 Ok(true).
132    /// - 有 caller_key_id + subscriber.owner_key_id 不匹配: **拒绝**, 返
133    ///   Err(reason) — 防其他 caller 拿可见 session_id 强制 unsub.
134    /// - session_id 不存在: 返 Ok(false) (idempotent, 不报错).
135    /// - subscriber.owner_key_id = None (legacy 注册): 退化为旧行为 — 任何 caller
136    ///   可 unsub (向后兼容).
137    pub async fn unregister_push_subscriber_with_owner_check(
138        &self,
139        session_id: &str,
140        caller_key_id: Option<&str>,
141    ) -> Result<bool, String> {
142        let mut subs = self.push_subscribers.lock().await;
143        // 不存在 → idempotent Ok(false), 不报错
144        let Some(sub) = subs.get(session_id) else {
145            return Ok(false);
146        };
147        // ownership check
148        match (caller_key_id, sub.owner_key_id.as_deref()) {
149            (None, _) => {
150                // 无 caller key: legacy / stdio 模式, 退化旧行为
151                subs.remove(session_id);
152                Ok(true)
153            }
154            (Some(caller), None) => {
155                // session 注册时无 owner_key_id (legacy): 任何 caller 可 unsub
156                tracing::warn!(
157                    session_id,
158                    caller,
159                    "v1.4.103 B8: unsub legacy session (no owner_key_id) — \
160                     allowed for backward-compat"
161                );
162                subs.remove(session_id);
163                Ok(true)
164            }
165            (Some(caller), Some(owner)) if caller == owner => {
166                subs.remove(session_id);
167                Ok(true)
168            }
169            (Some(caller), Some(owner)) => {
170                // ownership 不匹配: reject. 当前 MCP subscription ownership contract
171                // 没有 admin override surface;若要扩展,必须先在 spec / auth
172                // pipeline / integration tests 中定义清楚。
173                Err(format!(
174                    "session_id {session_id:?} owned by key_id {owner:?}, \
175                     caller key_id {caller:?} not allowed to unsub"
176                ))
177            }
178        }
179    }
180
181    /// 当前活跃订阅数。生产诊断使用 `push_subscribers_summary`,这里只作为
182    /// state 单测的轻量断言入口保留。
183    #[cfg(test)]
184    pub async fn push_subscriber_count(&self) -> usize {
185        self.push_subscribers.lock().await.len()
186    }
187
188    /// v1.4.58 Phase A: 列出所有 push 订阅 summary(tool diagnostic 用)。
189    ///
190    /// 返 Vec<(session_id, acc_ids, age_secs)>。
191    ///
192    /// **MED-NEW-3 修(2nd review)**:加 `caller_allowed_acc_ids` 参数做
193    /// scope-mode 多租过滤。当 caller 的 key 有 `allowed_acc_ids` 白名单时,
194    /// **只返** subscription 的 `acc_ids` 与 caller 白名单有交集的条目。
195    /// 避免 agent A(acc_ids=[100, 200])通过本 tool 看到 agent B 订阅的
196    /// acc_id=[300, 400]。
197    ///
198    /// `caller_allowed_acc_ids=None` / empty → 不过滤(legacy mode / no-scope key)。
199    ///
200    /// **rmcp 版本兼容**:rmcp 1.4.0 `Peer<RoleServer>` 不实装 `PartialEq`,
201    /// 无法按 peer 身份直接过滤。若未来 rmcp 加 PartialEq,可切到更精确的
202    /// per-session-owner 过滤(当前只能靠 acc_id 权限交集近似)。
203    pub async fn push_subscribers_summary(
204        &self,
205        caller_allowed_acc_ids: Option<&HashSet<u64>>,
206    ) -> Vec<(String, HashSet<u64>, u64)> {
207        let subs = self.push_subscribers.lock().await;
208        let now = Instant::now();
209        subs.iter()
210            .filter(|(_, sub)| subscriber_visible_to_caller(&sub.acc_ids, caller_allowed_acc_ids))
211            .map(|(id, sub)| {
212                let age = now
213                    .checked_duration_since(sub.registered_at)
214                    .map(|d| d.as_secs())
215                    .unwrap_or(0);
216                // LOW-3RD-1(3rd code review):scope mode 下返回的 acc_ids 要与
217                // caller allowed 求交集 — 防止 caller=[100] 看到 sub=[100, 999]
218                // 时知道 999 这个 acc 存在。sub.acc_ids 空集(subscribe-all)不做
219                // 交集(概念上 caller 看到的是"有个 catch-all subscriber",不泄漏
220                // 具体账户信息)。
221                let visible_accs = match caller_allowed_acc_ids {
222                    Some(allowed) if !allowed.is_empty() && !sub.acc_ids.is_empty() => {
223                        sub.acc_ids.intersection(allowed).copied().collect()
224                    }
225                    _ => sub.acc_ids.clone(),
226                };
227                (id.clone(), visible_accs, age)
228            })
229            .collect()
230    }
231}