Skip to main content

futu_rest/
caller_context.rs

1//! Caller context — 跨 REST surface 统一携带 caller key id + allowed_acc_ids 快照.
2//!
3//! ## 触发 (codex 0522 audit F1 / F2)
4//!
5//! v1.4.105 之前 REST adapter 只把 `allowed_acc_ids` 接到 response filter, 不
6//! 填进 `IncomingRequest.caller_allowed_acc_ids` —— dispatch-time handler
7//! (如 `SubAccPushHandler`) 看到 `None` 当 "无限制", silent bypass per-acc
8//! 白名单 (gRPC / WS 已对齐, REST 落后).
9//!
10//! 同时 REST 在 routes 层手写多套 caller scope check (account list / card_num /
11//! sub-acc-push / unsub-acc-push), `IncomingRequest.caller_key_id` 不存在导致
12//! per-key cleanup / 审计 / owner-bound subscription 在 daemon 层只能 REST
13//! state 表自己维护, 不能走 gateway 统一入口.
14//!
15//! ## 解决
16//!
17//! 引入 `CallerContext` (一处 build, 全 surface 透传), 作为 REST adapter
18//! `proto_request_with_ctx` 唯一 caller scope 输入. 同时填:
19//!
20//! 1. `IncomingRequest.caller_allowed_acc_ids` (defense-in-depth, dispatch handler)
21//! 2. `IncomingRequest.caller_key_id` (per-key 配额 / 审计 / cleanup)
22//! 3. `FilterRegistry::apply` 入参 (响应 acc_list filter)
23//!
24//! 与 routes 层手写 audit / per-acc rate check 共用同一份 `CallerContext`,
25//! 防止 "检查的 body" 和 "实际 dispatch 的 body" 不一致 (codex F2).
26
27use std::collections::HashSet;
28use std::sync::Arc;
29
30use futu_auth::KeyRecord;
31
32/// REST caller scope 快照 (一次 build, 多次透传).
33///
34/// **触发位置**: route handler 入口, 从 `Extension<Arc<KeyRecord>>` 抽取.
35///
36/// **下游消费者** (统一来源, 防漂移):
37/// - `adapter::proto_request_with_ctx` → `IncomingRequest.caller_*` 填充
38/// - `adapter::proto_request_with_ctx` → `FilterRegistry::apply` 响应过滤
39/// - `routes::trd::extract_and_resolve_card_num_into_acc_id` 共用
40/// - `routes::trd::sub_acc_push` / `routes::sys::unsub_acc_push` 的 per-acc
41///   `check_full_skip_rate` 共用
42///
43/// **legacy 模式**: `key_id = None` + `allowed_acc_ids = None` (无 keys.json /
44/// 未鉴权), handler 不 filter / 不审 per-key.
45#[derive(Debug, Clone, Default)]
46pub struct CallerContext {
47    /// 调用方 API key id (来自 `KeyRecord.id`). `None` = legacy mode.
48    pub key_id: Option<String>,
49    /// 调用方 `allowed_acc_ids` 硬限额快照 (来自 `KeyRecord.allowed_acc_ids`,
50    /// SIGHUP-aware, route handler build CallerContext 时一次性 clone).
51    /// `None` = 无限制 (legacy / 未配此白名单); `Some(non_empty_set)` =
52    /// 受限。`Some(empty)` 按 `futu-auth::RuntimeCounters` 历史契约等同
53    /// 不限制;card_num fail-closed 使用 sentinel `{0}` 表达 deny-all, 不用
54    /// 空集合表达 deny-all。
55    pub allowed_acc_ids: Option<Arc<HashSet<u64>>>,
56    /// Precomputed central-router admission bit for Verification(1006).
57    pub has_auth_setup_scope: bool,
58    /// Transport-owned peer check. Only the listener may set this bit.
59    pub is_loopback: bool,
60    /// No-key compatibility mode confirmed by the REST auth middleware.
61    pub legacy_local_mode: bool,
62}
63
64impl CallerContext {
65    /// Legacy mode: 无 caller key, 无 allowed_acc_ids 限制.
66    /// 用于 unauthenticated route 或 legacy 模式的 fallback 路径.
67    pub fn legacy() -> Self {
68        Self {
69            key_id: None,
70            allowed_acc_ids: None,
71            has_auth_setup_scope: false,
72            is_loopback: false,
73            legacy_local_mode: false,
74        }
75    }
76
77    /// 从 `KeyRecord` 构 CallerContext (route handler 入口主用).
78    ///
79    /// `rec = None` (Extension 缺失, legacy 模式) → 等价 `legacy()`.
80    pub fn from_key_record(rec: Option<&KeyRecord>) -> Self {
81        match rec {
82            None => Self::legacy(),
83            Some(r) => Self {
84                key_id: Some(r.id.clone()),
85                allowed_acc_ids: r.allowed_acc_ids.as_ref().map(|s| Arc::new(s.clone())),
86                has_auth_setup_scope: r.scopes.contains(&futu_auth::Scope::AuthSetup),
87                is_loopback: false,
88                legacy_local_mode: false,
89            },
90        }
91    }
92
93    pub fn with_transport(mut self, is_loopback: bool, legacy_local_mode: bool) -> Self {
94        self.is_loopback = is_loopback;
95        self.legacy_local_mode = legacy_local_mode;
96        self
97    }
98
99    /// `allowed_acc_ids` 借引用 (let `FilterRegistry::apply` 等 borrow 接 API
100    /// 直接吃). 跟 `Arc::as_ref` 等价但省 caller 写 `.as_ref().map(|a| a.as_ref())`.
101    pub fn allowed_acc_ids_borrow(&self) -> Option<&HashSet<u64>> {
102        self.allowed_acc_ids.as_deref()
103    }
104
105    /// `caller_allowed_acc_ids` 直接 clone Arc 供 `IncomingRequest` 字段填充
106    /// (Arc::clone 成本极低, 不深拷贝 set).
107    pub fn caller_allowed_acc_ids_arc(&self) -> Option<Arc<HashSet<u64>>> {
108        self.allowed_acc_ids.clone()
109    }
110
111    /// `caller_key_id` 直接 clone String 供 `IncomingRequest` 字段填充.
112    pub fn caller_key_id(&self) -> Option<String> {
113        self.key_id.clone()
114    }
115
116    pub fn caller_has_auth_setup_scope(&self) -> bool {
117        self.has_auth_setup_scope
118    }
119
120    pub fn caller_is_loopback(&self) -> bool {
121        self.is_loopback
122    }
123
124    pub fn caller_legacy_local_mode(&self) -> bool {
125        self.legacy_local_mode
126    }
127
128    /// audit log 用的 key_id (None → "<legacy>" 占位, 与 routes 层惯例一致).
129    pub fn key_id_for_audit(&self) -> String {
130        self.key_id
131            .clone()
132            .unwrap_or_else(|| "<legacy>".to_string())
133    }
134}
135
136#[cfg(test)]
137mod tests;