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}
57
58impl CallerContext {
59    /// Legacy mode: 无 caller key, 无 allowed_acc_ids 限制.
60    /// 用于 unauthenticated route 或 legacy 模式的 fallback 路径.
61    pub fn legacy() -> Self {
62        Self {
63            key_id: None,
64            allowed_acc_ids: None,
65        }
66    }
67
68    /// 从 `KeyRecord` 构 CallerContext (route handler 入口主用).
69    ///
70    /// `rec = None` (Extension 缺失, legacy 模式) → 等价 `legacy()`.
71    pub fn from_key_record(rec: Option<&KeyRecord>) -> Self {
72        match rec {
73            None => Self::legacy(),
74            Some(r) => Self {
75                key_id: Some(r.id.clone()),
76                allowed_acc_ids: r.allowed_acc_ids.as_ref().map(|s| Arc::new(s.clone())),
77            },
78        }
79    }
80
81    /// `allowed_acc_ids` 借引用 (let `FilterRegistry::apply` 等 borrow 接 API
82    /// 直接吃). 跟 `Arc::as_ref` 等价但省 caller 写 `.as_ref().map(|a| a.as_ref())`.
83    pub fn allowed_acc_ids_borrow(&self) -> Option<&HashSet<u64>> {
84        self.allowed_acc_ids.as_deref()
85    }
86
87    /// `caller_allowed_acc_ids` 直接 clone Arc 供 `IncomingRequest` 字段填充
88    /// (Arc::clone 成本极低, 不深拷贝 set).
89    pub fn caller_allowed_acc_ids_arc(&self) -> Option<Arc<HashSet<u64>>> {
90        self.allowed_acc_ids.clone()
91    }
92
93    /// `caller_key_id` 直接 clone String 供 `IncomingRequest` 字段填充.
94    pub fn caller_key_id(&self) -> Option<String> {
95        self.key_id.clone()
96    }
97
98    /// audit log 用的 key_id (None → "<legacy>" 占位, 与 routes 层惯例一致).
99    pub fn key_id_for_audit(&self) -> String {
100        self.key_id
101            .clone()
102            .unwrap_or_else(|| "<legacy>".to_string())
103    }
104}
105
106#[cfg(test)]
107mod tests;