1use super::{SecretMetadata, VaultClient, VaultClientInfo, VaultConfig, VaultError, VaultType};
7use async_trait::async_trait;
8use reqwest::Client;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12use tokio::sync::RwLock;
13use tracing::{debug, info};
14
15#[derive(Debug, Deserialize)]
17struct AuthResponse {
18 #[serde(rename = "accessToken")]
19 access_token: String,
20 #[serde(rename = "expiresIn")]
21 expires_in: u64,
22 #[serde(rename = "tokenType")]
23 token_type: String,
24}
25
26#[derive(Debug, Serialize)]
28struct AuthRequest {
29 #[serde(rename = "clientId")]
30 client_id: String,
31 #[serde(rename = "clientSecret")]
32 client_secret: String,
33}
34
35#[derive(Debug, Deserialize)]
37struct SecretResponse {
38 secret: SecretData,
39}
40
41#[derive(Debug, Deserialize)]
43struct SecretData {
44 #[serde(rename = "secretKey")]
45 secret_key: String,
46 #[serde(rename = "secretValue")]
47 secret_value: String,
48 #[serde(rename = "secretComment")]
49 secret_comment: Option<String>,
50 version: Option<u32>,
51 #[serde(rename = "createdAt")]
52 created_at: Option<String>,
53 #[serde(rename = "updatedAt")]
54 updated_at: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
59struct SecretsListResponse {
60 secrets: Vec<SecretListItem>,
61}
62
63#[derive(Debug, Deserialize)]
65struct SecretListItem {
66 #[serde(rename = "secretKey")]
67 secret_key: String,
68 #[allow(dead_code)]
69 version: Option<u32>,
70}
71
72#[derive(Debug, Serialize)]
74struct CreateSecretRequest {
75 #[serde(rename = "secretKey")]
76 secret_key: String,
77 #[serde(rename = "secretValue")]
78 secret_value: String,
79 #[serde(rename = "secretComment")]
80 secret_comment: Option<String>,
81 #[serde(rename = "workspaceId")]
82 workspace_id: String,
83 environment: String,
84 #[serde(rename = "secretPath")]
85 secret_path: String,
86}
87
88#[derive(Debug, Clone)]
90struct TokenInfo {
91 token: String,
92 expires_at: std::time::Instant,
93}
94
95pub struct InfisicalClient {
97 config: VaultConfig,
98 client: Client,
99 client_id: String,
100 client_secret: String,
101 workspace_id: Option<String>,
102 environment: String,
103 secret_path: String,
104 token_info: Arc<RwLock<Option<TokenInfo>>>,
105}
106
107impl InfisicalClient {
108 pub async fn new(config: VaultConfig) -> Result<Self, VaultError> {
110 let client_id = std::env::var("INFISICAL_UNIVERSAL_AUTH_CLIENT_ID").map_err(|_| {
112 VaultError::ConfigError(
113 "INFISICAL_UNIVERSAL_AUTH_CLIENT_ID environment variable not set".to_string(),
114 )
115 })?;
116
117 let client_secret =
118 std::env::var("INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET").map_err(|_| {
119 VaultError::ConfigError(
120 "INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET environment variable not set"
121 .to_string(),
122 )
123 })?;
124
125 let workspace_id = std::env::var("INFISICAL_PROJECT_ID").ok();
126 let environment = config
127 .environment
128 .clone()
129 .unwrap_or_else(|| "dev".to_string());
130 let secret_path =
131 std::env::var("INFISICAL_SECRET_PATH").unwrap_or_else(|_| "/".to_string());
132
133 let client = Client::builder()
134 .timeout(std::time::Duration::from_secs(config.timeout_seconds))
135 .build()
136 .map_err(|e| {
137 VaultError::NetworkError(format!("Failed to create HTTP client: {}", e))
138 })?;
139
140 let infisical_client = Self {
141 config,
142 client,
143 client_id,
144 client_secret,
145 workspace_id,
146 environment,
147 secret_path,
148 token_info: Arc::new(RwLock::new(None)),
149 };
150
151 infisical_client.authenticate().await?;
153
154 Ok(infisical_client)
155 }
156
157 fn base_url(&self) -> String {
159 self.config
160 .base_url
161 .as_ref()
162 .unwrap_or(&"https://app.infisical.com".to_string())
163 .clone()
164 }
165
166 async fn get_access_token(&self) -> Result<String, VaultError> {
168 let token_info = self.token_info.read().await;
169
170 if let Some(info) = token_info.as_ref() {
171 if info.expires_at > std::time::Instant::now() + std::time::Duration::from_secs(300) {
173 return Ok(info.token.clone());
174 }
175 }
176
177 drop(token_info);
179
180 self.authenticate().await?;
181
182 let token_info = self.token_info.read().await;
183 token_info
184 .as_ref()
185 .map(|info| info.token.clone())
186 .ok_or_else(|| {
187 VaultError::AuthenticationFailed("Failed to obtain access token".to_string())
188 })
189 }
190
191 fn parse_datetime(&self, datetime_str: &str) -> Option<chrono::DateTime<chrono::Utc>> {
193 chrono::DateTime::parse_from_rfc3339(datetime_str)
194 .map(|dt| dt.with_timezone(&chrono::Utc))
195 .ok()
196 }
197}
198
199#[async_trait]
200impl VaultClient for InfisicalClient {
201 async fn authenticate(&self) -> Result<(), VaultError> {
202 let auth_url = format!("{}/api/v1/auth/universal-auth/login", self.base_url());
203
204 let auth_request = AuthRequest {
205 client_id: self.client_id.clone(),
206 client_secret: self.client_secret.clone(),
207 };
208
209 debug!("Authenticating with Infisical at {}", auth_url);
210
211 let response = self
212 .client
213 .post(&auth_url)
214 .json(&auth_request)
215 .send()
216 .await
217 .map_err(|e| {
218 VaultError::NetworkError(format!("Authentication request failed: {}", e))
219 })?;
220
221 if !response.status().is_success() {
222 let status = response.status();
223 let error_text = response
224 .text()
225 .await
226 .unwrap_or_else(|_| "Unknown error".to_string());
227 return Err(VaultError::AuthenticationFailed(format!(
228 "Authentication failed with status {}: {}",
229 status, error_text
230 )));
231 }
232
233 let auth_response: AuthResponse = response.json().await.map_err(|e| {
234 VaultError::InvalidResponse(format!("Failed to parse auth response: {}", e))
235 })?;
236
237 if auth_response.token_type.to_lowercase() != "bearer" {
239 return Err(VaultError::AuthenticationFailed(format!(
240 "Unexpected token type: {} (expected: Bearer)",
241 auth_response.token_type
242 )));
243 }
244
245 let expires_at =
246 std::time::Instant::now() + std::time::Duration::from_secs(auth_response.expires_in);
247
248 let token_info = TokenInfo {
249 token: auth_response.access_token,
250 expires_at,
251 };
252
253 let mut token_guard = self.token_info.write().await;
254 *token_guard = Some(token_info);
255
256 info!("Successfully authenticated with Infisical");
257 Ok(())
258 }
259
260 async fn get_secret(&self, name: &str) -> Result<String, VaultError> {
261 let (value, _) = self.get_secret_with_metadata(name).await?;
262 Ok(value)
263 }
264
265 async fn get_secret_with_metadata(
266 &self,
267 name: &str,
268 ) -> Result<(String, SecretMetadata), VaultError> {
269 let token = self.get_access_token().await?;
270
271 let mut secret_url = format!(
272 "{}/api/v3/secrets/raw/{}?environment={}&secretPath={}",
273 self.base_url(),
274 urlencoding::encode(name),
275 urlencoding::encode(&self.environment),
276 urlencoding::encode(&self.secret_path)
277 );
278
279 if let Some(workspace_id) = &self.workspace_id {
280 secret_url = format!("{}&workspaceId={}", secret_url, workspace_id);
281 }
282
283 debug!("Fetching secret '{}' from Infisical", name);
284
285 let response = self
286 .client
287 .get(&secret_url)
288 .header("Authorization", format!("Bearer {}", token))
289 .send()
290 .await
291 .map_err(|e| VaultError::NetworkError(format!("Secret request failed: {}", e)))?;
292
293 if response.status() == 404 {
294 return Err(VaultError::SecretNotFound(name.to_string()));
295 }
296
297 if !response.status().is_success() {
298 let status = response.status();
299 let error_text = response
300 .text()
301 .await
302 .unwrap_or_else(|_| "Unknown error".to_string());
303
304 if status == 401 {
305 return Err(VaultError::AuthenticationFailed(
306 "Access token expired or invalid".to_string(),
307 ));
308 } else if status == 403 {
309 return Err(VaultError::AccessDenied(format!(
310 "Access denied to secret '{}'",
311 name
312 )));
313 }
314
315 return Err(VaultError::NetworkError(format!(
316 "Secret request failed with status {}: {}",
317 status, error_text
318 )));
319 }
320
321 let secret_response: SecretResponse = response.json().await.map_err(|e| {
322 VaultError::InvalidResponse(format!("Failed to parse secret response: {}", e))
323 })?;
324
325 let secret = secret_response.secret;
326
327 let mut tags = HashMap::new();
328
329 if let Some(comment) = &secret.secret_comment {
331 if !comment.is_empty() {
332 tags.insert("comment".to_string(), comment.clone());
333 }
334 }
335
336 if let Some(version) = secret.version {
338 tags.insert("version".to_string(), version.to_string());
339 }
340
341 let metadata = SecretMetadata {
342 name: secret.secret_key.clone(),
343 version: secret.version.map(|v| v.to_string()),
344 created_at: secret.created_at.and_then(|s| self.parse_datetime(&s)),
345 updated_at: secret.updated_at.and_then(|s| self.parse_datetime(&s)),
346 tags,
347 };
348
349 debug!("Successfully retrieved secret '{}'", name);
350 Ok((secret.secret_value, metadata))
351 }
352
353 async fn list_secrets(&self) -> Result<Vec<String>, VaultError> {
354 let token = self.get_access_token().await?;
355
356 let mut list_url = format!(
357 "{}/api/v3/secrets?environment={}&secretPath={}",
358 self.base_url(),
359 urlencoding::encode(&self.environment),
360 urlencoding::encode(&self.secret_path)
361 );
362
363 if let Some(workspace_id) = &self.workspace_id {
364 list_url = format!("{}&workspaceId={}", list_url, workspace_id);
365 }
366
367 debug!("Listing secrets from Infisical");
368
369 let response = self
370 .client
371 .get(&list_url)
372 .header("Authorization", format!("Bearer {}", token))
373 .send()
374 .await
375 .map_err(|e| VaultError::NetworkError(format!("List secrets request failed: {}", e)))?;
376
377 if !response.status().is_success() {
378 let status = response.status();
379 let error_text = response
380 .text()
381 .await
382 .unwrap_or_else(|_| "Unknown error".to_string());
383
384 if status == 401 {
385 return Err(VaultError::AuthenticationFailed(
386 "Access token expired or invalid".to_string(),
387 ));
388 } else if status == 403 {
389 return Err(VaultError::AccessDenied(
390 "Access denied to list secrets".to_string(),
391 ));
392 }
393
394 return Err(VaultError::NetworkError(format!(
395 "List secrets failed with status {}: {}",
396 status, error_text
397 )));
398 }
399
400 let secrets_response: SecretsListResponse = response.json().await.map_err(|e| {
401 VaultError::InvalidResponse(format!("Failed to parse secrets list response: {}", e))
402 })?;
403
404 let secret_names: Vec<String> = secrets_response
405 .secrets
406 .into_iter()
407 .map(|secret| {
408 secret.secret_key
411 })
412 .collect();
413
414 debug!("Successfully listed {} secrets", secret_names.len());
415 Ok(secret_names)
416 }
417
418 async fn set_secret(&self, name: &str, value: &str) -> Result<(), VaultError> {
419 self.set_secret_with_comment(name, value, None).await
420 }
421
422 async fn delete_secret(&self, name: &str) -> Result<(), VaultError> {
423 let token = self.get_access_token().await?;
424
425 let mut delete_url = format!(
426 "{}/api/v3/secrets/{}?environment={}&secretPath={}",
427 self.base_url(),
428 urlencoding::encode(name),
429 urlencoding::encode(&self.environment),
430 urlencoding::encode(&self.secret_path)
431 );
432
433 if let Some(workspace_id) = &self.workspace_id {
434 delete_url = format!("{}&workspaceId={}", delete_url, workspace_id);
435 }
436
437 debug!("Deleting secret '{}' from Infisical", name);
438
439 let response = self
440 .client
441 .delete(&delete_url)
442 .header("Authorization", format!("Bearer {}", token))
443 .send()
444 .await
445 .map_err(|e| {
446 VaultError::NetworkError(format!("Delete secret request failed: {}", e))
447 })?;
448
449 if response.status() == 404 {
450 return Err(VaultError::SecretNotFound(name.to_string()));
451 }
452
453 if !response.status().is_success() {
454 let status = response.status();
455 let error_text = response
456 .text()
457 .await
458 .unwrap_or_else(|_| "Unknown error".to_string());
459
460 if status == 401 {
461 return Err(VaultError::AuthenticationFailed(
462 "Access token expired or invalid".to_string(),
463 ));
464 } else if status == 403 {
465 return Err(VaultError::AccessDenied(format!(
466 "Access denied to delete secret '{}'",
467 name
468 )));
469 }
470
471 return Err(VaultError::NetworkError(format!(
472 "Delete secret failed with status {}: {}",
473 status, error_text
474 )));
475 }
476
477 info!("Successfully deleted secret '{}'", name);
478 Ok(())
479 }
480
481 async fn is_authenticated(&self) -> bool {
482 let token_info = self.token_info.read().await;
483
484 if let Some(info) = token_info.as_ref() {
485 info.expires_at > std::time::Instant::now()
486 } else {
487 false
488 }
489 }
490
491 fn client_info(&self) -> VaultClientInfo {
492 VaultClientInfo {
493 name: "Infisical Client".to_string(),
494 version: env!("CARGO_PKG_VERSION").to_string(),
495 vault_type: VaultType::Infisical,
496 read_only: false,
497 }
498 }
499}
500
501impl InfisicalClient {
502 pub async fn set_secret_with_comment(
504 &self,
505 name: &str,
506 value: &str,
507 comment: Option<&str>,
508 ) -> Result<(), VaultError> {
509 let workspace_id = self.workspace_id.as_ref().ok_or_else(|| {
510 VaultError::ConfigError("Workspace ID required for creating secrets".to_string())
511 })?;
512
513 let token = self.get_access_token().await?;
514
515 let create_url = format!(
516 "{}/api/v3/secrets/{}",
517 self.base_url(),
518 urlencoding::encode(name)
519 );
520
521 let create_request = CreateSecretRequest {
522 secret_key: name.to_string(),
523 secret_value: value.to_string(),
524 secret_comment: comment.map(|c| c.to_string()),
525 workspace_id: workspace_id.clone(),
526 environment: self.environment.clone(),
527 secret_path: self.secret_path.clone(),
528 };
529
530 debug!("Creating/updating secret '{}' in Infisical", name);
531
532 let response = self
533 .client
534 .post(&create_url)
535 .header("Authorization", format!("Bearer {}", token))
536 .json(&create_request)
537 .send()
538 .await
539 .map_err(|e| {
540 VaultError::NetworkError(format!("Create secret request failed: {}", e))
541 })?;
542
543 if !response.status().is_success() {
544 let status = response.status();
545 let error_text = response
546 .text()
547 .await
548 .unwrap_or_else(|_| "Unknown error".to_string());
549
550 if status == 401 {
551 return Err(VaultError::AuthenticationFailed(
552 "Access token expired or invalid".to_string(),
553 ));
554 } else if status == 403 {
555 return Err(VaultError::AccessDenied(format!(
556 "Access denied to create secret '{}'",
557 name
558 )));
559 }
560
561 return Err(VaultError::NetworkError(format!(
562 "Create secret failed with status {}: {}",
563 status, error_text
564 )));
565 }
566
567 info!("Successfully created/updated secret '{}'", name);
568 Ok(())
569 }
570
571 pub async fn get_secret_version(
573 &self,
574 name: &str,
575 version: u32,
576 ) -> Result<(String, SecretMetadata), VaultError> {
577 let token = self.get_access_token().await?;
578
579 let mut secret_url = format!(
580 "{}/api/v3/secrets/raw/{}?environment={}&secretPath={}&version={}",
581 self.base_url(),
582 urlencoding::encode(name),
583 urlencoding::encode(&self.environment),
584 urlencoding::encode(&self.secret_path),
585 version
586 );
587
588 if let Some(workspace_id) = &self.workspace_id {
589 secret_url = format!("{}&workspaceId={}", secret_url, workspace_id);
590 }
591
592 debug!(
593 "Fetching secret '{}' version {} from Infisical",
594 name, version
595 );
596
597 let response = self
598 .client
599 .get(&secret_url)
600 .header("Authorization", format!("Bearer {}", token))
601 .send()
602 .await
603 .map_err(|e| VaultError::NetworkError(format!("Secret request failed: {}", e)))?;
604
605 if response.status() == 404 {
606 return Err(VaultError::SecretNotFound(format!("{}:v{}", name, version)));
607 }
608
609 if !response.status().is_success() {
610 let status = response.status();
611 let error_text = response
612 .text()
613 .await
614 .unwrap_or_else(|_| "Unknown error".to_string());
615
616 if status == 401 {
617 return Err(VaultError::AuthenticationFailed(
618 "Access token expired or invalid".to_string(),
619 ));
620 } else if status == 403 {
621 return Err(VaultError::AccessDenied(format!(
622 "Access denied to secret '{}' version {}",
623 name, version
624 )));
625 }
626
627 return Err(VaultError::NetworkError(format!(
628 "Secret request failed with status {}: {}",
629 status, error_text
630 )));
631 }
632
633 let secret_response: SecretResponse = response.json().await.map_err(|e| {
634 VaultError::InvalidResponse(format!("Failed to parse secret response: {}", e))
635 })?;
636
637 let secret = secret_response.secret;
638
639 let mut tags = HashMap::new();
640
641 if let Some(comment) = &secret.secret_comment {
643 if !comment.is_empty() {
644 tags.insert("comment".to_string(), comment.clone());
645 }
646 }
647
648 tags.insert("version".to_string(), version.to_string());
650
651 let metadata = SecretMetadata {
652 name: secret.secret_key.clone(),
653 version: Some(version.to_string()),
654 created_at: secret.created_at.and_then(|s| self.parse_datetime(&s)),
655 updated_at: secret.updated_at.and_then(|s| self.parse_datetime(&s)),
656 tags,
657 };
658
659 debug!(
660 "Successfully retrieved secret '{}' version {}",
661 name, version
662 );
663 Ok((secret.secret_value, metadata))
664 }
665
666 pub async fn list_secret_versions(&self, name: &str) -> Result<Vec<u32>, VaultError> {
668 match self.get_secret_with_metadata(name).await {
674 Ok((_, metadata)) => {
675 if let Some(version_str) = metadata.version {
676 if let Ok(version) = version_str.parse::<u32>() {
677 return Ok(vec![version]);
678 }
679 }
680 Ok(vec![1]) }
682 Err(_) => Ok(vec![]), }
684 }
685
686 pub async fn get_secret_comment(&self, name: &str) -> Result<Option<String>, VaultError> {
688 let (_, metadata) = self.get_secret_with_metadata(name).await?;
689 Ok(metadata.tags.get("comment").cloned())
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[test]
698 fn test_auth_request_serialization() {
699 let request = AuthRequest {
700 client_id: "test-client".to_string(),
701 client_secret: "test-secret".to_string(),
702 };
703
704 let json = serde_json::to_string(&request).unwrap();
705 assert!(json.contains("clientId"));
706 assert!(json.contains("clientSecret"));
707 }
708
709 #[test]
710 fn test_secret_response_deserialization() {
711 let json = r#"{
712 "secret": {
713 "secretKey": "TEST_KEY",
714 "secretValue": "test-value",
715 "secretComment": "Test comment",
716 "version": 1,
717 "createdAt": "2023-01-01T00:00:00.000Z",
718 "updatedAt": "2023-01-01T00:00:00.000Z"
719 }
720 }"#;
721
722 let response: SecretResponse = serde_json::from_str(json).unwrap();
723 assert_eq!(response.secret.secret_key, "TEST_KEY");
724 assert_eq!(response.secret.secret_value, "test-value");
725 assert_eq!(response.secret.version, Some(1));
726 }
727}