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_namespace::error::NamespaceError;
487
488 let role_arn = properties.get(aws_props::ROLE_ARN).ok_or_else(|| {
490 lance_core::Error::from(NamespaceError::InvalidInput {
491 message: "AWS credential vending requires 'credential_vendor.aws_role_arn' to be set"
492 .to_string(),
493 })
494 })?;
495
496 let duration_millis = parse_duration_millis(properties, aws_props::DURATION_MILLIS);
497
498 let permission = parse_permission(properties);
499
500 let mut config = AwsCredentialVendorConfig::new(role_arn)
501 .with_duration_millis(duration_millis)
502 .with_permission(permission);
503
504 if let Some(external_id) = properties.get(aws_props::EXTERNAL_ID) {
505 config = config.with_external_id(external_id);
506 }
507 if let Some(region) = properties.get(aws_props::REGION) {
508 config = config.with_region(region);
509 }
510 if let Some(session_name) = properties.get(aws_props::ROLE_SESSION_NAME) {
511 config = config.with_role_session_name(session_name);
512 }
513
514 let assume_via_pod = properties
519 .get(aws_props::ASSUME_VIA_POD_WEB_IDENTITY)
520 .map(|v| v.eq_ignore_ascii_case("true"))
521 .unwrap_or(false);
522 let pod_token_file = properties
523 .get(aws_props::POD_WEB_IDENTITY_TOKEN_FILE)
524 .cloned()
525 .or_else(|| {
526 assume_via_pod
527 .then(|| std::env::var("AWS_WEB_IDENTITY_TOKEN_FILE").ok())
528 .flatten()
529 });
530 match &pod_token_file {
533 Some(path) => log::info!(
534 "AWS credential vendor (role {role_arn}): direct AssumeRoleWithWebIdentity \
535 via pod token file '{path}'"
536 ),
537 None if assume_via_pod => log::warn!(
538 "AWS credential vendor (role {role_arn}): aws_assume_via_pod_web_identity=true \
539 but no token file resolved (aws_pod_web_identity_token_file unset and \
540 AWS_WEB_IDENTITY_TOKEN_FILE not in env); falling back to chained AssumeRole"
541 ),
542 None => log::info!(
543 "AWS credential vendor (role {role_arn}): chained AssumeRole \
544 (pod web-identity not enabled)"
545 ),
546 }
547 if let Some(path) = pod_token_file {
548 config = config.with_pod_web_identity_token_file(path);
549 }
550
551 let vendor = AwsCredentialVendor::new(config).await?;
552 Ok(Some(Box::new(vendor)))
553}
554
555#[cfg(feature = "credential-vendor-gcp")]
556async fn create_gcp_vendor(
557 properties: &HashMap<String, String>,
558) -> Result<Option<Box<dyn CredentialVendor>>> {
559 use gcp::{GcpCredentialVendor, GcpCredentialVendorConfig};
560
561 let permission = parse_permission(properties);
562
563 let mut config = GcpCredentialVendorConfig::new().with_permission(permission);
564
565 if let Some(sa) = properties.get(gcp_props::SERVICE_ACCOUNT) {
566 config = config.with_service_account(sa);
567 }
568 if let Some(provider) = properties.get(gcp_props::WORKLOAD_IDENTITY_PROVIDER) {
569 config = config.with_workload_identity_provider(provider);
570 }
571 if let Some(service_account) = properties.get(gcp_props::IMPERSONATION_SERVICE_ACCOUNT) {
572 config = config.with_impersonation_service_account(service_account);
573 }
574
575 let vendor = GcpCredentialVendor::new(config)?;
576 Ok(Some(Box::new(vendor)))
577}
578
579#[cfg(feature = "credential-vendor-azure")]
580fn create_azure_vendor(
581 properties: &HashMap<String, String>,
582) -> Result<Option<Box<dyn CredentialVendor>>> {
583 use azure::{AzureCredentialVendor, AzureCredentialVendorConfig};
584 use lance_namespace::error::NamespaceError;
585
586 let account_name = properties.get(azure_props::ACCOUNT_NAME).ok_or_else(|| {
588 lance_core::Error::from(NamespaceError::InvalidInput {
589 message:
590 "Azure credential vending requires 'credential_vendor.azure_account_name' to be set"
591 .to_string(),
592 })
593 })?;
594
595 let duration_millis = parse_duration_millis(properties, azure_props::DURATION_MILLIS);
596 let permission = parse_permission(properties);
597
598 let mut config = AzureCredentialVendorConfig::new()
599 .with_account_name(account_name)
600 .with_duration_millis(duration_millis)
601 .with_permission(permission);
602
603 if let Some(tenant_id) = properties.get(azure_props::TENANT_ID) {
604 config = config.with_tenant_id(tenant_id);
605 }
606 if let Some(client_id) = properties.get(azure_props::FEDERATED_CLIENT_ID) {
607 config = config.with_federated_client_id(client_id);
608 }
609
610 let vendor = AzureCredentialVendor::new(config);
611 Ok(Some(Box::new(vendor)))
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn test_detect_provider_from_uri() {
620 assert_eq!(detect_provider_from_uri("s3://bucket/path"), "aws");
622 assert_eq!(detect_provider_from_uri("S3://bucket/path"), "aws");
623
624 assert_eq!(detect_provider_from_uri("gs://bucket/path"), "gcp");
626 assert_eq!(detect_provider_from_uri("GS://bucket/path"), "gcp");
627
628 assert_eq!(detect_provider_from_uri("az://container/path"), "azure");
630 assert_eq!(
631 detect_provider_from_uri("az://container@account.blob.core.windows.net/path"),
632 "azure"
633 );
634 assert_eq!(
635 detect_provider_from_uri("abfss://container@account.dfs.core.windows.net/path"),
636 "azure"
637 );
638
639 assert_eq!(detect_provider_from_uri("/local/path"), "unknown");
641 assert_eq!(detect_provider_from_uri("file:///local/path"), "unknown");
642 assert_eq!(detect_provider_from_uri("memory://test"), "unknown");
643 assert_eq!(detect_provider_from_uri("s3a://bucket/path"), "unknown");
645 assert_eq!(
646 detect_provider_from_uri("wasbs://container@account.blob.core.windows.net/path"),
647 "unknown"
648 );
649 }
650
651 #[test]
652 fn test_vended_permission_from_str() {
653 assert_eq!(
655 "read".parse::<VendedPermission>().unwrap(),
656 VendedPermission::Read
657 );
658 assert_eq!(
659 "READ".parse::<VendedPermission>().unwrap(),
660 VendedPermission::Read
661 );
662 assert_eq!(
663 "write".parse::<VendedPermission>().unwrap(),
664 VendedPermission::Write
665 );
666 assert_eq!(
667 "WRITE".parse::<VendedPermission>().unwrap(),
668 VendedPermission::Write
669 );
670 assert_eq!(
671 "admin".parse::<VendedPermission>().unwrap(),
672 VendedPermission::Admin
673 );
674 assert_eq!(
675 "Admin".parse::<VendedPermission>().unwrap(),
676 VendedPermission::Admin
677 );
678
679 let err = "invalid".parse::<VendedPermission>().unwrap_err();
681 assert!(err.contains("Invalid permission"));
682 assert!(err.contains("invalid"));
683
684 let err = "".parse::<VendedPermission>().unwrap_err();
685 assert!(err.contains("Invalid permission"));
686
687 let err = "readwrite".parse::<VendedPermission>().unwrap_err();
688 assert!(err.contains("Invalid permission"));
689 }
690
691 #[test]
692 fn test_vended_permission_display() {
693 assert_eq!(VendedPermission::Read.to_string(), "read");
694 assert_eq!(VendedPermission::Write.to_string(), "write");
695 assert_eq!(VendedPermission::Admin.to_string(), "admin");
696 }
697
698 #[test]
699 fn test_parse_permission_with_invalid_values() {
700 let mut props = HashMap::new();
702 props.insert(PERMISSION.to_string(), "invalid".to_string());
703 assert_eq!(parse_permission(&props), VendedPermission::Read);
704
705 props.insert(PERMISSION.to_string(), "".to_string());
707 assert_eq!(parse_permission(&props), VendedPermission::Read);
708
709 let empty_props: HashMap<String, String> = HashMap::new();
711 assert_eq!(parse_permission(&empty_props), VendedPermission::Read);
712 }
713
714 #[test]
715 fn test_parse_duration_millis_with_invalid_values() {
716 const TEST_KEY: &str = "test_duration_millis";
717
718 let mut props = HashMap::new();
720 props.insert(TEST_KEY.to_string(), "not_a_number".to_string());
721 assert_eq!(
722 parse_duration_millis(&props, TEST_KEY),
723 DEFAULT_CREDENTIAL_DURATION_MILLIS
724 );
725
726 props.insert(TEST_KEY.to_string(), "-1000".to_string());
728 assert_eq!(
729 parse_duration_millis(&props, TEST_KEY),
730 DEFAULT_CREDENTIAL_DURATION_MILLIS
731 );
732
733 props.insert(TEST_KEY.to_string(), "".to_string());
735 assert_eq!(
736 parse_duration_millis(&props, TEST_KEY),
737 DEFAULT_CREDENTIAL_DURATION_MILLIS
738 );
739
740 let empty_props: HashMap<String, String> = HashMap::new();
742 assert_eq!(
743 parse_duration_millis(&empty_props, TEST_KEY),
744 DEFAULT_CREDENTIAL_DURATION_MILLIS
745 );
746
747 props.insert(TEST_KEY.to_string(), "7200000".to_string());
749 assert_eq!(parse_duration_millis(&props, TEST_KEY), 7200000);
750 }
751
752 #[test]
753 fn test_has_credential_vendor_config() {
754 let mut props = HashMap::new();
756 props.insert(ENABLED.to_string(), "true".to_string());
757 assert!(has_credential_vendor_config(&props));
758
759 props.insert(ENABLED.to_string(), "TRUE".to_string());
761 assert!(has_credential_vendor_config(&props));
762
763 props.insert(ENABLED.to_string(), "false".to_string());
765 assert!(!has_credential_vendor_config(&props));
766
767 props.insert(ENABLED.to_string(), "yes".to_string());
769 assert!(!has_credential_vendor_config(&props));
770
771 let empty_props: HashMap<String, String> = HashMap::new();
773 assert!(!has_credential_vendor_config(&empty_props));
774 }
775
776 #[test]
777 fn test_vended_credentials_debug_redacts_secrets() {
778 let mut storage_options = HashMap::new();
779 storage_options.insert(
780 "aws_access_key_id".to_string(),
781 "AKIAIOSFODNN7EXAMPLE".to_string(),
782 );
783 storage_options.insert(
784 "aws_secret_access_key".to_string(),
785 "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
786 );
787 storage_options.insert(
788 "aws_session_token".to_string(),
789 "FwoGZXIvYXdzE...".to_string(),
790 );
791
792 let creds = VendedCredentials::new(storage_options, 1234567890);
793 let debug_output = format!("{:?}", creds);
794
795 assert!(!debug_output.contains("AKIAIOSFODNN7EXAMPLE"));
797 assert!(!debug_output.contains("wJalrXUtnFEMI"));
798 assert!(!debug_output.contains("FwoGZXIvYXdzE"));
799
800 assert!(debug_output.contains("redacted"));
802 assert!(debug_output.contains("3 keys"));
803
804 assert!(debug_output.contains("1234567890"));
806 }
807
808 #[test]
809 fn test_vended_credentials_is_expired() {
810 let past_millis = std::time::SystemTime::now()
812 .duration_since(std::time::UNIX_EPOCH)
813 .unwrap()
814 .as_millis() as u64
815 - 1000; let expired_creds = VendedCredentials::new(HashMap::new(), past_millis);
818 assert!(expired_creds.is_expired());
819
820 let future_millis = std::time::SystemTime::now()
822 .duration_since(std::time::UNIX_EPOCH)
823 .unwrap()
824 .as_millis() as u64
825 + 3600000; let valid_creds = VendedCredentials::new(HashMap::new(), future_millis);
828 assert!(!valid_creds.is_expired());
829 }
830
831 #[test]
832 fn test_redact_credential() {
833 assert_eq!(redact_credential("AKIAIOSFODNN7EXAMPLE"), "AKIAIOSF***MPLE");
835
836 assert_eq!(redact_credential("1234567890123456"), "12345678***3456");
838
839 assert_eq!(redact_credential("short1234567"), "short123***");
841 assert_eq!(redact_credential("short123"), "short123***");
842 assert_eq!(redact_credential("tiny"), "tiny***");
843 assert_eq!(redact_credential("ab"), "ab***");
844 assert_eq!(redact_credential("a"), "a***");
845
846 assert_eq!(redact_credential(""), "[empty]");
848
849 assert_eq!(redact_credential("AKIAIOSFODNN7EXAMPLE"), "AKIAIOSF***MPLE");
852
853 let long_token = "ya29.a0AfH6SMBx1234567890abcdefghijklmnopqrstuvwxyz";
855 assert_eq!(redact_credential(long_token), "ya29.a0A***wxyz");
856
857 let sas_token = "sv=2021-06-08&ss=b&srt=sco&sp=rwdlacuiytfx&se=2024-12-31";
859 assert_eq!(redact_credential(sas_token), "sv=2021-***2-31");
860 }
861}