Skip to main content

openlark_security/
lib.rs

1//! OpenLark 安全服务模块
2//!
3//! 提供飞书开放平台的完整安全服务,包括访问控制(ACS)和安全合规管理。
4//!
5//! ## 架构设计
6//!
7//! 采用 Project-Version-Resource (PVR) 三层架构,使用 canonical `openlark_core::config::Config`(#444–#447):
8//!
9//! ```text
10//! openlark-security/src/
11//! ├── acs/              # 访问控制系统 (Project)
12//! │   └── v1/          # API版本v1 (Version)
13//! └── security_and_compliance/  # 安全合规管理 (Project)
14//!     ├── v1/          # API版本v1 (Version) - 审计日志
15//!     └── v2/          # API版本v2 (Version) - 设备记录管理
16//! ```
17//!
18//! ## 快速开始
19//!
20//! ```rust,no_run
21//! use openlark_security::prelude::*;
22//! use openlark_core::config::Config;
23//!
24//! #[tokio::main]
25//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
26//!     // 使用 canonical core Config(v0.18 推荐唯一路径,完整保留 token provider / headers / timeout 等)
27//!     let config = Config::builder()
28//!         .app_id("app_id")
29//!         .app_secret("app_secret")
30//!         .build();
31//!     let security = SecurityClient::new(config);
32//!
33//!     // 获取门禁用户列表(响应 data 透传为 ListUsersResponse)
34//!     let users = security.acs.v1().users().list()
35//!         .page_size(20)
36//!         .execute()
37//!         .await?;
38//!
39//!     println!("是否有更多: {}", users.has_more);
40//!     Ok(())
41//! }
42//! ```
43//!
44//! ## API覆盖
45//!
46//! ### acs (v1) - 访问控制系统
47//!
48//! 所有端点走 `openlark_core::Transport`,token 类型按飞书文档 Authorization 标注
49//! (用户/设备/记录类 `tenant_access_token`,权限组/访客类 `user_access_token`),返回响应 `data` 字段内容。
50//! 各 Service 是门面,方法返回 `*Request` 构建器(`.execute()` 发请求)。
51//!
52//! #### 用户管理 (users)
53//! - `users.get(user_id)` / `users.list()` / `users.create()` / `users.patch(user_id)` / `users.delete(user_id)`
54//!
55//! #### 用户人脸 (user_faces,`/users/{user_id}/face`)
56//! - `user_faces.get(user_id)` - 下载用户人脸
57//! - `user_faces.update(user_id)` - 上传用户人脸
58//!
59//! #### 人脸资源 (face,独立资源 `/faces`)
60//! - `face().get(face_id)` / `face().create()` / `face().delete(face_id)`
61//!
62//! #### 设备管理 (devices)
63//! - `devices.get/list/create/update/delete/approve/query`
64//! - `client_device(device_id)` - 客户端设备认证(便捷方法)
65//!
66//! #### 权限规则 (rule_external)
67//! - `rule_external.create(rule_id)` - 创建或更新权限组(body `{"rule":...}`)
68//! - `rule_external.get(device_id)` - 获取权限组
69//! - `rule_external.delete(rule_id)` - 删除权限组(无 body)
70//! - `rule_external.device_bind()` - 设备绑定(`{device_id, rule_ids[]}`)
71//!
72//! #### 访客管理 (visitors)
73//! - `visitors.create()` / `visitors.delete(visitor_id)`
74//!
75//! #### 访问记录 (access_records)
76//! - `access_records.list()` / `access_records.get_access_photo(access_record_id)`
77//!
78//! ### security_and_compliance (v2/v1) - 安全合规管理
79//! #### 设备记录管理 (device_record - v2)
80//! - `device_records.mine()` - 获取客户端设备认证信息
81//! - `device_records.create()` - 新增设备
82//! - `device_records.list()` - 查询设备信息
83//! - `device_records.get()` - 获取设备信息
84//! - `device_records.update()` - 更新设备
85//! - `device_records.delete()` - 删除设备
86//!
87//! #### 设备申报审批 (device_apply_record - v2)
88//! - `device_apply_records.approve()` - 审批设备申报
89//!
90//! #### 审计日志管理 (openapi_log - v1)
91//! - `openapi_logs.list_data()` - 获取OpenAPI审计日志数据
92
93#![warn(clippy::all)]
94#![warn(missing_copy_implementations)]
95#![warn(missing_debug_implementations)]
96
97// 错误处理模块
98pub mod error;
99
100// Project: acs - 访问控制系统
101pub mod acs;
102pub mod security;
103
104// 重新导出服务类型(Projects 通过 SecurityClient 暴露;顶层不 re-export Projects,隐藏绕过 single-entry 构造)。
105pub use security::security_and_compliance::{
106    SecurityAndComplianceV1Service, SecurityAndComplianceV2Service,
107};
108
109// 内部使用 Projects 类型(字段 pub 仍对外可见其完整路径)。
110use acs::acs::AcsProject;
111use security::security_and_compliance::SecurityAndComplianceProject;
112
113// 重新导出错误类型
114pub use crate::error::SecurityError;
115
116// 重新导出 canonical core Config,便于独立使用 security crate
117pub use openlark_core::config::Config;
118
119/// 安全服务客户端(唯一公开入口,single-entry)。
120///
121/// 直接持有 canonical `openlark_core::config::Config` + 项目(真实实现深度)。
122/// 遵循 CLIENT_NAMING_CONVENTION:仅提供 `new(config: Config)`。
123///
124/// 用法:`client.security.acs...` 或 `client.security.config()`
125///
126/// SecurityConfig 已完全移除(#425 / #447 最终收口)。Project 构造通过 Client 访问。
127#[derive(Debug, Clone)]
128pub struct SecurityClient {
129    config: openlark_core::config::Config,
130    /// ACS 项目
131    pub acs: AcsProject,
132    /// 安全合规项目
133    pub security_and_compliance: SecurityAndComplianceProject,
134}
135
136impl SecurityClient {
137    /// 使用 canonical `openlark_core::config::Config` 构造(v0.18 唯一推荐路径)。
138    ///
139    /// 完整保留 token_provider、自定义 headers、timeout、retry 等配置。
140    pub fn new(config: openlark_core::config::Config) -> Self {
141        Self {
142            acs: AcsProject::new(config.clone()),
143            security_and_compliance: SecurityAndComplianceProject::new(config.clone()),
144            config,
145        }
146    }
147
148    /// 返回当前 canonical 配置(测试中避免直接读取字段作为验收;用 behavioral evidence)。
149    pub fn config(&self) -> &openlark_core::config::Config {
150        &self.config
151    }
152}
153
154// Default 已移除,以避免产生空凭据 Config(违反 single-entry)。
155// 测试使用显式 new(Config::builder()...build()) 。
156
157/// 结果类型别名
158pub type SecurityResult<T> = Result<T, crate::error::SecurityError>;
159
160/// 预导出模块
161pub mod prelude {
162    pub use super::{SecurityClient, SecurityResult};
163
164    // 避免v1命名空间冲突,明确导出需要的类型(Projects 仅通过 Client 访问)。
165    pub use super::acs::acs::AcsV1Service;
166    pub use super::security::security_and_compliance::{
167        SecurityAndComplianceV1Service, SecurityAndComplianceV2Service,
168    };
169}
170
171#[cfg(test)]
172mod construction_tests {
173    use super::*;
174    use openlark_core::auth::{TokenProvider, TokenRequest};
175    use openlark_core::error::ErrorTrait;
176    use std::future::Future;
177    use std::pin::Pin;
178    use wiremock::matchers::{header, method, path};
179    use wiremock::{Mock, MockServer, ResponseTemplate};
180
181    /// 测试用 TokenProvider:总是返回固定 token,用于验证 provider 传播。
182    #[derive(Debug, Clone)]
183    struct TestTokenProvider(&'static str);
184
185    impl TokenProvider for TestTokenProvider {
186        fn get_token(
187            &self,
188            _request: TokenRequest,
189        ) -> Pin<Box<dyn Future<Output = openlark_core::SDKResult<String>> + Send + '_>> {
190            let token = self.0.to_string();
191            Box::pin(async move { Ok(token) })
192        }
193    }
194
195    /// 直接用 SecurityClient::new 构造(canonical-path),证明 ACS 使用 retained canonical Config:
196    /// - 自定义 base_url、headers、token_provider 生效
197    /// - timeout、response-size 配置保持
198    /// - 代表性 ACS leaf (users.list) wiremock 测试
199    #[tokio::test]
200    async fn security_client_new_canonical_config_propagates_base_headers_and_token_provider() {
201        let server = MockServer::start().await;
202
203        // 精确匹配 header,证明三者都传播到了外发请求
204        Mock::given(method("GET"))
205            .and(path("/open-apis/acs/v1/users"))
206            .and(header("Authorization", "Bearer test_tok_from_provider"))
207            .and(header("X-Custom-Prop", "yes"))
208            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
209                "code": 0,
210                "msg": "success",
211                "data": { "has_more": false, "items": [] }
212            })))
213            .mount(&server)
214            .await;
215
216        let base = Config::builder()
217            .app_id("test_app")
218            .app_secret("test_secret")
219            .base_url(server.uri())
220            .allow_custom_base_url(true)
221            .add_header("X-Custom-Prop", "yes")
222            .req_timeout(std::time::Duration::from_secs(30))
223            .max_response_size(8 * 1024 * 1024)
224            .build();
225
226        let config_with_provider =
227            base.with_token_provider(TestTokenProvider("test_tok_from_provider"));
228
229        let client = SecurityClient::new(config_with_provider);
230
231        // 构造后直接进入执行;验收依赖 mock header 匹配 + 后续行为测试(timeout/size 错误触发)。
232        // 绝不以读取 config 字段作为传播证明(per re-review)。
233
234        // 执行 ACS leaf 调用(代表性)
235        let _resp = client
236            .acs
237            .v1()
238            .users()
239            .list()
240            .execute()
241            .await
242            .expect("wiremock 应返回成功响应");
243
244        let received = server.received_requests().await.unwrap_or_default();
245        assert_eq!(received.len(), 1, "应只发一次 security leaf 请求");
246
247        // 路径匹配器 + header 匹配器已证明 base_url、自定义 header、token provider 生效。
248        // 这里再做一次宽松确认(url 里包含我们 mock 的路径即可)。
249        let req = &received[0];
250        assert!(
251            req.url.path() == "/open-apis/acs/v1/users"
252                || req.url.as_str().contains("/acs/v1/users"),
253            "请求路径应指向 ACS leaf,实际: {}",
254            req.url
255        );
256    }
257
258    /// 代表性 compliance (security_and_compliance v2) leaf 也应收到 retained canonical Config。
259    /// 证明与 ACS 相同:base_url、headers、token_provider 完整保留。
260    ///
261    /// 用 `device_records().list()`(tenant_access_token)而非 `mine()`:后者按飞书文档要求
262    /// user_access_token(#515),而本测试的 token provider 只供 tenant/app 凭证、不设
263    /// `option.user_access_token`,User 类 leaf 会在 `validate()` 直接被拒(http.rs:361)。
264    /// 本测试验证的是 config 传播,与具体 leaf 无关,故选 tenant 类代表 leaf。
265    #[tokio::test]
266    async fn security_client_new_canonical_config_propagates_to_compliance_v2_leaf() {
267        let server = MockServer::start().await;
268
269        // 精确匹配 header,证明 token provider 和自定义 header 传播
270        Mock::given(method("GET"))
271            .and(path("/open-apis/security_and_compliance/v2/device_records"))
272            .and(header("Authorization", "Bearer test_tok_from_provider"))
273            .and(header("X-Compliance-Test", "propagated"))
274            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
275                "code": 0,
276                "msg": "success",
277                "data": { "has_more": false, "items": [] }
278            })))
279            .mount(&server)
280            .await;
281
282        let base = Config::builder()
283            .app_id("test_app")
284            .app_secret("test_secret")
285            .base_url(server.uri())
286            .allow_custom_base_url(true)
287            .add_header("X-Compliance-Test", "propagated")
288            .req_timeout(std::time::Duration::from_secs(30))
289            .max_response_size(8 * 1024 * 1024)
290            .build();
291
292        let config_with_provider =
293            base.with_token_provider(TestTokenProvider("test_tok_from_provider"));
294
295        let client = SecurityClient::new(config_with_provider);
296
297        // 传播证明靠 mock header 匹配 + execute 成功(不读 config 字段)。
298
299        let _ = client
300            .security_and_compliance
301            .v2()
302            .device_records()
303            .list()
304            .execute()
305            .await
306            .expect("compliance v2 leaf 应成功");
307
308        let received = server.received_requests().await.unwrap_or_default();
309        assert_eq!(received.len(), 1, "应只发一次 compliance leaf 请求");
310        assert!(
311            received[0].url.path().contains("/device_records"),
312            "请求路径应指向 compliance v2 leaf"
313        );
314    }
315
316    /// 代表性 compliance v1 leaf(审计日志 list_data)也应收到 retained canonical Config。
317    #[tokio::test]
318    async fn security_client_new_canonical_config_propagates_to_compliance_v1_leaf() {
319        let server = MockServer::start().await;
320
321        Mock::given(method("POST"))
322            .and(path(
323                "/open-apis/security_and_compliance/v1/openapi_logs/list_data",
324            ))
325            .and(header("Authorization", "Bearer test_tok_from_provider"))
326            .and(header("X-Compliance-V1", "propagated"))
327            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
328                "code": 0,
329                "msg": "success",
330                "data": { "items": [{ "request_id": "r1" }], "has_more": false }
331            })))
332            .mount(&server)
333            .await;
334
335        let base = Config::builder()
336            .app_id("test_app")
337            .app_secret("test_secret")
338            .base_url(server.uri())
339            .allow_custom_base_url(true)
340            .add_header("X-Compliance-V1", "propagated")
341            .build();
342
343        let config_with_provider =
344            base.with_token_provider(TestTokenProvider("test_tok_from_provider"));
345
346        let client = SecurityClient::new(config_with_provider);
347
348        // 结构 sanity:header 已通过 provider + 传播生效(见 mock 精确匹配)
349        // 不再依赖“读取存储字段”作为传播验收;下面补充真实错误路径触发测试。
350
351        use serde_json::json;
352        let _ = client
353            .security_and_compliance
354            .v1()
355            .openapi_logs()
356            .list_data()
357            .body(json!({ "start_time": 1700000000, "end_time": 1700003600 }))
358            .execute()
359            .await
360            .expect("compliance v1 leaf 应成功");
361
362        let received = server.received_requests().await.unwrap_or_default();
363        assert_eq!(received.len(), 1);
364        assert!(received[0].url.path().contains("/openapi_logs/list_data"));
365    }
366
367    /// Compliance v1 业务错误必须保留 retry 语义与 request ID。
368    #[tokio::test]
369    async fn compliance_v1_preserves_retryable_error_and_request_id() {
370        let server = MockServer::start().await;
371        Mock::given(method("POST"))
372            .and(path(
373                "/open-apis/security_and_compliance/v1/openapi_logs/list_data",
374            ))
375            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
376                "code": 503,
377                "msg": "compliance v1 unavailable",
378                "request_id": "req-compliance-v1-503"
379            })))
380            .mount(&server)
381            .await;
382
383        let config = Config::builder()
384            .app_id("test_app")
385            .app_secret("test_secret")
386            .base_url(server.uri())
387            .allow_custom_base_url(true)
388            .build()
389            .with_token_provider(TestTokenProvider("compliance_v1_token"));
390        let client = SecurityClient::new(config);
391
392        let err = client
393            .security_and_compliance
394            .v1()
395            .openapi_logs()
396            .list_data()
397            .body(serde_json::json!({}))
398            .execute()
399            .await
400            .expect_err("compliance v1 业务错误必须向上传播");
401
402        assert!(err.is_retryable());
403        assert_eq!(err.context().request_id(), Some("req-compliance-v1-503"));
404    }
405
406    /// Compliance v2 业务错误必须保留 retry 语义与 request ID。
407    /// 同上用 `list()`(tenant)而非 `mine()`(user,#515):测试只配 tenant/app 凭证。
408    #[tokio::test]
409    async fn compliance_v2_preserves_retryable_error_and_request_id() {
410        let server = MockServer::start().await;
411        Mock::given(method("GET"))
412            .and(path("/open-apis/security_and_compliance/v2/device_records"))
413            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
414                "code": 429,
415                "msg": "compliance v2 rate limited",
416                "request_id": "req-compliance-v2-429"
417            })))
418            .mount(&server)
419            .await;
420
421        let config = Config::builder()
422            .app_id("test_app")
423            .app_secret("test_secret")
424            .base_url(server.uri())
425            .allow_custom_base_url(true)
426            .build()
427            .with_token_provider(TestTokenProvider("compliance_v2_token"));
428        let client = SecurityClient::new(config);
429
430        let err = client
431            .security_and_compliance
432            .v2()
433            .device_records()
434            .list()
435            .execute()
436            .await
437            .expect_err("compliance v2 业务错误必须向上传播");
438
439        assert!(err.is_retryable());
440        assert_eq!(err.context().request_id(), Some("req-compliance-v2-429"));
441    }
442
443    /// 行为验证:通过极小 timeout 触发超时错误,证明 req_timeout 配置已传播到执行路径。
444    /// 不依赖 client.config() 读取做验收。
445    #[tokio::test]
446    async fn security_client_timeout_propagates_and_triggers_timeout_error() {
447        let server = MockServer::start().await;
448
449        // 模拟慢响应(> timeout)
450        Mock::given(method("GET"))
451            .and(path("/open-apis/acs/v1/users"))
452            .respond_with(
453                ResponseTemplate::new(200)
454                    .set_body_json(serde_json::json!({"code":0,"msg":"ok","data":{"has_more":false,"items":[]}}))
455                    .set_delay(std::time::Duration::from_millis(800)),
456            )
457            .mount(&server)
458            .await;
459
460        let base = Config::builder()
461            .app_id("test_app")
462            .app_secret("test_secret")
463            .base_url(server.uri())
464            .allow_custom_base_url(true)
465            .req_timeout(std::time::Duration::from_millis(50)) // 极短,必超时
466            .build();
467
468        let config = base.with_token_provider(TestTokenProvider("test_tok_for_timeout_test"));
469
470        let client = SecurityClient::new(config);
471
472        let result = client.acs.v1().users().list().execute().await;
473
474        // 必须是错误,且与超时相关(reqwest timeout 或上层包装)
475        assert!(result.is_err(), "应因 timeout 配置触发错误");
476        let err = result.unwrap_err().to_string().to_lowercase();
477        // 短 timeout 常表现为网络/请求错误(reqwest 超时包装),接受宽松匹配
478        assert!(
479            err.contains("timeout")
480                || err.contains("time")
481                || err.contains("deadline")
482                || err.contains("network")
483                || err.contains("send"),
484            "错误应体现超时或网络失败,实际: {}",
485            err
486        );
487    }
488
489    /// 行为验证:小 max_response_size + 大响应体 → response_too_large 错误。
490    #[tokio::test]
491    async fn security_client_max_response_size_propagates_and_triggers_size_error() {
492        let server = MockServer::start().await;
493
494        // 构造一个 > limit 的响应(用大 body)
495        let big_body = serde_json::json!({
496            "code": 0,
497            "msg": "ok",
498            "data": { "has_more": false, "items": [ {"x": "y".repeat(1024)} ] }
499        });
500        let big_json = serde_json::to_vec(&big_body).unwrap();
501
502        Mock::given(method("GET"))
503            .and(path("/open-apis/acs/v1/users"))
504            .respond_with(ResponseTemplate::new(200).set_body_raw(big_json, "application/json"))
505            .mount(&server)
506            .await;
507
508        let base = Config::builder()
509            .app_id("test_app")
510            .app_secret("test_secret")
511            .base_url(server.uri())
512            .allow_custom_base_url(true)
513            .max_response_size(512) // 故意很小
514            .build();
515
516        let config = base.with_token_provider(TestTokenProvider("test_tok_for_size_test"));
517
518        let client = SecurityClient::new(config);
519
520        let result = client.acs.v1().users().list().execute().await;
521
522        assert!(result.is_err(), "应因 response size 超限触发错误");
523        let err = result.unwrap_err().to_string();
524        // 精确错误来自 CoreError::response_too_large
525        assert!(
526            err.contains("响应体过大")
527                || err.to_lowercase().contains("large")
528                || err.to_lowercase().contains("size")
529                || err.contains("超过限制"),
530            "错误应体现响应过大,实际: {}",
531            err
532        );
533    }
534}