1#[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#[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
91pub const DEFAULT_CREDENTIAL_DURATION_MILLIS: u64 = 3600 * 1000;
93
94pub 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; if credential.is_empty() {
112 return "[empty]".to_string();
113 }
114
115 if credential.len() < MIN_LENGTH_FOR_BOTH_ENDS {
116 let show = credential.len().min(SHOW_START);
118 format!("{}***", &credential[..show])
119 } else {
120 format!(
122 "{}***{}",
123 &credential[..SHOW_START],
124 &credential[credential.len() - SHOW_END..]
125 )
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub enum VendedPermission {
143 #[default]
145 Read,
146 Write,
152 Admin,
154}
155
156impl VendedPermission {
157 pub fn can_write(&self) -> bool {
159 matches!(self, Self::Write | Self::Admin)
160 }
161
162 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
194pub const PROPERTY_PREFIX: &str = "credential_vendor.";
197
198pub const ENABLED: &str = "enabled";
200
201pub const PERMISSION: &str = "permission";
203
204pub const CACHE_ENABLED: &str = "cache_enabled";
207
208pub const API_KEY_SALT: &str = "api_key_salt";
211
212pub const API_KEY_HASH_PREFIX: &str = "api_key_hash.";
215
216#[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 pub const DURATION_MILLIS: &str = "aws_duration_millis";
226
227 pub const ASSUME_VIA_POD_WEB_IDENTITY: &str = "aws_assume_via_pod_web_identity";
230
231 pub const POD_WEB_IDENTITY_TOKEN_FILE: &str = "aws_pod_web_identity_token_file";
234}
235
236#[cfg(feature = "credential-vendor-gcp")]
238pub mod gcp_props {
239 pub const SERVICE_ACCOUNT: &str = "gcp_service_account";
240
241 pub const WORKLOAD_IDENTITY_PROVIDER: &str = "gcp_workload_identity_provider";
244
245 pub const IMPERSONATION_SERVICE_ACCOUNT: &str = "gcp_impersonation_service_account";
248}
249
250#[cfg(feature = "credential-vendor-azure")]
252pub mod azure_props {
253 pub const TENANT_ID: &str = "azure_tenant_id";
254 pub const ACCOUNT_NAME: &str = "azure_account_name";
256 pub const DURATION_MILLIS: &str = "azure_duration_millis";
259
260 pub const FEDERATED_CLIENT_ID: &str = "azure_federated_client_id";
263}
264
265#[derive(Clone)]
267pub struct VendedCredentials {
268 pub storage_options: HashMap<String, String>,
273
274 pub expires_at_millis: u64,
276}
277
278impl std::fmt::Debug for VendedCredentials {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 f.debug_struct("VendedCredentials")
281 .field(
282 "storage_options",
283 &format!("[{} keys redacted]", self.storage_options.len()),
284 )
285 .field("expires_at_millis", &self.expires_at_millis)
286 .finish()
287 }
288}
289
290impl VendedCredentials {
291 pub fn new(storage_options: HashMap<String, String>, expires_at_millis: u64) -> Self {
293 Self {
294 storage_options,
295 expires_at_millis,
296 }
297 }
298
299 pub fn is_expired(&self) -> bool {
301 let now_millis = std::time::SystemTime::now()
302 .duration_since(std::time::UNIX_EPOCH)
303 .expect("time went backwards")
304 .as_millis() as u64;
305 now_millis >= self.expires_at_millis
306 }
307}
308
309#[async_trait]
315pub trait CredentialVendor: Send + Sync + std::fmt::Debug {
316 async fn vend_credentials(
339 &self,
340 table_location: &str,
341 identity: Option<&Identity>,
342 ) -> Result<VendedCredentials>;
343
344 fn provider_name(&self) -> &'static str;
346
347 fn permission(&self) -> VendedPermission;
349}
350
351pub fn detect_provider_from_uri(uri: &str) -> &'static str {
360 let Ok(url) = uri_to_url(uri) else {
361 return "unknown";
362 };
363
364 match url.scheme() {
365 "s3" => "aws",
366 "gs" => "gcp",
367 "az" | "abfss" => "azure",
368 _ => "unknown",
369 }
370}
371
372pub fn has_credential_vendor_config(properties: &HashMap<String, String>) -> bool {
377 properties
378 .get(ENABLED)
379 .map(|v| v.eq_ignore_ascii_case("true"))
380 .unwrap_or(false)
381}
382
383#[allow(unused_variables)]
405pub async fn create_credential_vendor_for_location(
406 table_location: &str,
407 properties: &HashMap<String, String>,
408) -> Result<Option<Box<dyn CredentialVendor>>> {
409 let provider = detect_provider_from_uri(table_location);
410
411 let vendor: Option<Box<dyn CredentialVendor>> = match provider {
412 #[cfg(feature = "credential-vendor-aws")]
413 "aws" => create_aws_vendor(properties).await?,
414
415 #[cfg(feature = "credential-vendor-gcp")]
416 "gcp" => create_gcp_vendor(properties).await?,
417
418 #[cfg(feature = "credential-vendor-azure")]
419 "azure" => create_azure_vendor(properties)?,
420
421 _ => None,
422 };
423
424 #[cfg(any(
426 feature = "credential-vendor-aws",
427 feature = "credential-vendor-azure",
428 feature = "credential-vendor-gcp"
429 ))]
430 if let Some(v) = vendor {
431 let cache_enabled = properties
432 .get(CACHE_ENABLED)
433 .map(|s| !s.eq_ignore_ascii_case("false"))
434 .unwrap_or(true);
435
436 if cache_enabled {
437 return Ok(Some(Box::new(cache::CachingCredentialVendor::new(v))));
438 } else {
439 return Ok(Some(v));
440 }
441 }
442
443 #[cfg(not(any(
444 feature = "credential-vendor-aws",
445 feature = "credential-vendor-azure",
446 feature = "credential-vendor-gcp"
447 )))]
448 let _ = vendor;
449
450 Ok(None)
451}
452
453#[cfg(any(
455 test,
456 feature = "credential-vendor-aws",
457 feature = "credential-vendor-azure",
458 feature = "credential-vendor-gcp"
459))]
460fn parse_permission(properties: &HashMap<String, String>) -> VendedPermission {
461 properties
462 .get(PERMISSION)
463 .and_then(|s| s.parse().ok())
464 .unwrap_or_default()
465}
466
467#[cfg(any(
469 test,
470 feature = "credential-vendor-aws",
471 feature = "credential-vendor-azure",
472 feature = "credential-vendor-gcp"
473))]
474fn parse_duration_millis(properties: &HashMap<String, String>, key: &str) -> u64 {
475 properties
476 .get(key)
477 .and_then(|s| s.parse::<u64>().ok())
478 .unwrap_or(DEFAULT_CREDENTIAL_DURATION_MILLIS)
479}
480
481#[cfg(feature = "credential-vendor-aws")]
482async fn create_aws_vendor(
483 properties: &HashMap<String, String>,
484) -> Result<Option<Box<dyn CredentialVendor>>> {
485 use aws::{AwsCredentialVendor, AwsCredentialVendorConfig};
486 use lance_core::utils::parse::str_is_truthy;
487 use lance_namespace::error::NamespaceError;
488
489 let role_arn = properties.get(aws_props::ROLE_ARN).ok_or_else(|| {
491 lance_core::Error::from(NamespaceError::InvalidInput {
492 message: "AWS credential vending requires 'credential_vendor.aws_role_arn' to be set"
493 .to_string(),
494 })
495 })?;
496
497 let duration_millis = parse_duration_millis(properties, aws_props::DURATION_MILLIS);
498
499 let permission = parse_permission(properties);
500
501 let mut config = AwsCredentialVendorConfig::new(role_arn)
502 .with_duration_millis(duration_millis)
503 .with_permission(permission);
504
505 if let Some(external_id) = properties.get(aws_props::EXTERNAL_ID) {
506 config = config.with_external_id(external_id);
507 }
508 if let Some(region) = properties.get(aws_props::REGION) {
509 config = config.with_region(region);
510 }
511 if let Some(session_name) = properties.get(aws_props::ROLE_SESSION_NAME) {
512 config = config.with_role_session_name(session_name);
513 }
514
515 let assume_via_pod = properties
520 .get(aws_props::ASSUME_VIA_POD_WEB_IDENTITY)
521 .map(|v| str_is_truthy(v))
522 .unwrap_or(false);
523 let pod_token_file = properties
524 .get(aws_props::POD_WEB_IDENTITY_TOKEN_FILE)
525 .cloned()
526 .or_else(|| {
527 assume_via_pod
528 .then(|| std::env::var("AWS_WEB_IDENTITY_TOKEN_FILE").ok())
529 .flatten()
530 });
531 match &pod_token_file {
534 Some(path) => log::info!(
535 "AWS credential vendor (role {role_arn}): direct AssumeRoleWithWebIdentity \
536 via pod token file '{path}'"
537 ),
538 None if assume_via_pod => log::warn!(
539 "AWS credential vendor (role {role_arn}): aws_assume_via_pod_web_identity=true \
540 but no token file resolved (aws_pod_web_identity_token_file unset and \
541 AWS_WEB_IDENTITY_TOKEN_FILE not in env); falling back to chained AssumeRole"
542 ),
543 None => log::info!(
544 "AWS credential vendor (role {role_arn}): chained AssumeRole \
545 (pod web-identity not enabled)"
546 ),
547 }
548 if let Some(path) = pod_token_file {
549 config = config.with_pod_web_identity_token_file(path);
550 }
551
552 let vendor = AwsCredentialVendor::new(config).await?;
553 Ok(Some(Box::new(vendor)))
554}
555
556#[cfg(feature = "credential-vendor-gcp")]
557async fn create_gcp_vendor(
558 properties: &HashMap<String, String>,
559) -> Result<Option<Box<dyn CredentialVendor>>> {
560 use gcp::{GcpCredentialVendor, GcpCredentialVendorConfig};
561
562 let permission = parse_permission(properties);
563
564 let mut config = GcpCredentialVendorConfig::new().with_permission(permission);
565
566 if let Some(sa) = properties.get(gcp_props::SERVICE_ACCOUNT) {
567 config = config.with_service_account(sa);
568 }
569 if let Some(provider) = properties.get(gcp_props::WORKLOAD_IDENTITY_PROVIDER) {
570 config = config.with_workload_identity_provider(provider);
571 }
572 if let Some(service_account) = properties.get(gcp_props::IMPERSONATION_SERVICE_ACCOUNT) {
573 config = config.with_impersonation_service_account(service_account);
574 }
575
576 let vendor = GcpCredentialVendor::new(config)?;
577 Ok(Some(Box::new(vendor)))
578}
579
580#[cfg(feature = "credential-vendor-azure")]
581fn create_azure_vendor(
582 properties: &HashMap<String, String>,
583) -> Result<Option<Box<dyn CredentialVendor>>> {
584 use azure::{AzureCredentialVendor, AzureCredentialVendorConfig};
585 use lance_namespace::error::NamespaceError;
586
587 let account_name = properties.get(azure_props::ACCOUNT_NAME).ok_or_else(|| {
589 lance_core::Error::from(NamespaceError::InvalidInput {
590 message:
591 "Azure credential vending requires 'credential_vendor.azure_account_name' to be set"
592 .to_string(),
593 })
594 })?;
595
596 let duration_millis = parse_duration_millis(properties, azure_props::DURATION_MILLIS);
597 let permission = parse_permission(properties);
598
599 let mut config = AzureCredentialVendorConfig::new()
600 .with_account_name(account_name)
601 .with_duration_millis(duration_millis)
602 .with_permission(permission);
603
604 if let Some(tenant_id) = properties.get(azure_props::TENANT_ID) {
605 config = config.with_tenant_id(tenant_id);
606 }
607 if let Some(client_id) = properties.get(azure_props::FEDERATED_CLIENT_ID) {
608 config = config.with_federated_client_id(client_id);
609 }
610
611 let vendor = AzureCredentialVendor::new(config);
612 Ok(Some(Box::new(vendor)))
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn test_detect_provider_from_uri() {
621 assert_eq!(detect_provider_from_uri("s3://bucket/path"), "aws");
623 assert_eq!(detect_provider_from_uri("S3://bucket/path"), "aws");
624
625 assert_eq!(detect_provider_from_uri("gs://bucket/path"), "gcp");
627 assert_eq!(detect_provider_from_uri("GS://bucket/path"), "gcp");
628
629 assert_eq!(detect_provider_from_uri("az://container/path"), "azure");
631 assert_eq!(
632 detect_provider_from_uri("az://container@account.blob.core.windows.net/path"),
633 "azure"
634 );
635 assert_eq!(
636 detect_provider_from_uri("abfss://container@account.dfs.core.windows.net/path"),
637 "azure"
638 );
639
640 assert_eq!(detect_provider_from_uri("/local/path"), "unknown");
642 assert_eq!(detect_provider_from_uri("file:///local/path"), "unknown");
643 assert_eq!(detect_provider_from_uri("memory://test"), "unknown");
644 assert_eq!(detect_provider_from_uri("s3a://bucket/path"), "unknown");
646 assert_eq!(
647 detect_provider_from_uri("wasbs://container@account.blob.core.windows.net/path"),
648 "unknown"
649 );
650 }
651
652 #[test]
653 fn test_vended_permission_from_str() {
654 assert_eq!(
656 "read".parse::<VendedPermission>().unwrap(),
657 VendedPermission::Read
658 );
659 assert_eq!(
660 "READ".parse::<VendedPermission>().unwrap(),
661 VendedPermission::Read
662 );
663 assert_eq!(
664 "write".parse::<VendedPermission>().unwrap(),
665 VendedPermission::Write
666 );
667 assert_eq!(
668 "WRITE".parse::<VendedPermission>().unwrap(),
669 VendedPermission::Write
670 );
671 assert_eq!(
672 "admin".parse::<VendedPermission>().unwrap(),
673 VendedPermission::Admin
674 );
675 assert_eq!(
676 "Admin".parse::<VendedPermission>().unwrap(),
677 VendedPermission::Admin
678 );
679
680 let err = "invalid".parse::<VendedPermission>().unwrap_err();
682 assert!(err.contains("Invalid permission"));
683 assert!(err.contains("invalid"));
684
685 let err = "".parse::<VendedPermission>().unwrap_err();
686 assert!(err.contains("Invalid permission"));
687
688 let err = "readwrite".parse::<VendedPermission>().unwrap_err();
689 assert!(err.contains("Invalid permission"));
690 }
691
692 #[test]
693 fn test_vended_permission_display() {
694 assert_eq!(VendedPermission::Read.to_string(), "read");
695 assert_eq!(VendedPermission::Write.to_string(), "write");
696 assert_eq!(VendedPermission::Admin.to_string(), "admin");
697 }
698
699 #[test]
700 fn test_parse_permission_with_invalid_values() {
701 let mut props = HashMap::new();
703 props.insert(PERMISSION.to_string(), "invalid".to_string());
704 assert_eq!(parse_permission(&props), VendedPermission::Read);
705
706 props.insert(PERMISSION.to_string(), "".to_string());
708 assert_eq!(parse_permission(&props), VendedPermission::Read);
709
710 let empty_props: HashMap<String, String> = HashMap::new();
712 assert_eq!(parse_permission(&empty_props), VendedPermission::Read);
713 }
714
715 #[test]
716 fn test_parse_duration_millis_with_invalid_values() {
717 const TEST_KEY: &str = "test_duration_millis";
718
719 let mut props = HashMap::new();
721 props.insert(TEST_KEY.to_string(), "not_a_number".to_string());
722 assert_eq!(
723 parse_duration_millis(&props, TEST_KEY),
724 DEFAULT_CREDENTIAL_DURATION_MILLIS
725 );
726
727 props.insert(TEST_KEY.to_string(), "-1000".to_string());
729 assert_eq!(
730 parse_duration_millis(&props, TEST_KEY),
731 DEFAULT_CREDENTIAL_DURATION_MILLIS
732 );
733
734 props.insert(TEST_KEY.to_string(), "".to_string());
736 assert_eq!(
737 parse_duration_millis(&props, TEST_KEY),
738 DEFAULT_CREDENTIAL_DURATION_MILLIS
739 );
740
741 let empty_props: HashMap<String, String> = HashMap::new();
743 assert_eq!(
744 parse_duration_millis(&empty_props, TEST_KEY),
745 DEFAULT_CREDENTIAL_DURATION_MILLIS
746 );
747
748 props.insert(TEST_KEY.to_string(), "7200000".to_string());
750 assert_eq!(parse_duration_millis(&props, TEST_KEY), 7200000);
751 }
752
753 #[test]
754 fn test_has_credential_vendor_config() {
755 let mut props = HashMap::new();
757 props.insert(ENABLED.to_string(), "true".to_string());
758 assert!(has_credential_vendor_config(&props));
759
760 props.insert(ENABLED.to_string(), "TRUE".to_string());
762 assert!(has_credential_vendor_config(&props));
763
764 props.insert(ENABLED.to_string(), "false".to_string());
766 assert!(!has_credential_vendor_config(&props));
767
768 props.insert(ENABLED.to_string(), "yes".to_string());
770 assert!(!has_credential_vendor_config(&props));
771
772 let empty_props: HashMap<String, String> = HashMap::new();
774 assert!(!has_credential_vendor_config(&empty_props));
775 }
776
777 #[test]
778 fn test_vended_credentials_debug_redacts_secrets() {
779 let mut storage_options = HashMap::new();
780 storage_options.insert(
781 "aws_access_key_id".to_string(),
782 "AKIAIOSFODNN7EXAMPLE".to_string(),
783 );
784 storage_options.insert(
785 "aws_secret_access_key".to_string(),
786 "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
787 );
788 storage_options.insert(
789 "aws_session_token".to_string(),
790 "FwoGZXIvYXdzE...".to_string(),
791 );
792
793 let creds = VendedCredentials::new(storage_options, 1234567890);
794 let debug_output = format!("{:?}", creds);
795
796 assert!(!debug_output.contains("AKIAIOSFODNN7EXAMPLE"));
798 assert!(!debug_output.contains("wJalrXUtnFEMI"));
799 assert!(!debug_output.contains("FwoGZXIvYXdzE"));
800
801 assert!(debug_output.contains("redacted"));
803 assert!(debug_output.contains("3 keys"));
804
805 assert!(debug_output.contains("1234567890"));
807 }
808
809 #[test]
810 fn test_vended_credentials_is_expired() {
811 let past_millis = std::time::SystemTime::now()
813 .duration_since(std::time::UNIX_EPOCH)
814 .unwrap()
815 .as_millis() as u64
816 - 1000; let expired_creds = VendedCredentials::new(HashMap::new(), past_millis);
819 assert!(expired_creds.is_expired());
820
821 let future_millis = std::time::SystemTime::now()
823 .duration_since(std::time::UNIX_EPOCH)
824 .unwrap()
825 .as_millis() as u64
826 + 3600000; let valid_creds = VendedCredentials::new(HashMap::new(), future_millis);
829 assert!(!valid_creds.is_expired());
830 }
831
832 #[test]
833 fn test_redact_credential() {
834 assert_eq!(redact_credential("AKIAIOSFODNN7EXAMPLE"), "AKIAIOSF***MPLE");
836
837 assert_eq!(redact_credential("1234567890123456"), "12345678***3456");
839
840 assert_eq!(redact_credential("short1234567"), "short123***");
842 assert_eq!(redact_credential("short123"), "short123***");
843 assert_eq!(redact_credential("tiny"), "tiny***");
844 assert_eq!(redact_credential("ab"), "ab***");
845 assert_eq!(redact_credential("a"), "a***");
846
847 assert_eq!(redact_credential(""), "[empty]");
849
850 assert_eq!(redact_credential("AKIAIOSFODNN7EXAMPLE"), "AKIAIOSF***MPLE");
853
854 let long_token = "ya29.a0AfH6SMBx1234567890abcdefghijklmnopqrstuvwxyz";
856 assert_eq!(redact_credential(long_token), "ya29.a0A***wxyz");
857
858 let sas_token = "sv=2021-06-08&ss=b&srt=sco&sp=rwdlacuiytfx&se=2024-12-31";
860 assert_eq!(redact_credential(sas_token), "sv=2021-***2-31");
861 }
862}