Skip to main content

futu_grpc/
auth.rs

1//! gRPC 的 Bearer Token 鉴权
2//!
3//! 两种模式:
4//! - **未配置 KeyStore**:完全不鉴权(保持旧行为,启动日志 warn)
5//! - **配置了 KeyStore**:所有 RPC 必须带 `authorization: Bearer <plaintext>` metadata,
6//!   且按 `EndpointSpec` / proto_id / RPC 类型校验 scope
7//!
8//! 由于 gRPC 采用通用 `Request(proto_id, body)` RPC 模式,scope 检查优先
9//! 使用 `EndpointSpec.runtime.scope`;未进入 surface spec 的 legacy proto
10//! 再回退到 capability/低层 proto_id 表。`SubscribePush` 流式 RPC 要求
11//! `qot:read`(推送混合了行情与交易,以行情 scope 为最低门槛)。
12//!
13//! | proto_id 范围 | 所需 scope |
14//! |---|---|
15//! | 已声明的 1xxx 公开 query(GetGlobalState / GetUserInfo / DelayStatistics) | `EndpointSpec.runtime.scope` |
16//! | legacy 1xxx 连接协议(InitConnect / KeepAlive / …) | 无(放行) |
17//! | 3xxx 行情 | `qot:read` |
18//! | 2001 / 2008 / 2101 / 2102 / 2111 / 2201 / 2211 / 2221 / 2222 / 2223 / 2225 / 2226 账户只读 | `acc:read` |
19//! | 2005 UnlockTrade | `trade:unlock`(v1.4.104 codex F1 P1) |
20//! | 2202 / 2205 / 2237 下单 / 改单 / 确认 | `trade:real` |
21//! | 其他 | 拒绝(保守) |
22
23use futu_auth::Scope;
24use tonic::{Request, Status};
25
26/// 按 proto_id 推断所需 scope.
27///
28/// 返回 `None` 只代表该 proto_id 未进入 `EndpointSpec` 且属于 legacy
29/// 免鉴权连接协议(仍会校验 key 本身有效)。已声明的 proto-backed endpoint
30/// 优先使用 `EndpointSpec.runtime.scope`.
31#[inline]
32pub fn scope_for_proto(proto_id: u32) -> Option<Scope> {
33    futu_auth_pipeline::capability::scope_for_proto_id(proto_id)
34}
35
36#[must_use]
37pub(crate) fn grpc_audit_context<T>(
38    req: &Request<T>,
39    stable_conn_id: u64,
40) -> futu_auth::audit::AuditContext {
41    let remote_addr = req.remote_addr().map(|addr| addr.to_string());
42    let session_id = format!("grpc:{stable_conn_id}");
43    futu_auth::audit::AuditContext::new(remote_addr.as_deref(), Some(session_id.as_str()))
44}
45
46// ─────────────────────────────────────────────────────────────────────────────
47// v1.4.106 codex 0517 ζ25-redo: gRPC stateful QOT stable identity (F2)
48//
49// **背景**:v1.4.105 之前 gRPC `request()` 每次 RPC 调 `next_conn_id()`
50// 自增分配新 virtual conn_id。`SubscriptionManager` / 各 cache 按 conn_id
51// 记账。gRPC 客户端连续两次 RPC(同一 caller、同一 stream)拿到不同 conn_id
52// → cache miss、sub 永远无法对齐、`get_sub_info` 看不到自己刚刚 sub 的
53// security。等价于 REST v1.4.90 P0-B 之前的 quota 永久泄漏 bug。
54//
55// **修法**(对齐 REST `REST_SHARED_CONN` 设计哲学):
56//
57// 同一 caller(按 Bearer token 派生身份;可选 `grpc-session-id` metadata
58// header 进一步隔离 session)的所有 gRPC RPC 共享同一 deterministic stable
59// conn_id。不同 caller / session → naturally 隔离。
60//
61// **取值范围**:`0x4000_0000_0000_0000`(bit 62)以上是本 namespace;
62// 高 2 bit 留作语义标识,低 62 bit 由 `bearer + session` hash 派生。
63// 与 `next_conn_id` 起点(20M)/ `REST_SHARED_CONN`(0xFFFF_FFFE)/ raw TCP
64// `ConnectionRegistry` 分配段(u32 范围)完全不重合。
65//
66// **legacy mode**(KeyStore 未配置):bearer = None;输出值仍 deterministic
67// 但全 caller 共享一个固定值(namespace base + LEGACY_FALLBACK_HASH)。这跟
68// 旧"全 RPC 共享同 ID"实质相同(legacy mode 本来就没鉴权区分),但比旧自增
69// 的 conn_id 行为更稳定(cache 不再每次 miss)。
70//
71// **测试方式**:
72// - 同 bearer + 同 session → 同 conn_id(reproducible)
73// - 同 bearer + 不同 session → 不同 conn_id
74// - 不同 bearer → 不同 conn_id
75// - bearer = None → LEGACY 固定值
76// - 输出值 bit 62 必置位(namespace 内)
77// ─────────────────────────────────────────────────────────────────────────────
78
79/// gRPC stable conn_id namespace base.
80///
81/// 任何 `derive_grpc_conn_id` 输出 conn_id 都满足 `out & GRPC_STABLE_CONN_NAMESPACE
82/// == GRPC_STABLE_CONN_NAMESPACE`(bit 62 置位)。这与 raw TCP / WS / REST /
83/// MCP 各自分配的 conn_id 段完全不重合,让 SubscriptionManager 可以零冲突
84/// 共用一个 conn_id 表。
85///
86/// 取值:`0x4000_0000_0000_0000`(4_611_686_018_427_387_904)。bit 62 单 bit
87/// 标识,低 62 bit 留 hash 派生。bit 63 留作未来 surface(避免 sign-bit
88/// 与 `i64` 互转踩坑)。
89pub const GRPC_STABLE_CONN_NAMESPACE: u64 = 0x4000_0000_0000_0000;
90
91/// legacy fallback magic for `bearer = None`(KeyStore 未配置或 metadata 缺失)。
92///
93/// 这是一个**任意非零 magic**,喂给 `DefaultHasher` 让 legacy 路径与有 bearer
94/// 的 caller 派生不同 conn_id。任何同 daemon 进程内 legacy caller 都拿同值
95/// → cache 行为对齐"全 legacy caller 共享一个 conn"。
96///
97/// 取值 `0x3F3F_3F3F_3F3F_3F3E`:高 2 bit 0、低 62 bit 内交错位模式(任意非零
98/// 即可,hasher 内部 mix 让结果分布均匀;本常量本身不直接当 conn_id 用)。
99const LEGACY_FALLBACK_HASH: u64 = 0x3F3F_3F3F_3F3F_3F3E;
100
101/// 派生 gRPC stable conn_id。
102///
103/// **参数**:
104/// - `bearer`:Bearer token(已经 `parse_bearer_scheme` 解出的纯 token,不含
105///   `Bearer ` 前缀)。`None` 表示 legacy mode(无鉴权配置),此时全部 caller
106///   共享同一 fallback id。
107/// - `session_id`:可选 `grpc-session-id` metadata header 值;用于同一 caller
108///   想运行多个独立 stream / sub-state 时区分。空 `""` 视同 `None`。
109///
110/// **返回**:u64 stable conn_id,bit 62 必置位(在 `GRPC_STABLE_CONN_NAMESPACE`
111/// 内)。同输入 → 同输出(process-stable,单 daemon 进程内幂等;不依赖跨
112/// Rust 版本 / 跨 process hash 稳定性,这对 conn_id 用途已足够)。
113///
114/// **隔离语义**:
115/// - 同 bearer + 同 session → 同 conn_id(连续 RPC 命中同一 sub state)
116/// - 同 bearer + 不同 session → 不同 conn_id(独立 sub state)
117/// - 不同 bearer → 不同 conn_id(caller 之间天然隔离)
118/// - bearer=None + session=任意 → 仍 deterministic(legacy 同 caller 行为一致)
119///
120/// **算法**:
121/// 1. 用 `DefaultHasher` 顺序 feed `(domain_tag, bearer_or_legacy, session_or_empty)`
122/// 2. 取低 62 bit
123/// 3. OR 上 `GRPC_STABLE_CONN_NAMESPACE`(bit 62)
124///
125/// `domain_tag` 防止和其他 hash 用途碰撞(pitfall #25 idempotency 派生原则)。
126///
127/// **不依赖项**:不依赖 KeyStore 是否配置(caller 已做完 auth 才到这里);
128/// 不依赖 RPC proto_id(`request()` / `subscribe_push()` 共用同一身份,
129/// 这是 stateful sub 的目的)。
130#[must_use]
131pub fn derive_grpc_conn_id(bearer: Option<&str>, session_id: Option<&str>) -> u64 {
132    use std::collections::hash_map::DefaultHasher;
133    use std::hash::{Hash, Hasher};
134
135    // domain tag — 让本 hash 与 idempotency / order-id hash 等其他用途分离
136    const DOMAIN_TAG: &str = "futu-grpc-stable-conn-id-v1";
137
138    let mut hasher = DefaultHasher::new();
139    DOMAIN_TAG.hash(&mut hasher);
140
141    match bearer {
142        Some(t) if !t.is_empty() => {
143            "bearer".hash(&mut hasher);
144            t.hash(&mut hasher);
145        }
146        _ => {
147            // legacy / 无 bearer:feed magic 让 caller 拿稳定值
148            "legacy".hash(&mut hasher);
149            LEGACY_FALLBACK_HASH.hash(&mut hasher);
150        }
151    }
152
153    let session = session_id.unwrap_or("");
154    if session.is_empty() {
155        "no-session".hash(&mut hasher);
156    } else {
157        "session".hash(&mut hasher);
158        session.hash(&mut hasher);
159    }
160
161    let raw = hasher.finish();
162    // 取低 62 bit + OR namespace base(bit 62)
163    (raw & ((1u64 << 62) - 1)) | GRPC_STABLE_CONN_NAMESPACE
164}
165
166/// 从 gRPC `Request<T>` metadata 提 optional `grpc-session-id` header。
167///
168/// 同一 Bearer 想分多个独立 stream / sub-state 时可显式带 session-id;
169/// 缺失 → `None`,所有 RPC 共享同 caller 的 default session。
170///
171/// **格式**:任意非空 ASCII string。空字符串 → `None`。
172#[must_use]
173pub fn extract_grpc_session_id<T>(req: &Request<T>) -> Option<String> {
174    let value = req.metadata().get("grpc-session-id")?;
175    let s = value.to_str().ok()?.trim();
176    if s.is_empty() {
177        return None;
178    }
179    Some(s.to_string())
180}
181
182/// 从 gRPC `Request<T>` metadata 提取 trade-write idempotency key。
183///
184/// REST surface 使用 HTTP `Idempotency-Key` header;gRPC 没有 HTTP body
185/// envelope,等价语义放在 metadata。metadata key 在 tonic 中按小写访问,
186/// 这里同时接受 canonical `idempotency-key` 与更常见的
187/// `x-idempotency-key`,空白值视同未传。
188#[must_use]
189pub fn extract_grpc_idempotency_key<T>(req: &Request<T>) -> Option<String> {
190    for name in ["idempotency-key", "x-idempotency-key"] {
191        let Some(value) = req.metadata().get(name) else {
192            continue;
193        };
194        let Ok(s) = value.to_str() else {
195            continue;
196        };
197        let trimmed = s.trim();
198        if !trimmed.is_empty() {
199            return Some(trimmed.to_string());
200        }
201    }
202    None
203}
204
205// ─────────────────────────────────────────────────────────────────────────────
206// v1.4.104: 跨 surface auth 中间件 transport adapter helpers
207//
208// 阶段 2: 把 authenticate / check_scope 等 inline 逻辑替换为
209// `futu_auth_pipeline::authenticate_request` 调用. 本节留下 transport-only
210// helpers (Bearer extract / RejectKind → Status 翻译), 让 surface adapter 极薄.
211// 阶段 7-2: subscribe_push 也走 pipeline, 旧 `authenticate` / `check_scope`
212// pub fn 删除 (无调用方).
213// ─────────────────────────────────────────────────────────────────────────────
214
215/// 从 gRPC `Request<T>` metadata 提 Bearer token (case-insensitive scheme parse).
216/// 返 owned `Option<String>` 让 caller 决定 lifetime (避免 borrow vs move 冲突,
217/// gRPC `Request` 主体后续要 `into_inner()` 拿 body).
218///
219/// metadata 缺失 / scheme 错 / token 空 → `None` (caller 应转成
220/// `Credential::None`, pipeline 在 scope mode 下会 reject as Unauthenticated).
221///
222/// v1.4.104 阶段 7-3: 内部 case-insensitive 解析委托 `futu_auth_pipeline::
223/// parse_bearer_scheme`, 4 surface 共用同一逻辑 (gRPC / WS / REST / MCP).
224#[must_use]
225pub fn extract_grpc_token<T>(req: &Request<T>) -> Option<String> {
226    let value = req.metadata().get("authorization")?;
227    let s = value.to_str().ok()?;
228    let token = futu_auth_pipeline::parse_bearer_scheme(s)?;
229    Some(token.to_string())
230}
231
232/// v1.4.106 D1 5b: gRPC surface adapter — 把 pipeline `AuthDecision::Reject`
233/// 翻成 tonic `Status`.
234///
235/// **历史**: v1.4.104 阶段 5 把"翻 reject 为 Status"作 free fn `grpc_status_for`
236/// 写在本文件; v1.4.106 D1 把 4 surface 的同类 translate fn 收敛到
237/// [`futu_auth_pipeline::SurfaceAdapter`] trait, 让 4 surface 一致, 防
238/// sibling-route 不一致 regression (codex round 3 F1 教训).
239///
240/// **gRPC Status 映射** (v1.4.105 #3 P2 sealed):
241/// - `Unauthenticated` → `Status::unauthenticated(reason)` (保留 reason 让
242///   client 知道 missing token / invalid bearer)
243/// - `Forbidden` → `Status::permission_denied("forbidden")` (generic 文案 **不泄**
244///   required scope name. raw reason 仍由 pipeline 写入 audit log, 运维能查;
245///   客户端只看通用 forbidden, 不能从 message 反推 daemon 内部 scope 名)
246/// - `RateLimited` → `Status::resource_exhausted(reason)` (rate 信息客户端需知道
247///   backoff 策略)
248/// - `NotFound` → `Status::not_found(reason)`
249/// - `InternalError` → `Status::internal("internal error")` (类似 generic)
250///
251/// **历史上下文** (external reviewer FINAL-BUG-REPORT-v5 #3 P2):
252/// REST 已统一 generic `{"error":"forbidden"}`, gRPC 之前透传 `reason` 暴露了
253/// `missing scope acc:read` / `missing scope trade:real` 等 daemon 内部 scope
254/// 名称, 让 qot-only key 的攻击者能从拒绝消息探测 daemon scope 命名空间.
255/// v1.4.105 对齐 REST 的 generic 策略.
256pub struct GrpcAdapter;
257
258impl futu_auth_pipeline::SurfaceAdapter for GrpcAdapter {
259    type WireResponse = Status;
260
261    fn surface_id() -> futu_auth_pipeline::SurfaceId {
262        futu_auth_pipeline::SurfaceId::Grpc
263    }
264
265    fn translate_reject(
266        kind: futu_auth_pipeline::RejectKind,
267        reason: String,
268    ) -> Self::WireResponse {
269        use futu_auth_pipeline::RejectKind::*;
270        match kind {
271            Unauthenticated => Status::unauthenticated(reason),
272            // v1.4.105 #3 P2: 对齐 REST `{"error":"forbidden"}` generic 策略,
273            // 不泄 scope name. raw `reason` 已由 pipeline 写 audit log
274            // (futu_auth::audit::reject), 运维查 daemon log 仍能拿到完整 scope
275            // 信息.
276            Forbidden => {
277                drop(reason); // explicit drop, 不透给客户端
278                Status::permission_denied("forbidden")
279            }
280            RateLimited => Status::resource_exhausted(reason),
281            NotFound => Status::not_found(reason),
282            InternalError => {
283                drop(reason);
284                Status::internal("internal error")
285            }
286        }
287    }
288}
289
290/// gRPC surface 的 rejection 状态码翻译入口.
291///
292/// 内部委托 [`GrpcAdapter::translate_reject`]. `server.rs` 与测试继续走这个
293/// free fn,避免 surface adapter 细节散到 request handler.
294#[must_use]
295pub fn grpc_status_for(kind: futu_auth_pipeline::RejectKind, reason: String) -> Status {
296    use futu_auth_pipeline::SurfaceAdapter;
297    GrpcAdapter::translate_reject(kind, reason)
298}
299
300#[cfg(test)]
301mod tests;