Skip to main content

futu_auth/
audit.rs

1//! 审计事件发射 helpers + JSONL 订阅层
2//!
3//! ## 设计
4//!
5//! 所有 interface(grpc / rest / ws / mcp)把 auth 决策和下单事件都走同一组
6//! helper,emit 的 `tracing::event!` 使用固定 `target = "futu_audit"`。这样:
7//!
8//! - 正常日志订阅(stderr / 文件)照常能看到它们(没过滤就全走)
9//! - 专用 audit JSONL 文件订阅层走 `filter_fn(meta.target() == TARGET)` 只收这些
10//!
11//! JSONL 每条一行,字段固定 —— 字段顺序由 tracing-subscriber 的 json formatter
12//! 决定(level / timestamp / target / message / 其它 fields),方便 `jq`/`duckdb`
13//! 后处理。
14//!
15//! ## 事件形状(field 命名)
16//!
17//! | 字段       | 含义                                        |
18//! |-----------|---------------------------------------------|
19//! | iface     | `"grpc"` / `"rest"` / `"ws"` / `"mcp"`    |
20//! | endpoint  | 具体接口:路径 / proto_id / tool 名         |
21//! | key_id    | API key id,或 `<missing>` / `<invalid>`   |
22//! | outcome   | `"allow"` / `"reject"`                     |
23//! | reason    | reject 时的文字原因(allow 时也可选)       |
24//! | args_hash | 下单工具额外附:args 的 SHA-256 前 8 hex   |
25//! | scope     | 校验的 scope(allow 时可选)                |
26
27use std::path::Path;
28
29use tracing::Level;
30
31/// 固定 target,供 tracing filter 使用
32pub const TARGET: &str = "futu_audit";
33
34/// auth 拒绝事件
35pub fn reject(iface: &str, endpoint: &str, key_id: &str, reason: &str) {
36    tracing::event!(
37        target: TARGET,
38        Level::WARN,
39        iface = iface,
40        endpoint = endpoint,
41        key_id = key_id,
42        outcome = "reject",
43        reason = reason,
44        "auth reject"
45    );
46    crate::metrics::bump_auth_event(iface, "reject", key_id);
47    // 限额类的 reject 额外分桶计数(reason 以 "limit: " 开头是 guard 产生的)
48    if let Some(rest) = reason.strip_prefix("limit: ") {
49        crate::metrics::bump_limit_reject(iface, key_id, rest);
50    } else if reason.starts_with("rate limit")
51        || reason.starts_with("daily value")
52        || reason.starts_with("order value")
53    {
54        crate::metrics::bump_limit_reject(iface, key_id, reason);
55    }
56}
57
58/// auth 通过事件(用 INFO 级别;debug 模式会看到量比较大,由 EnvFilter 过滤)
59pub fn allow(iface: &str, endpoint: &str, key_id: &str, scope: Option<&str>) {
60    tracing::event!(
61        target: TARGET,
62        Level::INFO,
63        iface = iface,
64        endpoint = endpoint,
65        key_id = key_id,
66        outcome = "allow",
67        scope = scope.unwrap_or(""),
68        "auth allow"
69    );
70    crate::metrics::bump_auth_event(iface, "allow", key_id);
71}
72
73/// 交易事件(下单 / 改单 / 撤单)—— 无论 allow / reject 都记录
74pub fn trade(
75    iface: &str,
76    tool: &str,
77    key_id: &str,
78    args_hash: &str,
79    outcome: &str,
80    reason: Option<&str>,
81) {
82    tracing::event!(
83        target: TARGET,
84        Level::WARN,
85        iface = iface,
86        endpoint = tool,
87        key_id = key_id,
88        outcome = outcome,
89        args_hash = args_hash,
90        reason = reason.unwrap_or(""),
91        "trade event"
92    );
93    crate::metrics::bump_auth_event(iface, outcome, key_id);
94}
95
96// -------- JSONL 层安装 --------
97//
98// 这里不直接返回一个 `impl Layer<S>` 是因为 tracing-subscriber 的类型签名挺拗,
99// 放在 main.rs 里现场拼装反而清爽。下面这对 helper 把 "打开文件 + non-blocking
100// 包装" 抽成一个函数,并在退出时保留 guard 防止 flush 丢失。
101
102/// 打开 audit 输出路径,返回一个非阻塞 writer 和 guard(guard 必须活到进程退出)
103///
104/// - 如果 path 以 `.jsonl` / `.log` 等后缀结尾,直接当成单文件 append 打开
105/// - 否则视为目录,使用每日滚动,文件名 `futu-audit.log`
106pub fn open_writer(
107    path: &Path,
108) -> std::io::Result<(
109    tracing_appender::non_blocking::NonBlocking,
110    tracing_appender::non_blocking::WorkerGuard,
111)> {
112    // 启发式:
113    //   - 已存在的目录 → 目录
114    //   - 以 '/' 结尾 → 目录
115    //   - 没有扩展名 → 目录
116    //   - 有扩展名 → 单文件
117    // 想明确语义的话用户可以直接 `mkdir -p /foo/bar` 再传路径。
118    let is_dir_hint = path.is_dir()
119        || path.as_os_str().to_string_lossy().ends_with('/')
120        || path.extension().is_none();
121
122    // v1.4.87 CLAUDE.md 坑 #49: /tmp/ 是 world-readable (1777). audit log 可能
123    // 含 redacted token 占位 + timestamp + account id 等 metadata, 放 /tmp 对
124    // skill / 其他 local user 可读, 不安全. 硬约定在 stderr 打 warn 提醒用户.
125    warn_if_world_readable_path(path);
126
127    if is_dir_hint {
128        // 目录:每日滚动
129        std::fs::create_dir_all(path)?;
130        tighten_dir_perms(path); // v1.4.87: 0700 on Unix
131        // v1.4.93 BUG-5318-001 (跨 4 版半修补完): tracing_appender::rolling 创建
132        // 滚动 log file 时使用默认 OpenOptions, 不接受 mode 参数 → 文件 mode 是
133        // umask-dependent (典型 0644). v1.4.87 / 88 / 89 / 90 changelog 都承诺
134        // "audit log 文件 0600", 但 dir-rolling 路径下从未生效, 仅 0700 dir 真修.
135        // 修法: 立即 tighten 现有文件 (启动时 dir 里可能已有老文件 0644) +
136        // 包一层 Mode0600Appender 在每次 write 后 best-effort tighten 当前 file.
137        tighten_log_files_in_dir(path); // v1.4.93: 启动 sweep 老文件
138        let appender = tracing_appender::rolling::daily(path, "futu-audit.log");
139        let wrapped = Mode0600Appender::new(appender, path.to_path_buf());
140        Ok(tracing_appender::non_blocking(wrapped))
141    } else {
142        // 单文件:append
143        if let Some(parent) = path.parent()
144            && !parent.as_os_str().is_empty()
145        {
146            std::fs::create_dir_all(parent)?;
147            tighten_dir_perms(parent); // v1.4.87: 0700 on Unix
148        }
149        let file = open_file_0600(path)?; // v1.4.87: 0600 on Unix
150        Ok(tracing_appender::non_blocking(file))
151    }
152}
153
154/// v1.4.87 CLAUDE.md 坑 #49: Audit log dir 0700 permission (Unix only).
155///
156/// Windows/其他 OS no-op. 已有目录如果是更严的 perm (例如 0500), 不动 — best-effort
157/// tighten, 不 loosen.
158pub(crate) fn tighten_dir_perms(path: &Path) {
159    #[cfg(unix)]
160    {
161        use std::os::unix::fs::PermissionsExt;
162        if let Ok(meta) = std::fs::metadata(path) {
163            let cur = meta.permissions().mode() & 0o777;
164            // 只收紧, 不 loosen. cur > 0o700 → 收到 0700. cur <= 0o700 不动.
165            if cur & 0o077 != 0 {
166                let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
167            }
168        }
169    }
170    #[cfg(not(unix))]
171    {
172        let _ = path;
173    }
174}
175
176/// v1.4.87 CLAUDE.md 坑 #49: Open audit log file with 0600 permission (Unix).
177///
178/// 等价于 `OpenOptions::new().append(true).create(true).open(path)` + 0600 mode.
179/// 已有文件如果已有更严 perm 也不动. Windows no-op.
180pub(crate) fn open_file_0600(path: &Path) -> std::io::Result<std::fs::File> {
181    let mut opts = std::fs::OpenOptions::new();
182    opts.append(true).create(true);
183    #[cfg(unix)]
184    {
185        use std::os::unix::fs::OpenOptionsExt;
186        opts.mode(0o600);
187    }
188    let file = opts.open(path)?;
189    // 已存在文件 create=true 不会改 mode. 所以需 set_permissions 收紧.
190    #[cfg(unix)]
191    {
192        use std::os::unix::fs::PermissionsExt;
193        if let Ok(meta) = file.metadata() {
194            let cur = meta.permissions().mode() & 0o777;
195            if cur & 0o077 != 0 {
196                let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
197            }
198        }
199    }
200    Ok(file)
201}
202
203/// v1.4.87 CLAUDE.md 坑 #49: stderr warn if log path 是 world-readable 目录
204/// (典型 `/tmp/`, `/var/tmp/`, `/private/tmp/`).
205fn warn_if_world_readable_path(path: &Path) {
206    let s = path.as_os_str().to_string_lossy();
207    let world_readable_prefixes = ["/tmp/", "/var/tmp/", "/private/tmp/", "/tmp"];
208    for prefix in &world_readable_prefixes {
209        if s == *prefix || s.starts_with(prefix) {
210            eprintln!(
211                "⚠️  audit log path {s:?} 位于 world-readable 目录 (mode 1777). \
212                 audit log 含账户 id + redacted token 占位 + timestamp, 同机其他\n\
213                 用户 (包括 agent skill) 可读. 建议改用 ~/.futu-opend-rs/logs/ \
214                 或 /var/log/futu/ 等 0700 目录."
215            );
216            break;
217        }
218    }
219}
220
221/// v1.4.93 BUG-5318-001: 包装 `RollingFileAppender`, 每次 write 之后 best-effort
222/// 把 dir 中的 `futu-audit.log*` 文件 chmod 0o600 (Unix only). 1s debounce 避免
223/// 高频 chmod 带来的 IO 抖动.
224///
225/// 必须包 `RollingFileAppender` 而不是 inject `OpenOptionsExt::mode(0o600)` 是因为
226/// tracing-appender 0.2.5 没有 builder 暴露 OpenOptions custom mode (`create_writer`
227/// hardcoded `OpenOptions::new().append(true).create(true).open(path)`). 等 upstream
228/// 加 builder 后可改用更优方案.
229struct Mode0600Appender {
230    inner: tracing_appender::rolling::RollingFileAppender,
231    dir: std::path::PathBuf,
232    last_tighten: std::sync::atomic::AtomicU64, // last tighten epoch second
233}
234
235impl Mode0600Appender {
236    fn new(inner: tracing_appender::rolling::RollingFileAppender, dir: std::path::PathBuf) -> Self {
237        Self {
238            inner,
239            dir,
240            last_tighten: std::sync::atomic::AtomicU64::new(0),
241        }
242    }
243
244    /// 1s debounce: 每秒最多 chmod 一次, 避免每行都 stat/chmod
245    fn maybe_tighten(&self) {
246        let now = std::time::SystemTime::now()
247            .duration_since(std::time::UNIX_EPOCH)
248            .map(|d| d.as_secs())
249            .unwrap_or(0);
250        let last = self.last_tighten.load(std::sync::atomic::Ordering::Relaxed);
251        if now > last
252            && self
253                .last_tighten
254                .compare_exchange(
255                    last,
256                    now,
257                    std::sync::atomic::Ordering::Relaxed,
258                    std::sync::atomic::Ordering::Relaxed,
259                )
260                .is_ok()
261        {
262            tighten_log_files_in_dir(&self.dir);
263        }
264    }
265}
266
267impl std::io::Write for Mode0600Appender {
268    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
269        let n = self.inner.write(buf)?;
270        // 在第一次写入或日期翻滚后, RollingFileAppender 才创建新文件. 我们
271        // best-effort 在 write 后立即 tighten — 1s debounce 内不重复.
272        self.maybe_tighten();
273        Ok(n)
274    }
275
276    fn flush(&mut self) -> std::io::Result<()> {
277        self.inner.flush()
278    }
279}
280
281/// v1.4.93 BUG-5318-001: 把 dir 中所有 `futu-audit.log*` 文件 chmod 0o600 (Unix only).
282/// 已经更严 (e.g. 0o400) 不动, best-effort tighten.
283pub(crate) fn tighten_log_files_in_dir(dir: &Path) {
284    #[cfg(unix)]
285    {
286        use std::os::unix::fs::PermissionsExt;
287        let Ok(rd) = std::fs::read_dir(dir) else {
288            return;
289        };
290        for entry in rd.flatten() {
291            let p = entry.path();
292            let name = match p.file_name().and_then(|n| n.to_str()) {
293                Some(n) => n,
294                None => continue,
295            };
296            // 只处理 futu-audit.log* (含日期后缀)
297            if !name.starts_with("futu-audit.log") {
298                continue;
299            }
300            if let Ok(meta) = std::fs::metadata(&p) {
301                if !meta.is_file() {
302                    continue;
303                }
304                let cur = meta.permissions().mode() & 0o777;
305                if cur & 0o077 != 0 {
306                    let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600));
307                }
308            }
309        }
310    }
311    #[cfg(not(unix))]
312    {
313        let _ = dir;
314    }
315}
316
317#[cfg(test)]
318mod tests;