Skip to main content

alien_core/
client_config.rs

1//! Client configuration structures for different cloud platforms
2//!
3//! This module contains the configuration structs for all supported cloud platforms.
4//! These structs define the authentication and platform-specific settings needed
5//! to connect to cloud services, but do not contain implementation logic (which
6//! remains in the respective client crates).
7
8use crate::Platform;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Service endpoint overrides for testing AWS services
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15#[serde(rename_all = "camelCase", deny_unknown_fields)]
16pub struct AwsServiceOverrides {
17    /// Override endpoints for specific AWS services
18    /// Key is the service name (e.g., "lambda", "s3"), value is the base URL
19    pub endpoints: HashMap<String, String>,
20}
21
22/// Configuration for AWS role impersonation
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct AwsImpersonationConfig {
27    /// The ARN of the role to assume
28    pub role_arn: String,
29    /// Optional session name for the assumed role session
30    pub session_name: Option<String>,
31    /// Optional duration for the assumed role credentials (in seconds)
32    pub duration_seconds: Option<i32>,
33    /// Optional external ID for the assume role operation
34    pub external_id: Option<String>,
35    /// Optional target region override. When provided, the impersonated config
36    /// uses this region instead of inheriting the caller's region. Required for
37    /// cross-region impersonation (e.g., management in us-east-1 targeting us-east-2).
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub target_region: Option<String>,
40}
41
42/// Configuration for AWS Web Identity Token authentication
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46pub struct AwsWebIdentityConfig {
47    /// The ARN of the role to assume
48    pub role_arn: String,
49    /// Optional session name for the assumed role session
50    pub session_name: Option<String>,
51    /// The path to the web identity token file
52    pub web_identity_token_file: String,
53    /// Optional duration for the assumed role credentials (in seconds)
54    pub duration_seconds: Option<i32>,
55}
56
57/// Supported AWS authentication methods
58#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60#[serde(rename_all = "camelCase", tag = "type")]
61pub enum AwsCredentials {
62    /// Static direct access keys.
63    AccessKeys {
64        /// AWS Access Key ID
65        access_key_id: String,
66        /// AWS Secret Access Key
67        secret_access_key: String,
68        /// Optional AWS Session Token
69        session_token: Option<String>,
70    },
71    /// Temporary AWS session credentials with an expiration time.
72    SessionCredentials {
73        /// AWS Access Key ID
74        access_key_id: String,
75        /// AWS Secret Access Key
76        secret_access_key: String,
77        /// AWS Session Token
78        session_token: String,
79        /// Credential expiration as an RFC3339 timestamp
80        expires_at: String,
81    },
82    /// AWS Instance Metadata Service credentials.
83    Imds {
84        /// Optional IMDS endpoint override
85        endpoint: Option<String>,
86    },
87    /// Container credentials endpoint: ECS task roles and EKS Pod Identity.
88    Container {
89        /// Credentials endpoint URL
90        endpoint: String,
91        /// Authorization header value for the endpoint
92        authorization_token: Option<String>,
93        /// File holding the authorization header value, re-read on each
94        /// refresh because the platform rotates it
95        authorization_token_file: Option<String>,
96    },
97    /// AWS profile credentials loaded via the AWS CLI.
98    Profile {
99        /// AWS profile name
100        name: String,
101    },
102    /// Web Identity Token for OIDC authentication
103    WebIdentity {
104        /// Web identity configuration
105        config: AwsWebIdentityConfig,
106    },
107}
108
109impl std::fmt::Debug for AwsCredentials {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            AwsCredentials::AccessKeys {
113                access_key_id,
114                session_token,
115                ..
116            } => f
117                .debug_struct("AwsCredentials::AccessKeys")
118                .field("access_key_id", access_key_id)
119                .field("secret_access_key", &"[REDACTED]")
120                .field(
121                    "session_token",
122                    &session_token.as_ref().map(|_| "[REDACTED]"),
123                )
124                .finish(),
125            AwsCredentials::SessionCredentials {
126                access_key_id,
127                expires_at,
128                ..
129            } => f
130                .debug_struct("AwsCredentials::SessionCredentials")
131                .field("access_key_id", access_key_id)
132                .field("secret_access_key", &"[REDACTED]")
133                .field("session_token", &"[REDACTED]")
134                .field("expires_at", expires_at)
135                .finish(),
136            AwsCredentials::Imds { endpoint } => f
137                .debug_struct("AwsCredentials::Imds")
138                .field("endpoint", endpoint)
139                .finish(),
140            AwsCredentials::Container {
141                endpoint,
142                authorization_token,
143                authorization_token_file,
144            } => f
145                .debug_struct("AwsCredentials::Container")
146                .field("endpoint", endpoint)
147                .field(
148                    "authorization_token",
149                    &authorization_token.as_ref().map(|_| "[REDACTED]"),
150                )
151                .field("authorization_token_file", authorization_token_file)
152                .finish(),
153            AwsCredentials::Profile { name } => f
154                .debug_struct("AwsCredentials::Profile")
155                .field("name", name)
156                .finish(),
157            AwsCredentials::WebIdentity { config } => f
158                .debug_struct("AwsCredentials::WebIdentity")
159                .field("config", config)
160                .finish(),
161        }
162    }
163}
164
165/// AWS client configuration
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
168#[serde(rename_all = "camelCase", deny_unknown_fields)]
169pub struct AwsClientConfig {
170    /// The AWS Account ID.
171    pub account_id: String,
172    /// The AWS region.
173    pub region: String,
174    /// AWS authentication credentials.
175    pub credentials: AwsCredentials,
176    /// Service endpoint overrides for testing
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub service_overrides: Option<AwsServiceOverrides>,
179}
180
181/// Service endpoint overrides for testing GCP services
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
184#[serde(rename_all = "camelCase", deny_unknown_fields)]
185pub struct GcpServiceOverrides {
186    /// Override endpoints for specific GCP services
187    /// Key is the service name (e.g., "cloudrun", "storage"), value is the base URL
188    pub endpoints: HashMap<String, String>,
189}
190
191/// Authentication options for talking to GCP APIs.
192#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
194#[serde(rename_all = "camelCase", tag = "type")]
195pub enum GcpCredentials {
196    /// Use an already-minted OAuth2 access token.
197    AccessToken { token: String },
198
199    /// Use a refreshable service account impersonation source.
200    ImpersonatedServiceAccount {
201        /// Source configuration used to call IAMCredentials.
202        #[cfg_attr(feature = "openapi", schema(value_type = Object))]
203        source: Box<GcpClientConfig>,
204        /// Service account impersonation request.
205        config: GcpImpersonationConfig,
206    },
207
208    /// Use a full Service Account JSON key (as string). A short-lived JWT will
209    /// be created and exchanged for a bearer token automatically.
210    ServiceAccountKey { json: String },
211
212    /// Use GCP metadata server for authentication (for instances running on GCP)
213    ServiceMetadata,
214
215    /// Use projected service account token (for Kubernetes workload identity)
216    ProjectedServiceAccount {
217        /// Path to the projected service account token
218        token_file: String,
219        /// Service account email
220        service_account_email: String,
221    },
222
223    /// Use an external account credential configuration.
224    ExternalAccount {
225        /// Workload identity audience.
226        audience: String,
227        /// Subject token type for STS token exchange.
228        subject_token_type: String,
229        /// STS token exchange URL.
230        token_url: String,
231        /// Path to the subject token file.
232        credential_source_file: String,
233        /// Optional service account impersonation URL.
234        service_account_impersonation_url: Option<String>,
235    },
236
237    /// Use gcloud Application Default Credentials (authorized_user).
238    /// Exchanges refresh_token for an access_token via Google's OAuth2 endpoint.
239    AuthorizedUser {
240        /// OAuth2 client ID
241        client_id: String,
242        /// OAuth2 client secret
243        client_secret: String,
244        /// OAuth2 refresh token
245        refresh_token: String,
246    },
247}
248
249impl std::fmt::Debug for GcpCredentials {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        match self {
252            GcpCredentials::AccessToken { .. } => f
253                .debug_struct("GcpCredentials::AccessToken")
254                .field("token", &"[REDACTED]")
255                .finish(),
256            GcpCredentials::ImpersonatedServiceAccount { source, config } => f
257                .debug_struct("GcpCredentials::ImpersonatedServiceAccount")
258                .field("source_project_id", &source.project_id)
259                .field("source_region", &source.region)
260                .field("service_account_email", &config.service_account_email)
261                .finish(),
262            GcpCredentials::ServiceAccountKey { .. } => f
263                .debug_struct("GcpCredentials::ServiceAccountKey")
264                .field("json", &"[REDACTED]")
265                .finish(),
266            GcpCredentials::ServiceMetadata => write!(f, "GcpCredentials::ServiceMetadata"),
267            GcpCredentials::ProjectedServiceAccount {
268                token_file,
269                service_account_email,
270            } => f
271                .debug_struct("GcpCredentials::ProjectedServiceAccount")
272                .field("token_file", token_file)
273                .field("service_account_email", service_account_email)
274                .finish(),
275            GcpCredentials::ExternalAccount {
276                audience,
277                subject_token_type,
278                token_url,
279                credential_source_file,
280                service_account_impersonation_url,
281            } => f
282                .debug_struct("GcpCredentials::ExternalAccount")
283                .field("audience", audience)
284                .field("subject_token_type", subject_token_type)
285                .field("token_url", token_url)
286                .field("credential_source_file", credential_source_file)
287                .field(
288                    "service_account_impersonation_url",
289                    service_account_impersonation_url,
290                )
291                .finish(),
292            GcpCredentials::AuthorizedUser { client_id, .. } => f
293                .debug_struct("GcpCredentials::AuthorizedUser")
294                .field("client_id", client_id)
295                .field("client_secret", &"[REDACTED]")
296                .field("refresh_token", &"[REDACTED]")
297                .finish(),
298        }
299    }
300}
301
302/// Configuration for GCP service account impersonation
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
305#[serde(rename_all = "camelCase", deny_unknown_fields)]
306pub struct GcpImpersonationConfig {
307    /// The email of the service account to impersonate
308    pub service_account_email: String,
309    /// The OAuth 2.0 scopes that define the access token's permissions
310    pub scopes: Vec<String>,
311    /// Optional sequence of service accounts in a delegation chain
312    pub delegates: Option<Vec<String>>,
313    /// Optional desired lifetime duration of the access token (max 3600s)
314    pub lifetime: Option<String>,
315    /// Optional target project ID override. When provided, the impersonated config
316    /// uses this project ID instead of inheriting the caller's project.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub target_project_id: Option<String>,
319    /// Optional target region override. When provided, the impersonated config
320    /// uses this region instead of inheriting the caller's region.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub target_region: Option<String>,
323}
324
325impl Default for GcpImpersonationConfig {
326    fn default() -> Self {
327        Self {
328            service_account_email: String::new(),
329            scopes: vec!["https://www.googleapis.com/auth/cloud-platform".to_string()],
330            delegates: None,
331            lifetime: Some("3600s".to_string()),
332            target_project_id: None,
333            target_region: None,
334        }
335    }
336}
337
338/// GCP client configuration
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
341#[serde(rename_all = "camelCase", deny_unknown_fields)]
342pub struct GcpClientConfig {
343    /// The GCP Project ID.
344    pub project_id: String,
345    /// The GCP region for resources.
346    pub region: String,
347    /// GCP authentication credentials.
348    pub credentials: GcpCredentials,
349    /// Service endpoint overrides for testing
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub service_overrides: Option<GcpServiceOverrides>,
352    /// The GCP project number (numeric). Resolved at runtime via Resource Manager API.
353    /// Used in IAM condition expressions where resource.name uses project number.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub project_number: Option<String>,
356}
357
358/// Service endpoint overrides for testing Azure services
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
361#[serde(rename_all = "camelCase", deny_unknown_fields)]
362pub struct AzureServiceOverrides {
363    /// Override endpoints for specific Azure services
364    /// Key is the service name (e.g., "management", "storage", "containerApps"), value is the base URL
365    pub endpoints: HashMap<String, String>,
366}
367
368/// Represents Azure authentication credentials
369#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
370#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
371#[serde(rename_all = "camelCase", tag = "type")]
372pub enum AzureCredentials {
373    /// Service principal with client secret
374    ServicePrincipal {
375        /// The client ID (application ID)
376        client_id: String,
377        /// The client secret
378        client_secret: String,
379    },
380    /// Direct access token
381    AccessToken {
382        /// The bearer token to use for authentication
383        token: String,
384    },
385    /// Short-lived bearer tokens keyed by their exact Azure OAuth scope.
386    ///
387    /// This is the only Azure credential form returned by the credential mint
388    /// endpoint. It contains no refreshable source credential and must not be
389    /// used for a scope that is absent from the map.
390    ScopedAccessTokens {
391        /// Exact scope-to-token map. Minted configs include only the Azure
392        /// management, storage, Key Vault, and Service Bus scopes used by
393        /// Alien bindings.
394        tokens: HashMap<String, String>,
395    },
396    /// Azure VM IMDS managed identity.
397    VmManagedIdentity {
398        /// The client ID of the user-assigned managed identity
399        client_id: String,
400        /// Optional IMDS endpoint override
401        identity_endpoint: Option<String>,
402    },
403    /// Azure AD Workload Identity (federated identity)
404    WorkloadIdentity {
405        /// The client ID of the managed identity or application
406        client_id: String,
407        /// The tenant ID for authentication
408        tenant_id: String,
409        /// Path to the federated token file
410        federated_token_file: String,
411        /// The authority host URL
412        authority_host: String,
413    },
414    /// Azure Managed Identity (Container Apps / App Service)
415    /// Uses IDENTITY_ENDPOINT + IDENTITY_HEADER injected by the platform
416    ManagedIdentity {
417        /// The client ID of the user-assigned managed identity
418        client_id: String,
419        /// The identity endpoint URL (from IDENTITY_ENDPOINT env var)
420        identity_endpoint: String,
421        /// The identity header secret (from IDENTITY_HEADER env var)
422        identity_header: String,
423    },
424}
425
426impl std::fmt::Debug for AzureCredentials {
427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428        match self {
429            AzureCredentials::ServicePrincipal { client_id, .. } => f
430                .debug_struct("AzureCredentials::ServicePrincipal")
431                .field("client_id", client_id)
432                .field("client_secret", &"[REDACTED]")
433                .finish(),
434            AzureCredentials::AccessToken { .. } => f
435                .debug_struct("AzureCredentials::AccessToken")
436                .field("token", &"[REDACTED]")
437                .finish(),
438            AzureCredentials::ScopedAccessTokens { tokens } => f
439                .debug_struct("AzureCredentials::ScopedAccessTokens")
440                .field("scopes", &tokens.keys().collect::<Vec<_>>())
441                .field("tokens", &"[REDACTED]")
442                .finish(),
443            AzureCredentials::VmManagedIdentity {
444                client_id,
445                identity_endpoint,
446            } => f
447                .debug_struct("AzureCredentials::VmManagedIdentity")
448                .field("client_id", client_id)
449                .field("identity_endpoint", identity_endpoint)
450                .finish(),
451            AzureCredentials::WorkloadIdentity {
452                client_id,
453                tenant_id,
454                federated_token_file,
455                authority_host,
456            } => f
457                .debug_struct("AzureCredentials::WorkloadIdentity")
458                .field("client_id", client_id)
459                .field("tenant_id", tenant_id)
460                .field("federated_token_file", federated_token_file)
461                .field("authority_host", authority_host)
462                .finish(),
463            AzureCredentials::ManagedIdentity {
464                client_id,
465                identity_endpoint,
466                ..
467            } => f
468                .debug_struct("AzureCredentials::ManagedIdentity")
469                .field("client_id", client_id)
470                .field("identity_endpoint", identity_endpoint)
471                .field("identity_header", &"[REDACTED]")
472                .finish(),
473        }
474    }
475}
476
477/// Configuration for Azure managed identity impersonation
478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
480#[serde(rename_all = "camelCase", deny_unknown_fields)]
481pub struct AzureImpersonationConfig {
482    /// The client ID of the managed identity or service principal to impersonate
483    pub client_id: String,
484    /// The scope for the access token (e.g., "https://management.azure.com/.default")
485    pub scope: String,
486    /// Optional tenant ID for cross-tenant impersonation
487    pub tenant_id: Option<String>,
488    /// Optional target subscription ID override. When provided, the impersonated config
489    /// uses this subscription instead of inheriting the caller's subscription.
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub target_subscription_id: Option<String>,
492    /// Optional target region override. When provided, the impersonated config
493    /// uses this region instead of inheriting the caller's region.
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub target_region: Option<String>,
496}
497
498impl Default for AzureImpersonationConfig {
499    fn default() -> Self {
500        Self {
501            client_id: String::new(),
502            scope: "https://management.azure.com/.default".to_string(),
503            tenant_id: None,
504            target_subscription_id: None,
505            target_region: None,
506        }
507    }
508}
509
510/// Azure client configuration
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
512#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
513#[serde(rename_all = "camelCase", deny_unknown_fields)]
514pub struct AzureClientConfig {
515    /// The Azure Subscription ID where resources will be deployed.
516    pub subscription_id: String,
517    /// The customer's Azure Tenant ID.
518    pub tenant_id: String,
519    /// Azure region for resources.
520    pub region: Option<String>,
521    /// Azure authentication credentials.
522    pub credentials: AzureCredentials,
523    /// Service endpoint overrides for testing
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub service_overrides: Option<AzureServiceOverrides>,
526}
527
528/// Configuration mode for Kubernetes access
529#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
530#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
531#[serde(rename_all = "camelCase", tag = "mode")]
532pub enum KubernetesClientConfig {
533    /// Use in-cluster configuration (service account tokens, etc.)
534    InCluster {
535        /// The namespace to operate in
536        #[serde(skip_serializing_if = "Option::is_none")]
537        namespace: Option<String>,
538        /// Additional headers to include in requests
539        #[serde(skip_serializing_if = "Option::is_none")]
540        additional_headers: Option<HashMap<String, String>>,
541    },
542    /// Use kubeconfig file for configuration
543    Kubeconfig {
544        /// Path to kubeconfig file (optional, defaults to standard locations)
545        #[serde(skip_serializing_if = "Option::is_none")]
546        kubeconfig_path: Option<String>,
547        /// Context name to use (optional, defaults to current-context)
548        #[serde(skip_serializing_if = "Option::is_none")]
549        context: Option<String>,
550        /// Cluster name to use (optional, defaults to context's cluster)
551        #[serde(skip_serializing_if = "Option::is_none")]
552        cluster: Option<String>,
553        /// User name to use (optional, defaults to context's user)
554        #[serde(skip_serializing_if = "Option::is_none")]
555        user: Option<String>,
556        /// The namespace to operate in
557        #[serde(skip_serializing_if = "Option::is_none")]
558        namespace: Option<String>,
559        /// Additional headers to include in requests
560        #[serde(skip_serializing_if = "Option::is_none")]
561        additional_headers: Option<HashMap<String, String>>,
562    },
563    /// Manual configuration with explicit values
564    Manual {
565        /// The Kubernetes cluster server URL
566        server_url: String,
567        /// The cluster certificate authority data (base64 encoded)
568        certificate_authority_data: Option<String>,
569        /// Skip TLS verification (insecure)
570        insecure_skip_tls_verify: Option<bool>,
571        /// Client certificate data (base64 encoded) for mutual TLS
572        client_certificate_data: Option<String>,
573        /// Client key data (base64 encoded) for mutual TLS
574        client_key_data: Option<String>,
575        /// Bearer token for authentication
576        token: Option<String>,
577        /// Username for basic authentication
578        username: Option<String>,
579        /// Password for basic authentication
580        password: Option<String>,
581        /// The namespace to operate in
582        namespace: Option<String>,
583        /// Additional headers to include in requests
584        additional_headers: HashMap<String, String>,
585    },
586}
587
588impl std::fmt::Debug for KubernetesClientConfig {
589    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
590        match self {
591            KubernetesClientConfig::InCluster {
592                namespace,
593                additional_headers,
594            } => f
595                .debug_struct("KubernetesClientConfig::InCluster")
596                .field("namespace", namespace)
597                .field("additional_headers", additional_headers)
598                .finish(),
599            KubernetesClientConfig::Kubeconfig {
600                kubeconfig_path,
601                context,
602                cluster,
603                user,
604                namespace,
605                additional_headers,
606            } => f
607                .debug_struct("KubernetesClientConfig::Kubeconfig")
608                .field("kubeconfig_path", kubeconfig_path)
609                .field("context", context)
610                .field("cluster", cluster)
611                .field("user", user)
612                .field("namespace", namespace)
613                .field("additional_headers", additional_headers)
614                .finish(),
615            KubernetesClientConfig::Manual {
616                server_url,
617                certificate_authority_data,
618                insecure_skip_tls_verify,
619                client_certificate_data,
620                client_key_data,
621                token,
622                username,
623                password,
624                namespace,
625                additional_headers,
626            } => f
627                .debug_struct("KubernetesClientConfig::Manual")
628                .field("server_url", server_url)
629                .field("certificate_authority_data", certificate_authority_data)
630                .field("insecure_skip_tls_verify", insecure_skip_tls_verify)
631                .field("client_certificate_data", client_certificate_data)
632                .field(
633                    "client_key_data",
634                    &client_key_data.as_ref().map(|_| "[REDACTED]"),
635                )
636                .field("token", &token.as_ref().map(|_| "[REDACTED]"))
637                .field("username", username)
638                .field("password", &password.as_ref().map(|_| "[REDACTED]"))
639                .field("namespace", namespace)
640                .field("additional_headers", additional_headers)
641                .finish(),
642        }
643    }
644}
645
646/// Cloud-agnostic impersonation configuration
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
648#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
649#[serde(rename_all = "camelCase", tag = "platform")]
650pub enum ImpersonationConfig {
651    Aws(AwsImpersonationConfig),
652    Gcp(GcpImpersonationConfig),
653    Azure(AzureImpersonationConfig),
654    // Kubernetes doesn't support impersonation, so we don't include it here
655}
656
657/// Configuration for different cloud platform clients
658#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
659#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
660#[serde(rename_all = "camelCase", tag = "platform")]
661pub enum ClientConfig {
662    Aws(Box<AwsClientConfig>),
663    Gcp(Box<GcpClientConfig>),
664    Azure(Box<AzureClientConfig>),
665    Kubernetes(Box<KubernetesClientConfig>),
666    KubernetesCloud {
667        kubernetes: Box<KubernetesClientConfig>,
668        #[cfg_attr(feature = "openapi", schema(value_type = Object))]
669        cloud: Box<ClientConfig>,
670    },
671    Local {
672        /// State directory for local resources and deployment state
673        state_directory: String,
674    },
675    /// Machines platform - uses Horizon-backed controllers without cloud credentials.
676    #[serde(skip)]
677    Machines,
678    /// Test platform - uses mock controllers without real cloud APIs
679    #[serde(skip)]
680    Test,
681}
682
683impl ClientConfig {
684    /// Returns the platform enum for this configuration.
685    pub fn platform(&self) -> Platform {
686        match self {
687            ClientConfig::Aws(_) => Platform::Aws,
688            ClientConfig::Gcp(_) => Platform::Gcp,
689            ClientConfig::Azure(_) => Platform::Azure,
690            ClientConfig::Kubernetes(_) => Platform::Kubernetes,
691            ClientConfig::KubernetesCloud { .. } => Platform::Kubernetes,
692            ClientConfig::Local { .. } => Platform::Local,
693            ClientConfig::Machines => Platform::Machines,
694            ClientConfig::Test => Platform::Test,
695        }
696    }
697
698    pub fn config_for_platform(&self, platform: Platform) -> Option<ClientConfig> {
699        match self {
700            ClientConfig::KubernetesCloud { cloud, .. } => {
701                if platform == Platform::Kubernetes {
702                    Some(self.clone())
703                } else if cloud.platform() == platform {
704                    Some((**cloud).clone())
705                } else {
706                    None
707                }
708            }
709            config if config.platform() == platform => Some(config.clone()),
710            _ => None,
711        }
712    }
713
714    /// Returns the AWS configuration if this is an AWS client config.
715    pub fn aws_config(&self) -> Option<&AwsClientConfig> {
716        match self {
717            ClientConfig::Aws(config) => Some(config),
718            ClientConfig::KubernetesCloud { cloud, .. } => cloud.aws_config(),
719            _ => None,
720        }
721    }
722
723    /// Returns the GCP configuration if this is a GCP client config.
724    pub fn gcp_config(&self) -> Option<&GcpClientConfig> {
725        match self {
726            ClientConfig::Gcp(config) => Some(config),
727            ClientConfig::KubernetesCloud { cloud, .. } => cloud.gcp_config(),
728            _ => None,
729        }
730    }
731
732    /// Returns the Azure configuration if this is an Azure client config.
733    pub fn azure_config(&self) -> Option<&AzureClientConfig> {
734        match self {
735            ClientConfig::Azure(config) => Some(config),
736            ClientConfig::KubernetesCloud { cloud, .. } => cloud.azure_config(),
737            _ => None,
738        }
739    }
740
741    /// Returns the Kubernetes configuration if this is a Kubernetes client config.
742    pub fn kubernetes_config(&self) -> Option<&KubernetesClientConfig> {
743        match self {
744            ClientConfig::Kubernetes(config) => Some(config),
745            ClientConfig::KubernetesCloud { kubernetes, .. } => Some(kubernetes),
746            _ => None,
747        }
748    }
749}
750
751#[cfg(test)]
752mod tests {
753    use super::{
754        AwsClientConfig, AwsCredentials, AzureClientConfig, AzureCredentials, ClientConfig,
755        GcpClientConfig, GcpCredentials, KubernetesClientConfig,
756    };
757
758    #[test]
759    fn aws_credentials_wire_format_matches_manager_api_contract() {
760        let cases = [
761            (
762                AwsCredentials::AccessKeys {
763                    access_key_id: "access-key".to_string(),
764                    secret_access_key: "secret-key".to_string(),
765                    session_token: Some("session-token".to_string()),
766                },
767                serde_json::json!({
768                    "type": "accessKeys",
769                    "access_key_id": "access-key",
770                    "secret_access_key": "secret-key",
771                    "session_token": "session-token",
772                }),
773            ),
774            (
775                AwsCredentials::SessionCredentials {
776                    access_key_id: "access-key".to_string(),
777                    secret_access_key: "secret-key".to_string(),
778                    session_token: "session-token".to_string(),
779                    expires_at: "2099-01-01T00:00:00Z".to_string(),
780                },
781                serde_json::json!({
782                    "type": "sessionCredentials",
783                    "access_key_id": "access-key",
784                    "secret_access_key": "secret-key",
785                    "session_token": "session-token",
786                    "expires_at": "2099-01-01T00:00:00Z",
787                }),
788            ),
789        ];
790
791        for (credentials, expected) in cases {
792            let serialized = serde_json::to_value(&credentials).unwrap();
793            assert_eq!(serialized, expected);
794            assert_eq!(
795                serde_json::from_value::<AwsCredentials>(serialized).unwrap(),
796                credentials
797            );
798        }
799    }
800
801    #[test]
802    fn kubernetes_cloud_exposes_nested_aws_config() {
803        let config = ClientConfig::KubernetesCloud {
804            kubernetes: Box::new(KubernetesClientConfig::InCluster {
805                namespace: Some("test".to_string()),
806                additional_headers: None,
807            }),
808            cloud: Box::new(ClientConfig::Aws(Box::new(AwsClientConfig {
809                account_id: "123456789012".to_string(),
810                region: "us-east-2".to_string(),
811                credentials: AwsCredentials::AccessKeys {
812                    access_key_id: "access".to_string(),
813                    secret_access_key: "secret".to_string(),
814                    session_token: None,
815                },
816                service_overrides: None,
817            }))),
818        };
819
820        assert_eq!(config.platform(), crate::Platform::Kubernetes);
821        assert!(config.kubernetes_config().is_some());
822        assert_eq!(config.aws_config().unwrap().region, "us-east-2");
823        assert!(config.gcp_config().is_none());
824        assert!(config.azure_config().is_none());
825    }
826
827    #[test]
828    fn kubernetes_cloud_preserves_cloud_config_for_kubernetes_controllers() {
829        let config = ClientConfig::KubernetesCloud {
830            kubernetes: Box::new(KubernetesClientConfig::InCluster {
831                namespace: Some("test".to_string()),
832                additional_headers: None,
833            }),
834            cloud: Box::new(ClientConfig::Aws(Box::new(AwsClientConfig {
835                account_id: "123456789012".to_string(),
836                region: "us-east-2".to_string(),
837                credentials: AwsCredentials::AccessKeys {
838                    access_key_id: "access".to_string(),
839                    secret_access_key: "secret".to_string(),
840                    session_token: None,
841                },
842                service_overrides: None,
843            }))),
844        };
845
846        let kubernetes_config = config
847            .config_for_platform(crate::Platform::Kubernetes)
848            .unwrap();
849
850        assert!(matches!(
851            kubernetes_config,
852            ClientConfig::KubernetesCloud { .. }
853        ));
854        assert!(kubernetes_config.kubernetes_config().is_some());
855        assert_eq!(kubernetes_config.aws_config().unwrap().region, "us-east-2");
856    }
857
858    #[test]
859    fn kubernetes_cloud_exposes_nested_gcp_config() {
860        let config = ClientConfig::KubernetesCloud {
861            kubernetes: Box::new(KubernetesClientConfig::InCluster {
862                namespace: Some("test".to_string()),
863                additional_headers: None,
864            }),
865            cloud: Box::new(ClientConfig::Gcp(Box::new(GcpClientConfig {
866                project_id: "project".to_string(),
867                region: "us-central1".to_string(),
868                credentials: GcpCredentials::AccessToken {
869                    token: "token".to_string(),
870                },
871                service_overrides: None,
872                project_number: None,
873            }))),
874        };
875
876        assert_eq!(config.gcp_config().unwrap().project_id, "project");
877        assert!(config.aws_config().is_none());
878        assert!(config.azure_config().is_none());
879    }
880
881    #[test]
882    fn kubernetes_cloud_exposes_nested_azure_config() {
883        let config = ClientConfig::KubernetesCloud {
884            kubernetes: Box::new(KubernetesClientConfig::InCluster {
885                namespace: Some("test".to_string()),
886                additional_headers: None,
887            }),
888            cloud: Box::new(ClientConfig::Azure(Box::new(AzureClientConfig {
889                subscription_id: "sub".to_string(),
890                tenant_id: "tenant".to_string(),
891                region: Some("eastus".to_string()),
892                credentials: AzureCredentials::AccessToken {
893                    token: "token".to_string(),
894                },
895                service_overrides: None,
896            }))),
897        };
898
899        assert_eq!(config.azure_config().unwrap().subscription_id, "sub");
900        assert!(config.aws_config().is_none());
901        assert!(config.gcp_config().is_none());
902    }
903
904    #[test]
905    fn scoped_azure_tokens_debug_redacts_every_token() {
906        let credentials = AzureCredentials::ScopedAccessTokens {
907            tokens: std::collections::HashMap::from([
908                (
909                    "https://management.azure.com/.default".to_string(),
910                    "management-secret".to_string(),
911                ),
912                (
913                    "https://storage.azure.com/.default".to_string(),
914                    "storage-secret".to_string(),
915                ),
916            ]),
917        };
918
919        let debug = format!("{credentials:?}");
920        assert!(debug.contains("https://management.azure.com/.default"));
921        assert!(debug.contains("https://storage.azure.com/.default"));
922        assert!(!debug.contains("management-secret"));
923        assert!(!debug.contains("storage-secret"));
924        assert!(debug.contains("[REDACTED]"));
925    }
926}