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;