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