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