futu_auth/scope.rs
1//! Scope: 能力分组
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8/// API Key 能力分组
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(try_from = "String", into = "String")]
11#[non_exhaustive]
12pub enum Scope {
13 /// 行情只读(11 个工具)
14 QotRead,
15 /// 账户只读(5 个工具)
16 AccRead,
17 /// 模拟交易写
18 TradeSimulate,
19 /// 真实交易写
20 TradeReal,
21 /// 允许自动 unlock_trade(从 keychain 读密码)
22 TradeUnlock,
23 /// v1.4.32+ daemon 管理 (`/api/admin/status|reload|shutdown`)。
24 /// 权限危险,只给运维 / 监控 key;LLM key 永远不要加这个。
25 Admin,
26 /// v1.4.90 P1-A: trade 类 super-scope。**仅在 REST middleware
27 /// `scope_for_path` 用作"需要任意 trade* scope"的占位需求**:持有
28 /// [`Scope::TradeReal`] / [`Scope::TradeSimulate`] / [`Scope::TradeUnlock`]
29 /// 任一即满足。**不应**写入 keys.json(KeyRecord.scopes 里出现
30 /// `Scope::Trade` 没意义,等价于不分 sim/real/unlock 的旧式权限)。
31 /// env 是 sim 还是 real 由 handler 层用 KeyRecord 真实 scopes 二次校验。
32 Trade,
33 /// v1.4.106 codex 0542 F1 [P2 SECURITY]: `/metrics` 端点 scope-gated 的
34 /// 专用 scope. default secure — 不再像 v1.4.105 之前那样无 auth 暴露
35 /// `key_id` 标签 (= API key id 明文 cardinality enumeration channel,
36 /// 任意本机 process / agent skill 都能 fingerprint).
37 ///
38 /// **行为**:
39 /// - 持 `MetricsRead` 的 key → `/metrics` 通过, `key_id=` label 仍 redact
40 /// 为 `kh_<8hex>` (短 SHA256 hash, 反查 key id 需要离线 dictionary 攻击)
41 /// - 不持 `MetricsRead` → 401 (legacy 模式) 或 403
42 /// - **opt-out**: 老用户 dashboard 依赖明文 key_id 时设
43 /// `FUTU_METRICS_PUBLIC=1` 环境变量回退 v1.4.105 行为 (无 auth + 明文
44 /// key_id). 此为 backward-compat 边界 trade-off — secure default + 明示
45 /// opt-out, 而非 opt-in.
46 ///
47 /// 与 [`Scope::Admin`] 区别: Admin 含 mutating endpoint (shutdown/reload),
48 /// MetricsRead 仅 read-only Prometheus 抓取. dashboard / Prometheus
49 /// scraper 应持 MetricsRead 而不是 Admin.
50 MetricsRead,
51}
52
53impl Scope {
54 pub const ALL: &'static [Scope] = &[
55 Scope::QotRead,
56 Scope::AccRead,
57 Scope::TradeSimulate,
58 Scope::TradeReal,
59 Scope::TradeUnlock,
60 Scope::Admin,
61 Scope::Trade,
62 Scope::MetricsRead,
63 ];
64
65 #[must_use]
66 pub fn as_str(&self) -> &'static str {
67 match self {
68 Scope::QotRead => "qot:read",
69 Scope::AccRead => "acc:read",
70 Scope::TradeSimulate => "trade:simulate",
71 Scope::TradeReal => "trade:real",
72 Scope::TradeUnlock => "trade:unlock",
73 Scope::Admin => "admin",
74 Scope::Trade => "trade",
75 Scope::MetricsRead => "metrics:read",
76 }
77 }
78
79 /// v1.4.90 P1-A: super-scope `Scope::Trade` 的成员集合。REST
80 /// middleware 在 mutating trade endpoint 的需求侧用 `Scope::Trade`
81 /// 占位,持有任一成员即视为满足;handler 层再用真实 scopes
82 /// 二次校验 env (sim/real/unlock).
83 #[must_use]
84 pub fn trade_super_members() -> &'static [Scope] {
85 &[Scope::TradeReal, Scope::TradeSimulate, Scope::TradeUnlock]
86 }
87}
88
89impl fmt::Display for Scope {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.write_str(self.as_str())
92 }
93}
94
95#[derive(Debug, thiserror::Error)]
96#[error(
97 "unknown scope {0:?} (valid: qot:read, acc:read, trade:simulate, trade:real, trade:unlock, admin, metrics:read)"
98)]
99pub struct ScopeParseError(pub String);
100
101impl FromStr for Scope {
102 type Err = ScopeParseError;
103
104 fn from_str(s: &str) -> Result<Self, Self::Err> {
105 match s {
106 "qot:read" => Ok(Scope::QotRead),
107 "acc:read" => Ok(Scope::AccRead),
108 "trade:simulate" => Ok(Scope::TradeSimulate),
109 "trade:real" => Ok(Scope::TradeReal),
110 "trade:unlock" => Ok(Scope::TradeUnlock),
111 "admin" => Ok(Scope::Admin),
112 // 注意:`"trade"` 仅作为 super-scope 内部占位 (Scope::Trade),
113 // 不应出现在 keys.json 里;这里保留 FromStr 是为了 roundtrip
114 // 测试与 `as_str()` 对称。
115 "trade" => Ok(Scope::Trade),
116 // v1.4.106 codex 0542 F1 [P2 SECURITY]: /metrics 端点专用 scope.
117 "metrics:read" => Ok(Scope::MetricsRead),
118 other => Err(ScopeParseError(other.to_string())),
119 }
120 }
121}
122
123impl TryFrom<String> for Scope {
124 type Error = ScopeParseError;
125 fn try_from(value: String) -> Result<Self, Self::Error> {
126 value.parse()
127 }
128}
129
130impl From<Scope> for String {
131 fn from(s: Scope) -> String {
132 s.as_str().to_string()
133 }
134}
135
136/// Futu API protocol id → 所需 scope 的**通用映射**
137///
138/// gRPC 和核心 WS 都用这个函数做 scope 检查。proto_id 常量定义在 futu-core
139/// (circular dep 顾虑下这里手动枚举);新增 proto 时必须同步更新这里的 match
140/// 分支,否则落到 catch-all `TradeReal` 被拒(fail-closed)。
141///
142/// **v1.4.104 codex round 1 F4 (P2) fix**: 显式 trade/acc protos 用
143/// [`SCOPED_TRADE_REAL_PROTOS`] / [`SCOPED_TRADE_UNLOCK_PROTOS`] /
144/// [`SCOPED_ACC_READ_PROTOS`] 暴露给 invariant test, 让
145/// `body_aware::build_check_ctxs` + `response_filter::FilterRegistry` 共同
146/// 覆盖. 加新 scoped proto 时:
147/// 1. match 分支加 → 让 scope check 知道新 proto
148/// 2. 把 proto_id 加到对应的 `SCOPED_*_PROTOS` const list (机械 enumeration)
149/// 3. 其中 一处 (body_aware OR response_filter OR EXPLICIT_NO_ACC_ID_PROTOS)
150/// 必须 cover, 否则 cross_surface_invariants test 挂.
151///
152/// | proto_id 范围 | 所需 scope |
153/// |---|---|
154/// | 1xxx 系统(InitConnect / GetGlobalState / KeepAlive / …) | 无(放行) |
155/// | 3xxx 行情(含 push updates) | `qot:read` |
156/// | 2005 UnlockTrade | `trade:unlock`(v1.4.104 codex F1 P1 fix) |
157/// | 2202 PlaceOrder / 2205 ModifyOrder / 2237 ReconfirmOrder | `trade:real` |
158/// | 2xxx 账户只读(AccList / Funds / Positions / Orders / Deals / 费率 / push) | `acc:read` |
159/// | 其他 | catch-all `trade:real`(fail-closed) |
160pub fn scope_for_proto_id(proto_id: u32) -> Option<Scope> {
161 match proto_id {
162 // v1.4.110 GetUsedQuota / v1.4.98 T2-8 GET_TOKEN_STATE 落在 1xxx
163 // 范围, 但有明确业务权限语义. 单独前置, 避免被下面
164 // 1000..=1999 => None 兜住.
165 1010 => Some(Scope::QotRead),
166 // NN+MM token 状态查询, unlock-trade 失败时第一线诊断.
167 // 否则被下面 1000..=1999 => None 兜住.
168 1326 => Some(Scope::AccRead),
169
170 // 1xxx 系统 / 连接管理:InitConnect / GlobalState / KeepAlive / UserInfo ...
171 1000..=1999 => None,
172
173 // 3xxx 全部行情(请求 + push 全挂 qot:read)
174 3000..=3999 => Some(Scope::QotRead),
175
176 // 2005 UnlockTrade —— v1.4.104 codex round 1 F1 (P1) fix:
177 // 之前 mapping 是 TradeReal (推理 "未解锁不能下单, 视同 trade:real"
178 // 是错的). UnlockTrade 是独立 scope:caller 持 trade:unlock 才能解锁,
179 // 不应让 trade:real 通过. v1.4.103 codex F5.3 已让 MCP futu_unlock_trade
180 // 走 trade:unlock, 但 gRPC/raw WS 直调 proto 2005 时仍走 TradeReal —
181 // narrow Bearer (trade:real only, 无 trade:unlock) 可绕过 unlock scope.
182 // v1.4.104 阶段 7-5 改 MCP futu_unlock_trade 走 caller-specific
183 // pipeline (TradeUnlock check), 但 proto 2005 mapping 还是 TradeReal —
184 // codex round 1 F1 抓出 silent gap. 现统一改 TradeUnlock 关闭 4 surface
185 // 一致.
186 2005 => Some(Scope::TradeUnlock),
187
188 // 2202 PlaceOrder / 2205 ModifyOrder / 2237 ReconfirmOrder
189 2202 | 2205 | 2237 => Some(Scope::TradeReal),
190
191 // 2xxx 账户只读:list / funds / positions / orders / deals / push / 费率
192 2001 | 2008 | 2101 | 2102 | 2111 | 2201 | 2208 | 2211 | 2218 | 2221 | 2222 | 2223
193 | 2225 | 2226 | 2240 => Some(Scope::AccRead),
194
195 // v1.4.94 / v1.4.95 Tier M (mobile-driven extensions, 22701-22710):
196 // 全 only-read 性质 (账户资金 / 业务分组 / margin / 合规 / 债券 holdings) →
197 // acc:read scope 统一. 不显式覆盖会 fall-through 到 TradeReal,
198 // 让 acc:read-only 的 LLM agent 调不到这些 endpoint.
199 //
200 // | proto_id | endpoint | 含义 |
201 // |----------|---------------------------|-------------------|
202 // | 22701 | TRD_GET_CASH_LOG | v1.4.94 M1 |
203 // | 22702 | TRD_GET_CASH_DETAIL | v1.4.94 M1 |
204 // | 22703 | TRD_GET_BIZ_GROUP | v1.4.94 M1 |
205 // | 22704 | TRD_GET_MARGIN_INFO | v1.4.95 U2-D |
206 // | 22705 | TRD_GET_ACCOUNT_FLAG | v1.4.95 U2-A |
207 // | 22706 | TRD_GET_BOND_TOTAL_ASSET | v1.4.95 U2-B |
208 // | 22707 | TRD_GET_BOND_SINGLE_ASSET | v1.4.95 U2-B |
209 // | 22708 | TRD_GET_BOND_POSITION_LIST| v1.4.95 U2-B |
210 // | 22709 | TRD_GET_BOND_ANSWER_STATE | v1.4.95 U2-B |
211 // | 22710 | TRD_GET_BOND_TRADE_REMIND | v1.4.95 U2-B |
212 22701..=22710 => Some(Scope::AccRead),
213
214 // v1.4.98 T2-* (mobile-source-audit Phase 2): quote 类 read-only endpoint.
215 // - 6503 QOT_GET_SPREAD_TABLE: 摆盘步长
216 // - 20231 QOT_GET_RISK_FREE_RATE: 无风险利率 (期权定价)
217 // - 6365 / 6366 QOT_GET_TICKER_STATISTIC: 逐笔统计 + push
218 // (cmd 1326 GET_TOKEN_STATE 已前置 acc:read, 避免 1xxx None 兜底)
219 6503 | 6365 | 6366 | 20231 => Some(Scope::QotRead),
220
221 // 未覆盖 → fail-closed,统一拒(返回 TradeReal 让上游 check_scope 比对最严格)
222 _ => Some(Scope::TradeReal),
223 }
224}
225
226// ─────────────────────────────────────────────────────────────────────────────
227// v1.4.104 codex round 1 F4 (P2) fix: scoped proto_id 机械枚举
228//
229// 让 `futu-auth-pipeline::body_aware` / `response_filter` / 显式 exception
230// list 通过 `coverage_invariant` 测试**机械**对齐 — 加新 scoped proto 时
231// 漏一处必挂. 与 v1.4.103/104 之前 hand-maintained covered vec 不同, 现
232// 不再依赖人记忆.
233// ─────────────────────────────────────────────────────────────────────────────
234
235/// 显式 enumerate 所有需要 acc_id 白名单或响应 filter 的 trade write proto_id.
236/// `body_aware::build_check_ctxs` 必须 decode 这些 proto.
237pub const SCOPED_TRADE_REAL_PROTOS: &[u32] = &[
238 2202, // TRD_PLACE_ORDER
239 2205, // TRD_MODIFY_ORDER
240 2237, // TRD_RECONFIRM_ORDER
241];
242
243/// 显式 enumerate trade unlock proto_id (caller-specific TradeUnlock scope).
244/// v1.4.104 codex F1 (P1) 加.
245pub const SCOPED_TRADE_UNLOCK_PROTOS: &[u32] = &[
246 2005, // TRD_UNLOCK_TRADE
247];
248
249/// 显式 enumerate acc:read proto_id. 大多数走 `body_aware` decode acc_id
250/// whitelist. 例外见 [`EXPLICIT_NO_ACC_ID_PROTOS`].
251pub const SCOPED_ACC_READ_PROTOS: &[u32] = &[
252 1326, // GET_TOKEN_STATE — 无 acc_id, 走 explicit exception
253 2001, // TRD_GET_ACC_LIST — request 无 acc_id, 走 response-filter
254 2008, // TRD_SUB_ACC_PUSH (multi acc_id_list)
255 2101, 2102, 2111, // funds / positions / max_trd_qtys
256 2201, 2208, 2211, 2218, // order list / order_update push / fill list / fill_update push
257 2221, 2222, // history orders / history fills
258 2223, 2225, 2226, // margin ratio / order fee / flow summary
259 2240, // notify push
260 // Tier M (v1.4.94/95)
261 22701, 22702, 22703, // cash log / detail / biz group
262 22704, // margin info
263 22705, // account flag
264 22706, 22707, 22708, 22709, 22710, // bond × 5
265];
266
267/// **v1.4.106 ζ28 redo (codex 0532 F4 P3)**: typed coverage exception kind
268/// — 替代无类型 `EXPLICIT_NO_BODY_AWARE_PROTOS` 数组. 每个 exception 必须
269/// 显式分类, 让 "为什么这个 proto 不走 body_aware" 的意图保留在代码里
270/// (而非靠注释推).
271///
272/// 4 个 variant 涵盖所有 "非 body-aware" 场景:
273///
274/// - [`Self::ResponseFiltered`] — request 无 acc_id, 但 response 含 acc_list[]
275/// 走 [`futu_auth_pipeline::FilterRegistry`] (e.g. 2001 TRD_GET_ACC_LIST).
276/// - [`Self::PushOnly`] — push event 不是 request, 无 request-side body
277/// (e.g. 2208 TRD_UPDATE_ORDER / 2218 TRD_UPDATE_ORDER_FILL / 2240 TRD_NOTIFY).
278/// pipeline 不应 dispatch push proto 作 request, 但 scope check 仍跑.
279/// - [`Self::MetaNoAccount`] — meta query 无 acc_id 概念 (e.g. 1326
280/// GET_TOKEN_STATE NN/MM token 状态).
281/// - [`Self::InternalOnly`] — daemon-internal proto_id (高位 0x8000_0000 bit),
282/// 不应从公开 surface 进入 (gRPC / raw WS / raw TCP). v1.4.106 codex 0532 F3
283/// public surface 显式 reject (见 [`is_internal_proto_id`]).
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285#[non_exhaustive]
286pub enum CoverageException {
287 ResponseFiltered,
288 PushOnly,
289 MetaNoAccount,
290 InternalOnly,
291}
292
293/// `(proto_id, CoverageException)` 显式分类表 — v1.4.106 ζ28 替代无类型
294/// `EXPLICIT_NO_BODY_AWARE_PROTOS`.
295///
296/// 每个 entry 在 invariant test 强制 match 某个 variant, 不允许 hand-roll
297/// "我加进去就行" 漏类型.
298pub const COVERAGE_EXCEPTIONS: &[(u32, CoverageException)] = &[
299 // ResponseFiltered — request 无 acc_id, response s2c.acc_list[] 走 FilterRegistry
300 (2001, CoverageException::ResponseFiltered),
301 // PushOnly — push event 不是 request
302 (2208, CoverageException::PushOnly),
303 (2218, CoverageException::PushOnly),
304 (2240, CoverageException::PushOnly),
305 // MetaNoAccount — meta query 无 acc_id 概念
306 (1326, CoverageException::MetaNoAccount),
307];
308
309/// 列出本 daemon 所有 proto_id → exception 映射表的 proto_id 集合.
310/// 与 [`COVERAGE_EXCEPTIONS`] 同步 (v1.4.106 ζ28 起 source-of-truth 是
311/// `COVERAGE_EXCEPTIONS`, 此 const 仅作 backward compat alias).
312///
313/// **保留供 backward compat**: `body_aware::extract_coverage` 用此 set
314/// 判 NoAccIdConcept 还是 NotRegistered. 加新 exception 走
315/// [`COVERAGE_EXCEPTIONS`] 自动反映在此.
316pub const EXPLICIT_NO_BODY_AWARE_PROTOS: &[u32] = &[
317 1326, // GET_TOKEN_STATE — meta query (NN/MM token state, 无 acc_id)
318 2001, // TRD_GET_ACC_LIST — 走 response-side filter (FilterRegistry)
319 2208, // TRD_UPDATE_ORDER push — 不是 request, 无 body_aware
320 2218, // TRD_UPDATE_ORDER_FILL push
321 2240, // TRD_NOTIFY push
322];
323
324/// **v1.4.106 ζ28 redo (codex 0532 F3 P2)**: 判一个 proto_id 是否是
325/// daemon-internal (高位 `0x8000_0000` bit set).
326///
327/// daemon-internal proto_id (e.g. `TRD_UNSUB_ACC_PUSH_INTERNAL = 0x8000_0000 |
328/// 2008` v1.4.102 codex 44 F1 fix) **绝不应**从公开 surface (gRPC / raw WS /
329/// raw TCP) 进入 — 仅 REST `/api/unsub-acc-push` handler 内部合成给 router.
330///
331/// 公开 surface 看到此 bit set 立即 reject (`Forbidden` 等价 wire error)
332/// 防探测 daemon 内部 routing.
333#[must_use]
334pub fn is_internal_proto_id(proto_id: u32) -> bool {
335 (proto_id & 0x8000_0000) != 0
336}
337
338#[cfg(test)]
339mod tests;