1use std::{collections::HashMap, str::FromStr, sync::Arc, time::Duration};
5
6use object_store::list::PaginatedListStore;
7use object_store::{
8 ClientOptions, CredentialProvider, ObjectStore as OSObjectStore, Result as ObjectStoreResult,
9 client::{HttpClient, HttpConnector, HttpRequestBody, ReqwestConnector},
10};
11use opendal::{Operator, services::Gcs};
12use reqsign_core::{Context as ReqsignContext, HttpSend, OsEnv, ProvideCredential};
13use reqsign_file_read_tokio::TokioFileRead;
14use reqsign_google::{Credential as ReqsignCredential, FileCredentialProvider};
15use tokio::sync::RwLock;
16
17use object_store::{
18 RetryConfig, StaticCredentialProvider,
19 gcp::{GcpCredential, GoogleCloudStorage, GoogleCloudStorageBuilder, GoogleConfigKey},
20};
21use url::Url;
22
23use crate::object_store::opendal_store::OpendalStore;
24use crate::object_store::{
25 DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
26 ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
27 dynamic_credentials::build_dynamic_credential_provider,
28 throttle::{AimdThrottleState, cloud_http_connector, shared_throttle_state, with_throttling},
29};
30use lance_core::error::{Error, Result};
31use lance_core::utils::parse::str_is_truthy;
32#[derive(Default, Debug)]
33pub struct GcsStoreProvider;
34
35#[derive(Debug)]
36struct ObjectStoreHttpSend {
37 client: HttpClient,
38}
39
40impl HttpSend for ObjectStoreHttpSend {
41 async fn http_send(
42 &self,
43 request: http::Request<bytes::Bytes>,
44 ) -> reqsign_core::Result<http::Response<bytes::Bytes>> {
45 let (parts, body) = request.into_parts();
46 let request = http::Request::from_parts(parts, HttpRequestBody::from(body));
47 let response = self.client.execute(request).await.map_err(|source| {
48 reqsign_core::Error::unexpected("failed to send Google workload identity HTTP request")
49 .with_source(source)
50 })?;
51 let (parts, body) = response.into_parts();
52 let body = body.bytes().await.map_err(|source| {
53 reqsign_core::Error::unexpected("failed to read Google workload identity HTTP response")
54 .with_source(source)
55 })?;
56 Ok(http::Response::from_parts(parts, body))
57 }
58}
59
60#[derive(Debug)]
61struct WorkloadIdentityCredentialProvider {
62 provider: FileCredentialProvider,
63 context: ReqsignContext,
64 cached_credential: RwLock<Option<ReqsignCredential>>,
65}
66
67impl WorkloadIdentityCredentialProvider {
68 fn new(application_credentials_path: String, http_client: HttpClient) -> Self {
69 let context = ReqsignContext::new()
70 .with_file_read(TokioFileRead)
71 .with_http_send(ObjectStoreHttpSend {
72 client: http_client,
73 })
74 .with_env(OsEnv);
75 Self {
76 provider: FileCredentialProvider::new(application_credentials_path),
77 context,
78 cached_credential: RwLock::new(None),
79 }
80 }
81}
82
83fn usable_gcp_credential(credential: &ReqsignCredential) -> Option<GcpCredential> {
84 credential
85 .token
86 .as_ref()
87 .filter(|_| credential.has_valid_token())
88 .map(|token| GcpCredential {
89 bearer: token.access_token.clone(),
90 })
91}
92
93fn workload_identity_error(
94 source: impl std::error::Error + Send + Sync + 'static,
95) -> object_store::Error {
96 object_store::Error::Generic {
97 store: "GCS workload identity credentials",
98 source: Box::new(source),
99 }
100}
101
102#[async_trait::async_trait]
103impl CredentialProvider for WorkloadIdentityCredentialProvider {
104 type Credential = GcpCredential;
105
106 async fn get_credential(&self) -> ObjectStoreResult<Arc<Self::Credential>> {
107 if let Some(credential) = self
108 .cached_credential
109 .read()
110 .await
111 .as_ref()
112 .and_then(usable_gcp_credential)
113 {
114 return Ok(Arc::new(credential));
115 }
116
117 let mut cached_credential = self.cached_credential.write().await;
118 if let Some(credential) = cached_credential.as_ref().and_then(usable_gcp_credential) {
119 return Ok(Arc::new(credential));
120 }
121
122 let credential = self
123 .provider
124 .provide_credential(&self.context)
125 .await
126 .map_err(workload_identity_error)?
127 .ok_or_else(|| {
128 workload_identity_error(std::io::Error::other(
129 "application credentials did not provide a Google access token",
130 ))
131 })?;
132 let gcp_credential = usable_gcp_credential(&credential).ok_or_else(|| {
133 workload_identity_error(std::io::Error::other(
134 "application credentials provided an expired or unusable Google access token",
135 ))
136 })?;
137 *cached_credential = Some(credential);
138 Ok(Arc::new(gcp_credential))
139 }
140}
141
142#[derive(serde::Deserialize)]
143struct ApplicationCredentialKind {
144 #[serde(rename = "type")]
145 credential_type: String,
146}
147
148struct GcsClientOptions {
149 object_requests: ClientOptions,
150 credential_requests: ClientOptions,
151}
152
153fn gcs_client_options(storage_options: &StorageOptions) -> Result<GcsClientOptions> {
154 let mut object_requests = storage_options.client_options()?;
155 let mut credential_requests = object_requests
158 .clone()
159 .with_default_headers(Default::default());
160 for (key, value) in storage_options.as_gcs_options() {
161 if let GoogleConfigKey::Client(key) = key {
162 object_requests = object_requests.with_config(key, value.clone());
163 credential_requests = credential_requests.with_config(key, value);
164 }
165 }
166 Ok(GcsClientOptions {
167 object_requests,
168 credential_requests,
169 })
170}
171
172fn workload_identity_credential_provider(
173 storage_options: &StorageOptions,
174 client_options: &ClientOptions,
175) -> Result<Option<Arc<dyn CredentialProvider<Credential = GcpCredential>>>> {
176 let gcs_options = storage_options.as_gcs_options();
177 if gcs_options.contains_key(&GoogleConfigKey::ServiceAccount)
178 || gcs_options.contains_key(&GoogleConfigKey::ServiceAccountKey)
179 {
180 return Ok(None);
181 }
182
183 let Some(application_credentials_path) =
184 gcs_options.get(&GoogleConfigKey::ApplicationCredentials)
185 else {
186 return Ok(None);
187 };
188 let Ok(contents) = std::fs::read(application_credentials_path) else {
189 return Ok(None);
190 };
191 let Ok(credential_kind) = serde_json::from_slice::<ApplicationCredentialKind>(&contents) else {
192 return Ok(None);
193 };
194 if credential_kind.credential_type != "external_account" {
195 return Ok(None);
196 }
197
198 let http_client = ReqwestConnector::default().connect(client_options)?;
199 Ok(Some(Arc::new(WorkloadIdentityCredentialProvider::new(
200 application_credentials_path.clone(),
201 http_client,
202 ))))
203}
204
205impl GcsStoreProvider {
206 async fn build_opendal_gcs_store(
207 &self,
208 base_path: &Url,
209 storage_options: &StorageOptions,
210 ) -> Result<Arc<dyn OSObjectStore>> {
211 let bucket = base_path
212 .host_str()
213 .ok_or_else(|| Error::invalid_input("GCS URL must contain bucket name"))?
214 .to_string();
215
216 let prefix = base_path.path().trim_start_matches('/').to_string();
217
218 let mut config_map: HashMap<String, String> = storage_options.0.clone();
221
222 config_map.insert("bucket".to_string(), bucket);
224
225 if !prefix.is_empty() {
226 config_map.insert("root".to_string(), format!("/{}", prefix));
227 }
228
229 let operator = Operator::from_iter::<Gcs>(config_map)
230 .map_err(|e| Error::invalid_input(format!("Failed to create GCS operator: {:?}", e)))?;
231
232 Ok(Arc::new(OpendalStore::new(operator)) as Arc<dyn OSObjectStore>)
233 }
234
235 async fn build_google_cloud_store(
236 &self,
237 base_path: &Url,
238 storage_options: &StorageOptions,
239 accessor: Option<Arc<StorageOptionsAccessor>>,
240 throttle_state: Option<&AimdThrottleState>,
241 ) -> Result<Arc<GoogleCloudStorage>> {
244 let retry_config = RetryConfig {
247 backoff: Default::default(),
248 max_retries: storage_options.client_max_retries(),
249 retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()),
250 };
251
252 let client_options = gcs_client_options(storage_options)?;
253
254 let mut builder = GoogleCloudStorageBuilder::new()
255 .with_url(base_path.as_ref())
256 .with_retry(retry_config)
257 .with_client_options(client_options.object_requests.clone());
258 for (key, value) in storage_options.as_gcs_options() {
259 builder = builder.with_config(key, value);
260 }
261
262 if let Some(credentials) =
263 build_dynamic_credential_provider::<GcpCredential>(accessor).await?
264 {
265 builder = builder.with_credentials(credentials);
266 } else if let Some(storage_token) = storage_options.get("google_storage_token") {
267 let credential = GcpCredential {
268 bearer: storage_token.clone(),
269 };
270 let credential_provider = Arc::new(StaticCredentialProvider::new(credential)) as _;
271 builder = builder.with_credentials(credential_provider);
272 } else if let Some(credential_provider) = workload_identity_credential_provider(
273 storage_options,
274 &client_options.credential_requests,
275 )? {
276 builder = builder.with_credentials(credential_provider);
279 }
280
281 let store_prefix =
282 self.calculate_object_store_prefix(base_path, Some(&storage_options.0))?;
283 builder = builder.with_http_connector(cloud_http_connector(
284 throttle_state,
285 crate::object_store::metrics_base(&store_prefix, base_path),
286 ));
287
288 Ok(Arc::new(builder.build()?))
289 }
290}
291
292#[async_trait::async_trait]
293impl ObjectStoreProvider for GcsStoreProvider {
294 async fn new_store(&self, base_path: Url, params: &ObjectStoreParams) -> Result<ObjectStore> {
295 let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE);
296 let mut storage_options =
297 StorageOptions::new(params.storage_options().cloned().unwrap_or_default());
298 storage_options.with_env_gcs();
299 let download_retry_count = storage_options.download_retry_count();
300
301 let use_opendal = storage_options
302 .0
303 .get("use_opendal")
304 .map(|v| str_is_truthy(v.as_str()))
305 .unwrap_or(false);
306
307 let accessor = params.get_accessor();
308
309 let store_prefix =
311 self.calculate_object_store_prefix(&base_path, params.storage_options())?;
312 let throttle_state = shared_throttle_state(&store_prefix, params)?;
313
314 let (inner, paginated_lister) = if use_opendal {
315 (
319 self.build_opendal_gcs_store(&base_path, &storage_options)
320 .await?,
321 None,
322 )
323 } else {
324 let store = self
325 .build_google_cloud_store(
326 &base_path,
327 &storage_options,
328 accessor,
329 throttle_state.as_ref(),
330 )
331 .await?;
332 (
333 store.clone() as Arc<dyn OSObjectStore>,
334 Some(store as Arc<dyn PaginatedListStore>),
335 )
336 };
337 let (inner, paginated_lister) =
338 with_throttling(throttle_state, !use_opendal, inner, paginated_lister);
339
340 Ok(ObjectStore {
341 inner,
342 local_dir_operations: None,
343 scheme: String::from("gs"),
344 block_size,
345 max_iop_size: *DEFAULT_MAX_IOP_SIZE,
346 use_constant_size_upload_parts: false,
347 list_is_lexically_ordered: true,
348 io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
349 download_retry_count,
350 io_tracker: Default::default(),
351 store_prefix: self
352 .calculate_object_store_prefix(&base_path, params.storage_options())?,
353 paginated_lister,
354 })
355 }
356}
357
358impl StorageOptions {
359 pub fn with_env_gcs(&mut self) {
362 self.merge_env_options(std::env::vars_os(), |key| {
363 GoogleConfigKey::from_str(key)
364 .ok()
365 .map(|key| key.as_ref().to_string())
366 });
367 let token_key = "google_storage_token";
369 for (os_key, os_value) in std::env::vars_os() {
370 if let (Some(key), Some(value)) = (os_key.to_str(), os_value.to_str())
371 && key.eq_ignore_ascii_case(token_key)
372 && !self.0.contains_key(token_key)
373 {
374 self.0.insert(token_key.to_string(), value.to_string());
375 }
376 }
377 }
378
379 pub fn as_gcs_options(&self) -> HashMap<GoogleConfigKey, String> {
398 self.as_cloud_options()
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use std::{collections::HashMap, fs, sync::Arc};
406
407 use crate::object_store::test_utils::StaticMockStorageOptionsProvider;
408 use crate::object_store::{ObjectStoreParams, StorageOptionsAccessor};
409 use tempfile::TempDir;
410 use wiremock::{
411 Mock, MockServer, ResponseTemplate,
412 matchers::{method, path},
413 };
414
415 #[rstest::rstest]
416 #[case::canonical("google_service_account", "service_account")]
417 #[case::canonical_over_case_variant("google_service_account", "GOOGLE_SERVICE_ACCOUNT")]
418 #[case::canonical_case_tie("GOOGLE_SERVICE_ACCOUNT", "Google_Service_Account")]
419 #[case::case_variant_over_alias("GOOGLE_SERVICE_ACCOUNT", "service_account_path")]
420 #[case::lexical_alias("service_account", "service_account_path")]
421 #[case::case_alias("SERVICE_ACCOUNT", "service_account")]
422 fn test_storage_options_alias_precedence(
423 #[case] preferred_key: &str,
424 #[case] conflicting_key: &str,
425 #[values("preferred", "")] preferred_value: &str,
426 #[values(false, true)] is_reversed: bool,
427 ) {
428 for _ in 0..16 {
430 let mut entries = [
431 (preferred_key.to_string(), preferred_value.to_string()),
432 (conflicting_key.to_string(), "conflicting".to_string()),
433 ("lance_custom_option".to_string(), "ignored".to_string()),
434 ];
435 if is_reversed {
436 entries.reverse();
437 }
438 let options = StorageOptions(HashMap::from(entries));
439 assert_eq!(
440 options.as_gcs_options(),
441 HashMap::from([(GoogleConfigKey::ServiceAccount, preferred_value.to_string())])
442 );
443 }
444 }
445
446 fn external_account_storage_options(
447 temp_dir: &TempDir,
448 token_url: String,
449 ) -> HashMap<String, String> {
450 let subject_token_path = temp_dir.path().join("oidc-token");
451 fs::write(&subject_token_path, "github-oidc-token").unwrap();
452 let application_credentials_path = temp_dir.path().join("credentials.json");
453 fs::write(
454 &application_credentials_path,
455 serde_json::to_vec(&serde_json::json!({
456 "type": "external_account",
457 "audience": "test-audience",
458 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
459 "token_url": token_url,
460 "credential_source": {
461 "file": subject_token_path.to_string_lossy(),
462 "format": { "type": "text" }
463 }
464 }))
465 .unwrap(),
466 )
467 .unwrap();
468
469 HashMap::from([
470 (
471 "google_application_credentials".to_string(),
472 application_credentials_path.to_string_lossy().into_owned(),
473 ),
474 ("allow_http".to_string(), "true".to_string()),
475 ])
476 }
477
478 #[test]
479 fn test_gcs_store_path() {
480 let provider = GcsStoreProvider;
481
482 let url = Url::parse("gs://bucket/path/to/file").unwrap();
483 let path = provider.extract_path(&url).unwrap();
484 let expected_path = object_store::path::Path::from("path/to/file");
485 assert_eq!(path, expected_path);
486 }
487
488 #[tokio::test]
489 async fn test_use_opendal_flag() {
490 let provider = GcsStoreProvider;
491 let url = Url::parse("gs://test-bucket/path").unwrap();
492 let params_with_flag = ObjectStoreParams {
493 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
494 HashMap::from([
495 ("use_opendal".to_string(), "true".to_string()),
496 (
497 "service_account".to_string(),
498 "test@example.iam.gserviceaccount.com".to_string(),
499 ),
500 ]),
501 ))),
502 ..Default::default()
503 };
504
505 let store = provider
506 .new_store(url.clone(), ¶ms_with_flag)
507 .await
508 .unwrap();
509 assert_eq!(store.scheme, "gs");
510 }
511
512 #[tokio::test]
513 async fn test_dynamic_gcp_credentials_provider() {
514 let accessor = Arc::new(StorageOptionsAccessor::with_provider(Arc::new(
515 StaticMockStorageOptionsProvider {
516 options: HashMap::from([(
517 "google_storage_token".to_string(),
518 "gcp-token".to_string(),
519 )]),
520 },
521 )));
522
523 let credentials = build_dynamic_credential_provider::<GcpCredential>(Some(accessor))
524 .await
525 .expect("dynamic gcp credentials should build")
526 .expect("expected credential provider")
527 .get_credential()
528 .await
529 .expect("expected gcp credential");
530
531 assert_eq!(credentials.bearer, "gcp-token");
532 }
533
534 #[tokio::test]
535 async fn test_external_account_application_credentials() {
536 let mock_server = MockServer::start().await;
537 Mock::given(method("POST"))
538 .and(path("/token"))
539 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
540 "access_token": "federated-token",
541 "expires_in": 3600
542 })))
543 .expect(1)
544 .mount(&mock_server)
545 .await;
546
547 let temp_dir = tempfile::tempdir().unwrap();
548 let mut storage_options =
549 external_account_storage_options(&temp_dir, format!("{}/token", mock_server.uri()));
550 storage_options.insert(
551 "headers.Authorization".to_string(),
552 "Bearer storage-secret".to_string(),
553 );
554 let params = ObjectStoreParams {
555 storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
556 storage_options.clone(),
557 ))),
558 ..Default::default()
559 };
560
561 let store = GcsStoreProvider
562 .new_store(Url::parse("gs://test-bucket/path").unwrap(), ¶ms)
563 .await
564 .expect("external account credentials should build a GCS store");
565 assert_eq!(store.scheme, "gs");
566
567 let storage_options = StorageOptions::new(storage_options);
568 let client_options = gcs_client_options(&storage_options).unwrap();
569 let credential_provider = workload_identity_credential_provider(
570 &storage_options,
571 &client_options.credential_requests,
572 )
573 .expect("external account credential provider should build")
574 .expect("external account credentials should select the reqsign provider");
575 for _ in 0..2 {
576 let credential = credential_provider
577 .get_credential()
578 .await
579 .expect("workload identity token exchange should succeed");
580 assert_eq!(credential.bearer, "federated-token");
581 }
582 mock_server.verify().await;
583 let requests = mock_server.received_requests().await.unwrap();
584 assert!(!requests[0].headers.contains_key("authorization"));
585 }
586
587 #[tokio::test]
588 async fn test_external_account_respects_client_timeout() {
589 let mock_server = MockServer::start().await;
590 Mock::given(method("POST"))
591 .and(path("/token"))
592 .respond_with(
593 ResponseTemplate::new(200)
594 .set_delay(Duration::from_secs(1))
595 .set_body_json(serde_json::json!({
596 "access_token": "federated-token",
597 "expires_in": 3600
598 })),
599 )
600 .expect(1)
601 .mount(&mock_server)
602 .await;
603
604 let temp_dir = tempfile::tempdir().unwrap();
605 let mut storage_options =
606 external_account_storage_options(&temp_dir, format!("{}/token", mock_server.uri()));
607 storage_options.insert("timeout".to_string(), "50ms".to_string());
608 let storage_options = StorageOptions::new(storage_options);
609 let client_options = gcs_client_options(&storage_options).unwrap();
610 let credential_provider = workload_identity_credential_provider(
611 &storage_options,
612 &client_options.credential_requests,
613 )
614 .expect("external account credential provider should build")
615 .expect("external account credentials should select the reqsign provider");
616
617 let credential_result = tokio::time::timeout(
618 Duration::from_millis(200),
619 credential_provider.get_credential(),
620 )
621 .await
622 .expect("configured client timeout should bound the credential exchange");
623 let error = credential_result.expect_err("the delayed token exchange should time out");
624 assert!(matches!(&error, object_store::Error::Generic { .. }));
625 assert!(
626 error
627 .to_string()
628 .contains("failed to send Google workload identity HTTP request"),
629 "unexpected error: {error}"
630 );
631 mock_server.verify().await;
632 }
633}