Skip to main content

lance_namespace_impls/
credentials.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Credential vending for cloud storage access.
5//!
6//! This module provides credential vending functionality that generates
7//! temporary, scoped credentials for accessing cloud storage. Similar to
8//! Apache Polaris's credential vending, it supports:
9//!
10//! - **AWS**: STS AssumeRole with scoped IAM policies (requires `credential-vendor-aws` feature)
11//! - **GCP**: OAuth2 tokens with access boundaries (requires `credential-vendor-gcp` feature)
12//! - **Azure**: SAS tokens with user delegation keys (requires `credential-vendor-azure` feature)
13//!
14//! The appropriate vendor is automatically selected based on the table location URI scheme:
15//! - `s3://` for AWS
16//! - `gs://` for GCP
17//! - `az://` for Azure
18//!
19//! ## Configuration via Properties
20//!
21//! Credential vendors are configured via properties with the `credential_vendor.` prefix.
22//!
23//! ### Properties format:
24//!
25//! ```text
26//! # Required to enable credential vending
27//! credential_vendor.enabled = "true"
28//!
29//! # Common properties (apply to all providers)
30//! credential_vendor.permission = "read"          # read, write, or admin (default: read)
31//!
32//! # AWS-specific properties (for s3:// locations)
33//! credential_vendor.aws_role_arn = "arn:aws:iam::123456789012:role/MyRole"  # required for AWS
34//! credential_vendor.aws_external_id = "my-external-id"
35//! credential_vendor.aws_region = "us-west-2"
36//! credential_vendor.aws_role_session_name = "my-session"
37//! credential_vendor.aws_duration_millis = "3600000"  # 1 hour (default, range: 15min-12hrs)
38//!
39//! # GCP-specific properties (for gs:// locations)
40//! # Note: GCP token duration cannot be configured; it's determined by the STS endpoint
41//! # To use a service account key file, set GOOGLE_APPLICATION_CREDENTIALS env var before starting
42//! credential_vendor.gcp_service_account = "my-sa@project.iam.gserviceaccount.com"
43//!
44//! # Azure-specific properties (for az:// locations)
45//! credential_vendor.azure_account_name = "mystorageaccount"  # required for Azure
46//! credential_vendor.azure_tenant_id = "my-tenant-id"
47//! credential_vendor.azure_duration_millis = "3600000"  # 1 hour (default, up to 7 days)
48//! ```
49//!
50//! ### Example using ConnectBuilder:
51//!
52//! ```ignore
53//! ConnectBuilder::new("dir")
54//!     .property("root", "s3://bucket/path")
55//!     .property("credential_vendor.enabled", "true")
56//!     .property("credential_vendor.aws_role_arn", "arn:aws:iam::123456789012:role/MyRole")
57//!     .property("credential_vendor.permission", "read")
58//!     .connect()
59//!     .await?;
60//! ```
61
62#[cfg(feature = "credential-vendor-aws")]
63pub mod aws;
64
65#[cfg(feature = "credential-vendor-azure")]
66pub mod azure;
67
68#[cfg(feature = "credential-vendor-gcp")]
69pub mod gcp;
70
71/// Credential caching module.
72/// Available when any credential vendor feature is enabled.
73#[cfg(any(
74    feature = "credential-vendor-aws",
75    feature = "credential-vendor-azure",
76    feature = "credential-vendor-gcp"
77))]
78pub mod cache;
79
80use std::collections::HashMap;
81use std::str::FromStr;
82
83use async_trait::async_trait;
84use lance_core::Result;
85use lance_io::object_store::uri_to_url;
86use lance_namespace::models::Identity;
87
88/// Default credential duration: 1 hour (3600000 milliseconds)
89pub const DEFAULT_CREDENTIAL_DURATION_MILLIS: u64 = 3600 * 1000;
90
91/// Redact a credential string for logging, showing first and last few characters.
92///
93/// This is useful for debugging while avoiding exposure of sensitive data.
94/// Format: `AKIAIOSF***MPLE` (first 8 + "***" + last 4)
95///
96/// Shows 8 characters at the start (useful since AWS keys always start with AKIA/ASIA)
97/// and 4 characters at the end. For short strings, shows only the first few with "***".
98///
99/// # Security Note
100///
101/// This function should only be used for identifiers and tokens, never for secrets
102/// like `aws_secret_access_key` which should never be logged even in redacted form.
103pub fn redact_credential(credential: &str) -> String {
104    const SHOW_START: usize = 8;
105    const SHOW_END: usize = 4;
106    const MIN_LENGTH_FOR_BOTH_ENDS: usize = SHOW_START + SHOW_END + 4; // Need at least 16 chars
107
108    if credential.is_empty() {
109        return "[empty]".to_string();
110    }
111
112    if credential.len() < MIN_LENGTH_FOR_BOTH_ENDS {
113        // For short credentials, just show beginning
114        let show = credential.len().min(SHOW_START);
115        format!("{}***", &credential[..show])
116    } else {
117        // Show first 8 and last 4 characters
118        format!(
119            "{}***{}",
120            &credential[..SHOW_START],
121            &credential[credential.len() - SHOW_END..]
122        )
123    }
124}
125
126/// Permission level for vended credentials.
127///
128/// This determines what access the vended credentials will have:
129/// - `Read`: Read-only access to all table content
130/// - `Write`: Full read and write access (no delete)
131/// - `Admin`: Full read, write, and delete access
132///
133/// Permission enforcement by cloud provider:
134/// - **AWS**: Permissions are enforced via scoped IAM policies attached to the AssumeRole request
135/// - **Azure**: Permissions are enforced via SAS token permissions
136/// - **GCP**: Permissions are enforced via Credential Access Boundaries (CAB) that downscope
137///   the OAuth2 token to specific GCS IAM roles
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
139pub enum VendedPermission {
140    /// Read-only access to all table content (metadata, indices, data files)
141    #[default]
142    Read,
143    /// Full read and write access (no delete)
144    /// This is intended ONLY for testing purposes to generate a write-only permission set.
145    /// Technically, any user with write permission could "delete" the file by
146    /// overwriting the file with empty content.
147    /// So this cannot really prevent malicious use cases.
148    Write,
149    /// Full read, write, and delete access
150    Admin,
151}
152
153impl VendedPermission {
154    /// Returns true if this permission allows writing
155    pub fn can_write(&self) -> bool {
156        matches!(self, Self::Write | Self::Admin)
157    }
158
159    /// Returns true if this permission allows deleting
160    pub fn can_delete(&self) -> bool {
161        matches!(self, Self::Admin)
162    }
163}
164
165impl FromStr for VendedPermission {
166    type Err = String;
167
168    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
169        match s.to_lowercase().as_str() {
170            "read" => Ok(Self::Read),
171            "write" => Ok(Self::Write),
172            "admin" => Ok(Self::Admin),
173            _ => Err(format!(
174                "Invalid permission '{}'. Must be one of: read, write, admin",
175                s
176            )),
177        }
178    }
179}
180
181impl std::fmt::Display for VendedPermission {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        match self {
184            Self::Read => write!(f, "read"),
185            Self::Write => write!(f, "write"),
186            Self::Admin => write!(f, "admin"),
187        }
188    }
189}
190
191/// Property key prefix for credential vendor properties.
192/// Properties with this prefix are stripped when using `from_properties`.
193pub const PROPERTY_PREFIX: &str = "credential_vendor.";
194
195/// Common property key to explicitly enable credential vending (short form).
196pub const ENABLED: &str = "enabled";
197
198/// Common property key for permission level (short form).
199pub const PERMISSION: &str = "permission";
200
201/// Common property key to enable credential caching (short form).
202/// Default: true. Set to "false" to disable caching.
203pub const CACHE_ENABLED: &str = "cache_enabled";
204
205/// Common property key for API key salt (short form).
206/// Used to hash API keys before comparison: SHA256(api_key + ":" + salt)
207pub const API_KEY_SALT: &str = "api_key_salt";
208
209/// Property key prefix for API key hash to permission mappings (short form).
210/// Format: `api_key_hash.<sha256_hash> = "<permission>"`
211pub const API_KEY_HASH_PREFIX: &str = "api_key_hash.";
212
213/// AWS-specific property keys (short form, without prefix)
214#[cfg(feature = "credential-vendor-aws")]
215pub mod aws_props {
216    pub const ROLE_ARN: &str = "aws_role_arn";
217    pub const EXTERNAL_ID: &str = "aws_external_id";
218    pub const REGION: &str = "aws_region";
219    pub const ROLE_SESSION_NAME: &str = "aws_role_session_name";
220    /// AWS credential duration in milliseconds.
221    /// Default: 3600000 (1 hour). Range: 900000 (15 min) to 43200000 (12 hours).
222    pub const DURATION_MILLIS: &str = "aws_duration_millis";
223}
224
225/// GCP-specific property keys (short form, without prefix)
226#[cfg(feature = "credential-vendor-gcp")]
227pub mod gcp_props {
228    pub const SERVICE_ACCOUNT: &str = "gcp_service_account";
229
230    /// Workload Identity Provider resource name for OIDC token exchange.
231    /// Format: //iam.googleapis.com/projects/{project}/locations/global/workloadIdentityPools/{pool}/providers/{provider}
232    pub const WORKLOAD_IDENTITY_PROVIDER: &str = "gcp_workload_identity_provider";
233
234    /// Service account to impersonate after Workload Identity Federation (optional).
235    /// If not set, uses the federated identity directly.
236    pub const IMPERSONATION_SERVICE_ACCOUNT: &str = "gcp_impersonation_service_account";
237}
238
239/// Azure-specific property keys (short form, without prefix)
240#[cfg(feature = "credential-vendor-azure")]
241pub mod azure_props {
242    pub const TENANT_ID: &str = "azure_tenant_id";
243    /// Azure storage account name. Required for credential vending.
244    pub const ACCOUNT_NAME: &str = "azure_account_name";
245    /// Azure credential duration in milliseconds.
246    /// Default: 3600000 (1 hour). Azure SAS tokens can be valid up to 7 days.
247    pub const DURATION_MILLIS: &str = "azure_duration_millis";
248
249    /// Client ID of the Azure AD App Registration for Workload Identity Federation.
250    /// Required when using auth_token identity for OIDC token exchange.
251    pub const FEDERATED_CLIENT_ID: &str = "azure_federated_client_id";
252}
253
254/// Vended credentials with expiration information.
255#[derive(Clone)]
256pub struct VendedCredentials {
257    /// Storage options map containing credential keys.
258    /// - For AWS: `aws_access_key_id`, `aws_secret_access_key`, `aws_session_token`
259    /// - For GCP: `google_storage_token`
260    /// - For Azure: `azure_storage_sas_token`, `azure_storage_account_name`
261    pub storage_options: HashMap<String, String>,
262
263    /// Expiration time in milliseconds since Unix epoch.
264    pub expires_at_millis: u64,
265}
266
267impl std::fmt::Debug for VendedCredentials {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        f.debug_struct("VendedCredentials")
270            .field(
271                "storage_options",
272                &format!("[{} keys redacted]", self.storage_options.len()),
273            )
274            .field("expires_at_millis", &self.expires_at_millis)
275            .finish()
276    }
277}
278
279impl VendedCredentials {
280    /// Create new vended credentials.
281    pub fn new(storage_options: HashMap<String, String>, expires_at_millis: u64) -> Self {
282        Self {
283            storage_options,
284            expires_at_millis,
285        }
286    }
287
288    /// Check if the credentials have expired.
289    pub fn is_expired(&self) -> bool {
290        let now_millis = std::time::SystemTime::now()
291            .duration_since(std::time::UNIX_EPOCH)
292            .expect("time went backwards")
293            .as_millis() as u64;
294        now_millis >= self.expires_at_millis
295    }
296}
297
298/// Trait for credential vendors that generate temporary credentials.
299///
300/// Each cloud provider has its own configuration passed via the vendor
301/// implementation. The permission level is configured at vendor creation time
302/// via [`VendedPermission`].
303#[async_trait]
304pub trait CredentialVendor: Send + Sync + std::fmt::Debug {
305    /// Vend credentials for accessing the specified table location.
306    ///
307    /// The permission level (read/write/admin) is determined by the vendor's
308    /// configuration, not per-request. When identity is provided, the vendor
309    /// may use different authentication flows:
310    ///
311    /// - `auth_token`: Use AssumeRoleWithWebIdentity (AWS validates the token)
312    /// - `api_key`: Validate against configured API key hashes and use AssumeRole
313    /// - `None`: Use static configuration with AssumeRole
314    ///
315    /// # Arguments
316    ///
317    /// * `table_location` - The table URI to vend credentials for
318    /// * `identity` - Optional identity from the request (api_key OR auth_token, mutually exclusive)
319    ///
320    /// # Returns
321    ///
322    /// Returns vended credentials with expiration information.
323    ///
324    /// # Errors
325    ///
326    /// Returns error if identity validation fails (no fallback to static config).
327    async fn vend_credentials(
328        &self,
329        table_location: &str,
330        identity: Option<&Identity>,
331    ) -> Result<VendedCredentials>;
332
333    /// Returns the cloud provider name (e.g., "aws", "gcp", "azure").
334    fn provider_name(&self) -> &'static str;
335
336    /// Returns the permission level configured for this vendor.
337    fn permission(&self) -> VendedPermission;
338}
339
340/// Detect the cloud provider from a URI scheme.
341///
342/// Supported schemes for credential vending:
343/// - AWS S3: `s3://`
344/// - GCP GCS: `gs://`
345/// - Azure Blob: `az://`
346///
347/// Returns "aws", "gcp", "azure", or "unknown".
348pub fn detect_provider_from_uri(uri: &str) -> &'static str {
349    let Ok(url) = uri_to_url(uri) else {
350        return "unknown";
351    };
352
353    match url.scheme() {
354        "s3" => "aws",
355        "gs" => "gcp",
356        "az" | "abfss" => "azure",
357        _ => "unknown",
358    }
359}
360
361/// Check if credential vending is enabled.
362///
363/// Returns true only if the `enabled` property is set to "true".
364/// This expects properties with short names (prefix already stripped).
365pub fn has_credential_vendor_config(properties: &HashMap<String, String>) -> bool {
366    properties
367        .get(ENABLED)
368        .map(|v| v.eq_ignore_ascii_case("true"))
369        .unwrap_or(false)
370}
371
372/// Create a credential vendor for the specified table location based on its URI scheme.
373///
374/// This function automatically detects the cloud provider from the table location
375/// and creates the appropriate credential vendor using the provided properties.
376///
377/// # Arguments
378///
379/// * `table_location` - The table URI to create a vendor for (e.g., "s3://bucket/path")
380/// * `properties` - Configuration properties for credential vendors
381///
382/// # Returns
383///
384/// Returns `Some(vendor)` if the provider is detected and configured, `None` if:
385/// - The provider cannot be detected from the URI (e.g., local file path)
386/// - The required feature is not enabled for the detected provider
387///
388/// # Errors
389///
390/// Returns an error if the provider is detected but required configuration is missing:
391/// - AWS: `credential_vendor.aws_role_arn` is required
392/// - Azure: `credential_vendor.azure_account_name` is required
393#[allow(unused_variables)]
394pub async fn create_credential_vendor_for_location(
395    table_location: &str,
396    properties: &HashMap<String, String>,
397) -> Result<Option<Box<dyn CredentialVendor>>> {
398    let provider = detect_provider_from_uri(table_location);
399
400    let vendor: Option<Box<dyn CredentialVendor>> = match provider {
401        #[cfg(feature = "credential-vendor-aws")]
402        "aws" => create_aws_vendor(properties).await?,
403
404        #[cfg(feature = "credential-vendor-gcp")]
405        "gcp" => create_gcp_vendor(properties).await?,
406
407        #[cfg(feature = "credential-vendor-azure")]
408        "azure" => create_azure_vendor(properties)?,
409
410        _ => None,
411    };
412
413    // Wrap with caching if enabled (default: true)
414    #[cfg(any(
415        feature = "credential-vendor-aws",
416        feature = "credential-vendor-azure",
417        feature = "credential-vendor-gcp"
418    ))]
419    if let Some(v) = vendor {
420        let cache_enabled = properties
421            .get(CACHE_ENABLED)
422            .map(|s| !s.eq_ignore_ascii_case("false"))
423            .unwrap_or(true);
424
425        if cache_enabled {
426            return Ok(Some(Box::new(cache::CachingCredentialVendor::new(v))));
427        } else {
428            return Ok(Some(v));
429        }
430    }
431
432    #[cfg(not(any(
433        feature = "credential-vendor-aws",
434        feature = "credential-vendor-azure",
435        feature = "credential-vendor-gcp"
436    )))]
437    let _ = vendor;
438
439    Ok(None)
440}
441
442/// Parse permission from properties, defaulting to Read
443#[allow(dead_code)]
444fn parse_permission(properties: &HashMap<String, String>) -> VendedPermission {
445    properties
446        .get(PERMISSION)
447        .and_then(|s| s.parse().ok())
448        .unwrap_or_default()
449}
450
451/// Parse duration from properties using a vendor-specific key, defaulting to DEFAULT_CREDENTIAL_DURATION_MILLIS
452#[allow(dead_code)]
453fn parse_duration_millis(properties: &HashMap<String, String>, key: &str) -> u64 {
454    properties
455        .get(key)
456        .and_then(|s| s.parse::<u64>().ok())
457        .unwrap_or(DEFAULT_CREDENTIAL_DURATION_MILLIS)
458}
459
460#[cfg(feature = "credential-vendor-aws")]
461async fn create_aws_vendor(
462    properties: &HashMap<String, String>,
463) -> Result<Option<Box<dyn CredentialVendor>>> {
464    use aws::{AwsCredentialVendor, AwsCredentialVendorConfig};
465    use lance_core::Error;
466
467    // AWS requires role_arn to be configured
468    let role_arn = properties.get(aws_props::ROLE_ARN).ok_or_else(|| {
469        Error::invalid_input_source(
470            "AWS credential vending requires 'credential_vendor.aws_role_arn' to be set".into(),
471        )
472    })?;
473
474    let duration_millis = parse_duration_millis(properties, aws_props::DURATION_MILLIS);
475
476    let permission = parse_permission(properties);
477
478    let mut config = AwsCredentialVendorConfig::new(role_arn)
479        .with_duration_millis(duration_millis)
480        .with_permission(permission);
481
482    if let Some(external_id) = properties.get(aws_props::EXTERNAL_ID) {
483        config = config.with_external_id(external_id);
484    }
485    if let Some(region) = properties.get(aws_props::REGION) {
486        config = config.with_region(region);
487    }
488    if let Some(session_name) = properties.get(aws_props::ROLE_SESSION_NAME) {
489        config = config.with_role_session_name(session_name);
490    }
491
492    let vendor = AwsCredentialVendor::new(config).await?;
493    Ok(Some(Box::new(vendor)))
494}
495
496#[cfg(feature = "credential-vendor-gcp")]
497async fn create_gcp_vendor(
498    properties: &HashMap<String, String>,
499) -> Result<Option<Box<dyn CredentialVendor>>> {
500    use gcp::{GcpCredentialVendor, GcpCredentialVendorConfig};
501
502    let permission = parse_permission(properties);
503
504    let mut config = GcpCredentialVendorConfig::new().with_permission(permission);
505
506    if let Some(sa) = properties.get(gcp_props::SERVICE_ACCOUNT) {
507        config = config.with_service_account(sa);
508    }
509
510    let vendor = GcpCredentialVendor::new(config).await?;
511    Ok(Some(Box::new(vendor)))
512}
513
514#[cfg(feature = "credential-vendor-azure")]
515fn create_azure_vendor(
516    properties: &HashMap<String, String>,
517) -> Result<Option<Box<dyn CredentialVendor>>> {
518    use azure::{AzureCredentialVendor, AzureCredentialVendorConfig};
519    use lance_core::Error;
520
521    // Azure requires account_name to be configured
522    let account_name = properties.get(azure_props::ACCOUNT_NAME).ok_or_else(|| {
523        Error::invalid_input_source(
524            "Azure credential vending requires 'credential_vendor.azure_account_name' to be set"
525                .into(),
526        )
527    })?;
528
529    let duration_millis = parse_duration_millis(properties, azure_props::DURATION_MILLIS);
530    let permission = parse_permission(properties);
531
532    let mut config = AzureCredentialVendorConfig::new()
533        .with_account_name(account_name)
534        .with_duration_millis(duration_millis)
535        .with_permission(permission);
536
537    if let Some(tenant_id) = properties.get(azure_props::TENANT_ID) {
538        config = config.with_tenant_id(tenant_id);
539    }
540
541    let vendor = AzureCredentialVendor::new(config);
542    Ok(Some(Box::new(vendor)))
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn test_detect_provider_from_uri() {
551        // AWS (supported scheme: s3://)
552        assert_eq!(detect_provider_from_uri("s3://bucket/path"), "aws");
553        assert_eq!(detect_provider_from_uri("S3://bucket/path"), "aws");
554
555        // GCP (supported scheme: gs://)
556        assert_eq!(detect_provider_from_uri("gs://bucket/path"), "gcp");
557        assert_eq!(detect_provider_from_uri("GS://bucket/path"), "gcp");
558
559        // Azure (supported schemes: az:// and abfss://)
560        assert_eq!(detect_provider_from_uri("az://container/path"), "azure");
561        assert_eq!(
562            detect_provider_from_uri("az://container@account.blob.core.windows.net/path"),
563            "azure"
564        );
565        assert_eq!(
566            detect_provider_from_uri("abfss://container@account.dfs.core.windows.net/path"),
567            "azure"
568        );
569
570        // Unknown (unsupported schemes)
571        assert_eq!(detect_provider_from_uri("/local/path"), "unknown");
572        assert_eq!(detect_provider_from_uri("file:///local/path"), "unknown");
573        assert_eq!(detect_provider_from_uri("memory://test"), "unknown");
574        // Hadoop-style schemes not supported by lance-io
575        assert_eq!(detect_provider_from_uri("s3a://bucket/path"), "unknown");
576        assert_eq!(
577            detect_provider_from_uri("wasbs://container@account.blob.core.windows.net/path"),
578            "unknown"
579        );
580    }
581
582    #[test]
583    fn test_vended_permission_from_str() {
584        // Valid values (case-insensitive)
585        assert_eq!(
586            "read".parse::<VendedPermission>().unwrap(),
587            VendedPermission::Read
588        );
589        assert_eq!(
590            "READ".parse::<VendedPermission>().unwrap(),
591            VendedPermission::Read
592        );
593        assert_eq!(
594            "write".parse::<VendedPermission>().unwrap(),
595            VendedPermission::Write
596        );
597        assert_eq!(
598            "WRITE".parse::<VendedPermission>().unwrap(),
599            VendedPermission::Write
600        );
601        assert_eq!(
602            "admin".parse::<VendedPermission>().unwrap(),
603            VendedPermission::Admin
604        );
605        assert_eq!(
606            "Admin".parse::<VendedPermission>().unwrap(),
607            VendedPermission::Admin
608        );
609
610        // Invalid values should return error
611        let err = "invalid".parse::<VendedPermission>().unwrap_err();
612        assert!(err.contains("Invalid permission"));
613        assert!(err.contains("invalid"));
614
615        let err = "".parse::<VendedPermission>().unwrap_err();
616        assert!(err.contains("Invalid permission"));
617
618        let err = "readwrite".parse::<VendedPermission>().unwrap_err();
619        assert!(err.contains("Invalid permission"));
620    }
621
622    #[test]
623    fn test_vended_permission_display() {
624        assert_eq!(VendedPermission::Read.to_string(), "read");
625        assert_eq!(VendedPermission::Write.to_string(), "write");
626        assert_eq!(VendedPermission::Admin.to_string(), "admin");
627    }
628
629    #[test]
630    fn test_parse_permission_with_invalid_values() {
631        // Invalid permission should default to Read
632        let mut props = HashMap::new();
633        props.insert(PERMISSION.to_string(), "invalid".to_string());
634        assert_eq!(parse_permission(&props), VendedPermission::Read);
635
636        // Empty permission should default to Read
637        props.insert(PERMISSION.to_string(), "".to_string());
638        assert_eq!(parse_permission(&props), VendedPermission::Read);
639
640        // Missing permission should default to Read
641        let empty_props: HashMap<String, String> = HashMap::new();
642        assert_eq!(parse_permission(&empty_props), VendedPermission::Read);
643    }
644
645    #[test]
646    fn test_parse_duration_millis_with_invalid_values() {
647        const TEST_KEY: &str = "test_duration_millis";
648
649        // Invalid duration should default to DEFAULT_CREDENTIAL_DURATION_MILLIS
650        let mut props = HashMap::new();
651        props.insert(TEST_KEY.to_string(), "not_a_number".to_string());
652        assert_eq!(
653            parse_duration_millis(&props, TEST_KEY),
654            DEFAULT_CREDENTIAL_DURATION_MILLIS
655        );
656
657        // Negative number (parsed as u64 fails)
658        props.insert(TEST_KEY.to_string(), "-1000".to_string());
659        assert_eq!(
660            parse_duration_millis(&props, TEST_KEY),
661            DEFAULT_CREDENTIAL_DURATION_MILLIS
662        );
663
664        // Empty string should default
665        props.insert(TEST_KEY.to_string(), "".to_string());
666        assert_eq!(
667            parse_duration_millis(&props, TEST_KEY),
668            DEFAULT_CREDENTIAL_DURATION_MILLIS
669        );
670
671        // Missing duration should default
672        let empty_props: HashMap<String, String> = HashMap::new();
673        assert_eq!(
674            parse_duration_millis(&empty_props, TEST_KEY),
675            DEFAULT_CREDENTIAL_DURATION_MILLIS
676        );
677
678        // Valid duration should work
679        props.insert(TEST_KEY.to_string(), "7200000".to_string());
680        assert_eq!(parse_duration_millis(&props, TEST_KEY), 7200000);
681    }
682
683    #[test]
684    fn test_has_credential_vendor_config() {
685        // enabled = true
686        let mut props = HashMap::new();
687        props.insert(ENABLED.to_string(), "true".to_string());
688        assert!(has_credential_vendor_config(&props));
689
690        // enabled = TRUE (case-insensitive)
691        props.insert(ENABLED.to_string(), "TRUE".to_string());
692        assert!(has_credential_vendor_config(&props));
693
694        // enabled = false
695        props.insert(ENABLED.to_string(), "false".to_string());
696        assert!(!has_credential_vendor_config(&props));
697
698        // enabled = invalid value
699        props.insert(ENABLED.to_string(), "yes".to_string());
700        assert!(!has_credential_vendor_config(&props));
701
702        // enabled missing
703        let empty_props: HashMap<String, String> = HashMap::new();
704        assert!(!has_credential_vendor_config(&empty_props));
705    }
706
707    #[test]
708    fn test_vended_credentials_debug_redacts_secrets() {
709        let mut storage_options = HashMap::new();
710        storage_options.insert(
711            "aws_access_key_id".to_string(),
712            "AKIAIOSFODNN7EXAMPLE".to_string(),
713        );
714        storage_options.insert(
715            "aws_secret_access_key".to_string(),
716            "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
717        );
718        storage_options.insert(
719            "aws_session_token".to_string(),
720            "FwoGZXIvYXdzE...".to_string(),
721        );
722
723        let creds = VendedCredentials::new(storage_options, 1234567890);
724        let debug_output = format!("{:?}", creds);
725
726        // Should NOT contain actual secrets
727        assert!(!debug_output.contains("AKIAIOSFODNN7EXAMPLE"));
728        assert!(!debug_output.contains("wJalrXUtnFEMI"));
729        assert!(!debug_output.contains("FwoGZXIvYXdzE"));
730
731        // Should contain redacted message
732        assert!(debug_output.contains("redacted"));
733        assert!(debug_output.contains("3 keys"));
734
735        // Should contain expiration time
736        assert!(debug_output.contains("1234567890"));
737    }
738
739    #[test]
740    fn test_vended_credentials_is_expired() {
741        // Create credentials that expired in the past
742        let past_millis = std::time::SystemTime::now()
743            .duration_since(std::time::UNIX_EPOCH)
744            .unwrap()
745            .as_millis() as u64
746            - 1000; // 1 second ago
747
748        let expired_creds = VendedCredentials::new(HashMap::new(), past_millis);
749        assert!(expired_creds.is_expired());
750
751        // Create credentials that expire in the future
752        let future_millis = std::time::SystemTime::now()
753            .duration_since(std::time::UNIX_EPOCH)
754            .unwrap()
755            .as_millis() as u64
756            + 3600000; // 1 hour from now
757
758        let valid_creds = VendedCredentials::new(HashMap::new(), future_millis);
759        assert!(!valid_creds.is_expired());
760    }
761
762    #[test]
763    fn test_redact_credential() {
764        // Long credential: shows first 8 and last 4
765        assert_eq!(redact_credential("AKIAIOSFODNN7EXAMPLE"), "AKIAIOSF***MPLE");
766
767        // Exactly 16 chars: shows first 8 and last 4
768        assert_eq!(redact_credential("1234567890123456"), "12345678***3456");
769
770        // Short credential (< 16 chars): shows only first few
771        assert_eq!(redact_credential("short1234567"), "short123***");
772        assert_eq!(redact_credential("short123"), "short123***");
773        assert_eq!(redact_credential("tiny"), "tiny***");
774        assert_eq!(redact_credential("ab"), "ab***");
775        assert_eq!(redact_credential("a"), "a***");
776
777        // Empty string
778        assert_eq!(redact_credential(""), "[empty]");
779
780        // Real-world examples
781        // AWS access key ID (20 chars) - shows AKIA + 4 more chars which helps identify the key
782        assert_eq!(redact_credential("AKIAIOSFODNN7EXAMPLE"), "AKIAIOSF***MPLE");
783
784        // GCP token (typically very long)
785        let long_token = "ya29.a0AfH6SMBx1234567890abcdefghijklmnopqrstuvwxyz";
786        assert_eq!(redact_credential(long_token), "ya29.a0A***wxyz");
787
788        // Azure SAS token
789        let sas_token = "sv=2021-06-08&ss=b&srt=sco&sp=rwdlacuiytfx&se=2024-12-31";
790        assert_eq!(redact_credential(sas_token), "sv=2021-***2-31");
791    }
792}