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