pulseengine_mcp_auth/vault/
infisical.rs

1//! Infisical vault client implementation
2//!
3//! This module provides a client for Infisical's REST API using Universal Auth
4//! for secure secret management integration.
5
6use 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/// Infisical authentication response
16#[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/// Infisical authentication request
27#[derive(Debug, Serialize)]
28struct AuthRequest {
29    #[serde(rename = "clientId")]
30    client_id: String,
31    #[serde(rename = "clientSecret")]
32    client_secret: String,
33}
34
35/// Infisical secret response
36#[derive(Debug, Deserialize)]
37struct SecretResponse {
38    secret: SecretData,
39}
40
41/// Infisical secret data
42#[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/// Infisical secrets list response
58#[derive(Debug, Deserialize)]
59struct SecretsListResponse {
60    secrets: Vec<SecretListItem>,
61}
62
63/// Infisical secret list item
64#[derive(Debug, Deserialize)]
65struct SecretListItem {
66    #[serde(rename = "secretKey")]
67    secret_key: String,
68    #[allow(dead_code)]
69    version: Option<u32>,
70}
71
72/// Infisical create secret request
73#[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/// Token information
89#[derive(Debug, Clone)]
90struct TokenInfo {
91    token: String,
92    expires_at: std::time::Instant,
93}
94
95/// Infisical client implementation
96pub 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    /// Create a new Infisical client
109    pub async fn new(config: VaultConfig) -> Result<Self, VaultError> {
110        // Get credentials from environment
111        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        // Authenticate on creation
152        infisical_client.authenticate().await?;
153
154        Ok(infisical_client)
155    }
156
157    /// Get the base URL for Infisical API
158    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    /// Get a valid access token, refreshing if necessary
167    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            // Check if token is still valid (with 5 minute buffer)
172            if info.expires_at > std::time::Instant::now() + std::time::Duration::from_secs(300) {
173                return Ok(info.token.clone());
174            }
175        }
176
177        // Token expired or doesn't exist, need to re-authenticate
178        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    /// Parse ISO 8601 datetime string
192    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        // Validate token type is what we expect
238        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        // Include secret comment as a tag if present
330        if let Some(comment) = &secret.secret_comment {
331            if !comment.is_empty() {
332                tags.insert("comment".to_string(), comment.clone());
333            }
334        }
335
336        // Include version as a tag if present
337        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                // Could potentially include version info in the name for disambiguation
409                // For now, just return the key name as expected by the interface
410                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    /// Set a secret with an optional comment
503    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    /// Get a specific version of a secret
572    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        // Include secret comment as a tag if present
642        if let Some(comment) = &secret.secret_comment {
643            if !comment.is_empty() {
644                tags.insert("comment".to_string(), comment.clone());
645            }
646        }
647
648        // Include version as a tag
649        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    /// List all versions of a secret
667    pub async fn list_secret_versions(&self, name: &str) -> Result<Vec<u32>, VaultError> {
668        // Note: This would require a different API endpoint that lists secret versions
669        // For now, we'll return a placeholder implementation
670        // In a real implementation, you'd call an endpoint like /api/v3/secrets/{name}/versions
671
672        // Try to get the current secret and extract version info
673        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]) // Default to version 1 if no version info
681            }
682            Err(_) => Ok(vec![]), // Secret doesn't exist
683        }
684    }
685
686    /// Get the comment for a secret
687    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}