truthlinked_sdk/client.rs
1use crate::error::{Result, TruthlinkedError};
2use crate::license::LicenseKey;
3use crate::logging::{LoggingConfig, RequestLogger, RequestTimer};
4use crate::retry::{RetryConfig, RetryExecutor};
5use crate::signing::RequestSigner;
6use crate::types::*;
7use reqwest::{Client as HttpClient, StatusCode};
8use std::time::Duration;
9
10/// Truthlinked Authority Fabric API client
11///
12/// Provides type-safe access to the Truthlinked Authority Fabric API with
13/// enterprise-grade security and reliability features.
14///
15/// # Security Features
16/// - HTTPS-only communication (HTTP requests are rejected)
17/// - TLS certificate validation (no self-signed certificates)
18/// - License key memory protection (zeroized on drop)
19/// - Safe error handling (no credential leakage)
20/// - Connection pooling with reasonable limits
21/// - Request timeouts to prevent hanging
22///
23/// # Thread Safety
24/// This client is `Send + Sync` and can be safely shared across threads.
25/// Consider using `Arc<Client>` for shared access.
26///
27/// # Example
28/// ```rust,no_run
29/// use truthlinked_sdk::Client;
30///
31/// #[tokio::main]
32/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
33/// let client = Client::new(
34/// "https://api.truthlinked.org",
35/// std::env::var("TRUTHLINKED_LICENSE_KEY")?
36/// )?;
37///
38/// let health = client.health().await?;
39/// println!("Server status: {}", health.status);
40/// Ok(())
41/// }
42/// ```
43pub struct Client {
44 /// HTTP client with security hardening and connection pooling
45 http_client: HttpClient,
46 /// Base URL for API requests (must be HTTPS)
47 base_url: String,
48 /// License key with automatic memory protection
49 license_key: LicenseKey,
50 /// Request signer for replay attack prevention
51 signer: RequestSigner,
52 /// Retry executor with exponential backoff
53 retry_executor: RetryExecutor,
54 /// Request/response logger with credential redaction
55 logger: RequestLogger,
56}
57
58impl Client {
59 /// Creates a new Truthlinked API client
60 ///
61 /// # Arguments
62 /// * `base_url` - API base URL (must be HTTPS)
63 /// * `license_key` - Your Truthlinked license key
64 ///
65 /// # Security Guarantees
66 /// - Enforces HTTPS only (HTTP requests are rejected at client creation)
67 /// - Uses rustls TLS implementation (no OpenSSL vulnerabilities)
68 /// - Validates TLS certificates (rejects self-signed certificates)
69 /// - Configures reasonable timeouts (prevents indefinite hanging)
70 /// - Enables connection pooling (improves performance and reliability)
71 ///
72 /// # Errors
73 /// Returns `TruthlinkedError::InvalidRequest` if:
74 /// - Base URL does not start with "https://"
75 /// - HTTP client cannot be configured
76 ///
77 /// # Example
78 /// ```rust,no_run
79 /// use truthlinked_sdk::Client;
80 ///
81 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
82 /// let client = Client::new(
83 /// "https://api.truthlinked.org",
84 /// "tl_free_..."
85 /// )?;
86 /// # Ok(())
87 /// # }
88 /// ```
89 pub fn new(base_url: impl Into<String>, license_key: impl Into<String>) -> Result<Self> {
90 let base_url_string = base_url.into();
91 let license_key_string = license_key.into();
92
93 // Enforce HTTPS
94 if !base_url_string.starts_with("https://") {
95 return Err(TruthlinkedError::InvalidRequest(
96 "Base URL must use HTTPS".to_string()
97 ));
98 }
99
100 // Build HTTP client with security settings
101 let http_client = HttpClient::builder()
102 .timeout(Duration::from_secs(30))
103 .connect_timeout(Duration::from_secs(10))
104 .pool_idle_timeout(Duration::from_secs(90))
105 .pool_max_idle_per_host(10)
106 .https_only(true) // Enforce HTTPS
107 .build()?;
108
109 Ok(Self {
110 http_client,
111 signer: RequestSigner::new(&license_key_string),
112 base_url: base_url_string,
113 license_key: LicenseKey::new(license_key_string),
114 retry_executor: RetryExecutor::new(RetryConfig::production()),
115 logger: RequestLogger::new(LoggingConfig::production()),
116 })
117 }
118
119 /// Create client with custom configuration (used by ClientBuilder)
120 pub(crate) fn with_config(
121 http_client: HttpClient,
122 base_url: String,
123 license_key: String,
124 retry_config: RetryConfig,
125 logging_config: LoggingConfig,
126 ) -> Result<Self> {
127 Ok(Self {
128 http_client,
129 base_url,
130 signer: RequestSigner::new(&license_key),
131 license_key: LicenseKey::new(license_key),
132 retry_executor: RetryExecutor::new(retry_config),
133 logger: RequestLogger::new(logging_config),
134 })
135 }
136
137 /// Performs a health check against the Truthlinked API
138 ///
139 /// This endpoint does not require authentication and can be used to verify
140 /// that the API is accessible and responding correctly.
141 ///
142 /// # Returns
143 /// - `Ok(HealthResponse)` - Server is healthy and responding
144 /// - `Err(TruthlinkedError)` - Network error or server unavailable
145 ///
146 /// # Example
147 /// ```rust,no_run
148 /// # use truthlinked_sdk::Client;
149 /// # #[tokio::main]
150 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
151 /// # let client = Client::new("https://api.truthlinked.org", "key")?;
152 /// let health = client.health().await?;
153 /// assert_eq!(health.status, "healthy");
154 /// # Ok(())
155 /// # }
156 /// ```
157 pub async fn health(&self) -> Result<HealthResponse> {
158 let url = format!("{}/health", self.base_url);
159
160 self.retry_executor.execute(|| async {
161 let timer = RequestTimer::new();
162 let timestamp = RequestSigner::current_timestamp();
163 let signature = self.signer.sign_request("GET", "/health", b"", timestamp);
164
165 // Log request
166 let timestamp_str = timestamp.to_string();
167 let headers = vec![
168 ("X-Timestamp", timestamp_str.as_str()),
169 ("X-Signature", signature.as_str()),
170 ];
171 self.logger.log_request("GET", &url, &headers, b"");
172
173 match self.http_client
174 .get(&url)
175 .header("X-Timestamp", timestamp.to_string())
176 .header("X-Signature", signature)
177 .send()
178 .await
179 {
180 Ok(response) => {
181 let status = response.status().as_u16();
182 let response_headers = vec![]; // Would extract from response
183
184 match response.status() {
185 StatusCode::OK => {
186 let body = response.bytes().await?;
187 self.logger.log_response(status, &response_headers, &body, timer.elapsed());
188
189 let health: HealthResponse = serde_json::from_slice(&body)?;
190 Ok(health)
191 }
192 _ => {
193 let body = response.bytes().await?;
194 self.logger.log_response(status, &response_headers, &body, timer.elapsed());
195 self.handle_error_status(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
196 }
197 }
198 }
199 Err(e) => {
200 self.logger.log_error("GET", &url, &e.to_string(), timer.elapsed());
201 Err(e.into())
202 }
203 }
204 }).await
205 }
206
207 /// Exchanges an SSO token for an Authority Fabric token
208 ///
209 /// This operation requires a Professional tier license or higher.
210 /// The SSO token is validated and, if successful, an AF token is issued
211 /// with the requested scope (potentially narrowed based on policy).
212 ///
213 /// # Arguments
214 /// * `sso_token` - Valid SSO token from your identity provider
215 /// * `requested_scope` - List of permissions requested (e.g., ["read:users"])
216 /// * `nonce` - 32-byte cryptographic nonce (prevents replay attacks)
217 /// * `channel_binding` - 32-byte channel binding (prevents MITM attacks)
218 ///
219 /// # Security Notes
220 /// - Nonce must be cryptographically random and unique per request
221 /// - Channel binding should be derived from the TLS channel
222 /// - The granted scope may be narrower than requested based on policy
223 ///
224 /// # Errors
225 /// - `Unauthorized` - Invalid license key or SSO token
226 /// - `Forbidden` - License tier doesn't support token exchange
227 /// - `InvalidRequest` - Malformed request parameters
228 ///
229 /// # Example
230 /// ```rust,no_run
231 /// # use truthlinked_sdk::Client;
232 /// # #[tokio::main]
233 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
234 /// # let client = Client::new("https://api.truthlinked.org", "tl_pro_...")?;
235 /// use rand::Rng;
236 ///
237 /// let nonce: [u8; 32] = rand::thread_rng().gen();
238 /// let channel_binding: [u8; 32] = rand::thread_rng().gen();
239 ///
240 /// let response = client.exchange_token(
241 /// "eyJ0eXAiOiJKV1QiLCJhbGc...",
242 /// vec!["read:users".to_string()],
243 /// nonce,
244 /// channel_binding,
245 /// ).await?;
246 ///
247 /// println!("AF Token: {}", response.af_token);
248 /// # Ok(())
249 /// # }
250 /// ```
251 pub async fn exchange_token(
252 &self,
253 sso_token: impl Into<String>,
254 requested_scope: Vec<String>,
255 nonce: [u8; 32],
256 channel_binding: [u8; 32],
257 ) -> Result<TokenResponse> {
258 let url = format!("{}/v1/tokens", self.base_url);
259
260 let request = TokenRequest {
261 sso_token: sso_token.into(),
262 requested_scope,
263 nonce: hex::encode(nonce),
264 channel_binding: hex::encode(channel_binding),
265 };
266
267 let response = self.http_client
268 .post(&url)
269 .header("Authorization", format!("Bearer {}", self.license_key.as_str()))
270 .json(&request)
271 .send()
272 .await?;
273
274 self.handle_response(response).await
275 }
276
277 /// Validate AF token
278 pub async fn validate_token(&self, token_id: impl Into<String>) -> Result<ValidateResponse> {
279 let url = format!("{}/v1/tokens/{}/validate", self.base_url, token_id.into());
280
281 let response = self.http_client
282 .get(&url)
283 .header("Authorization", format!("Bearer {}", self.license_key.as_str()))
284 .send()
285 .await?;
286
287 self.handle_response(response).await
288 }
289
290 /// Retrieves shadow decisions showing breach prevention activity
291 ///
292 /// Shadow mode runs your IAM decisions through the Authority Fabric policy
293 /// engine in parallel, identifying cases where IAM would have allowed access
294 /// but AF would have denied it (indicating a potential security breach).
295 ///
296 /// This endpoint is available to all license tiers.
297 ///
298 /// # Returns
299 /// A list of shadow decisions, where each decision represents a divergence
300 /// between IAM and AF policy evaluation. Decisions with `breach_prevented: true`
301 /// indicate cases where AF would have prevented a security breach.
302 ///
303 /// # Example
304 /// ```rust,no_run
305 /// # use truthlinked_sdk::Client;
306 /// # #[tokio::main]
307 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
308 /// # let client = Client::new("https://api.truthlinked.org", "tl_free_...")?;
309 /// let decisions = client.get_shadow_decisions().await?;
310 ///
311 /// let breaches_prevented = decisions.iter()
312 /// .filter(|d| d.breach_prevented)
313 /// .count();
314 ///
315 /// println!("Breaches prevented: {}", breaches_prevented);
316 /// # Ok(())
317 /// # }
318 /// ```
319 pub async fn get_shadow_decisions(&self) -> Result<Vec<ShadowDecision>> {
320 let url = format!("{}/v1/shadow/decisions", self.base_url);
321
322 let response = self.http_client
323 .get(&url)
324 .header("Authorization", format!("Bearer {}", self.license_key.as_str()))
325 .send()
326 .await?;
327
328 self.handle_response(response).await
329 }
330
331 /// Replay IAM logs through AF policy engine
332 pub async fn replay_iam_logs(
333 &self,
334 logs: Vec<String>,
335 adapter: impl Into<String>,
336 ) -> Result<ReplayResponse> {
337 let url = format!("{}/v1/shadow/replay", self.base_url);
338
339 let request = ReplayRequest {
340 logs,
341 adapter: adapter.into(),
342 };
343
344 let response = self.http_client
345 .post(&url)
346 .header("Authorization", format!("Bearer {}", self.license_key.as_str()))
347 .json(&request)
348 .send()
349 .await?;
350
351 self.handle_response(response).await
352 }
353
354 /// Get SOX compliance report
355 pub async fn get_sox_report(&self) -> Result<SoxReport> {
356 let url = format!("{}/v1/compliance/sox", self.base_url);
357
358 let response = self.http_client
359 .get(&url)
360 .header("Authorization", format!("Bearer {}", self.license_key.as_str()))
361 .send()
362 .await?;
363
364 self.handle_response(response).await
365 }
366
367 /// Get PCI-DSS compliance report
368 pub async fn get_pci_report(&self) -> Result<PciReport> {
369 let url = format!("{}/v1/compliance/pci", self.base_url);
370
371 let response = self.http_client
372 .get(&url)
373 .header("Authorization", format!("Bearer {}", self.license_key.as_str()))
374 .send()
375 .await?;
376
377 self.handle_response(response).await
378 }
379
380 /// Get audit logs
381 pub async fn get_audit_logs(&self) -> Result<Vec<AuditLog>> {
382 let url = format!("{}/v1/audit/logs", self.base_url);
383
384 let response = self.http_client
385 .get(&url)
386 .header("Authorization", format!("Bearer {}", self.license_key.as_str()))
387 .send()
388 .await?;
389
390 self.handle_response(response).await
391 }
392
393 /// Get usage statistics
394 pub async fn get_usage(&self) -> Result<UsageResponse> {
395 let url = format!("{}/v1/usage", self.base_url);
396
397 let response = self.http_client
398 .get(&url)
399 .header("Authorization", format!("Bearer {}", self.license_key.as_str()))
400 .send()
401 .await?;
402
403 self.handle_response(response).await
404 }
405
406 /// Handle HTTP response with proper error mapping
407 async fn handle_response<T: serde::de::DeserializeOwned>(
408 &self,
409 response: reqwest::Response,
410 ) -> Result<T> {
411 let status = response.status();
412
413 match status {
414 StatusCode::OK => {
415 response.json::<T>().await.map_err(|_| TruthlinkedError::InvalidResponse)
416 }
417 StatusCode::UNAUTHORIZED => Err(TruthlinkedError::Unauthorized),
418 StatusCode::FORBIDDEN => Err(TruthlinkedError::Forbidden),
419 StatusCode::TOO_MANY_REQUESTS => {
420 let body = response.text().await.unwrap_or_default();
421 Err(TruthlinkedError::RateLimitExceeded(body))
422 }
423 StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
424 let body = response.text().await.unwrap_or_default();
425 Err(TruthlinkedError::InvalidRequest(body))
426 }
427 _ if status.is_server_error() => Err(TruthlinkedError::ServerError),
428 _ => Err(TruthlinkedError::InvalidResponse),
429 }
430 }
431}
432
433impl std::fmt::Debug for Client {
434 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435 f.debug_struct("Client")
436 .field("base_url", &self.base_url)
437 .field("license_key", &self.license_key.redacted())
438 .finish()
439 }
440}
441
442impl Client {
443 /// Handle HTTP error status codes
444 fn handle_error_status<T>(&self, status: StatusCode) -> Result<T> {
445 match status {
446 StatusCode::UNAUTHORIZED => Err(TruthlinkedError::Unauthorized),
447 StatusCode::FORBIDDEN => Err(TruthlinkedError::Forbidden),
448 StatusCode::TOO_MANY_REQUESTS => {
449 Err(TruthlinkedError::RateLimitExceeded("Rate limit exceeded".to_string()))
450 }
451 StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
452 Err(TruthlinkedError::InvalidRequest("Invalid request".to_string()))
453 }
454 _ if status.is_server_error() => Err(TruthlinkedError::ServerError),
455 _ => Err(TruthlinkedError::InvalidResponse),
456 }
457 }
458}