Skip to main content

laminar_connectors/storage/resolver/
mod.rs

1//! Cloud storage credential resolver.
2//!
3//! [`StorageCredentialResolver`] retains explicit `storage.*` config options and
4//! records non-secret information about ambient credential sources. Provider
5//! libraries read the environment themselves when constructing a client, so
6//! rotating tokens are never copied into connector configuration.
7//!
8//! Resolution priority chain:
9//! 1. Explicit connector options (`storage.*` keys)
10//! 2. Explicitly selected profiles or credential sources
11//! 3. Provider environment and default credential chains (handled downstream)
12#![allow(clippy::disallowed_types)] // cold path: storage configuration
13
14use std::collections::HashMap;
15use std::fmt;
16
17use super::provider::StorageProvider;
18use laminar_core::storage_auth::classify_storage_auth_source;
19pub use laminar_core::storage_auth::AuthSource;
20use laminar_core::storage_location::StorageEndpointClass;
21
22/// AWS S3 environment variable fallbacks.
23///
24/// Maps option key (as used by `object_store`/`deltalake`) to env var name.
25const AWS_ENV_MAPPING: &[(&str, &str)] = &[
26    ("aws_access_key_id", "AWS_ACCESS_KEY_ID"),
27    ("aws_secret_access_key", "AWS_SECRET_ACCESS_KEY"),
28    ("aws_region", "AWS_REGION"),
29    ("aws_default_region", "AWS_DEFAULT_REGION"),
30    ("aws_session_token", "AWS_SESSION_TOKEN"),
31    ("aws_endpoint", "AWS_ENDPOINT_URL"),
32    ("aws_endpoint", "AWS_ENDPOINT"),
33    ("aws_endpoint_url_s3", "AWS_ENDPOINT_URL_S3"),
34    ("aws_profile", "AWS_PROFILE"),
35    ("aws_web_identity_token_file", "AWS_WEB_IDENTITY_TOKEN_FILE"),
36    ("aws_role_arn", "AWS_ROLE_ARN"),
37    ("aws_role_session_name", "AWS_ROLE_SESSION_NAME"),
38    (
39        "aws_container_credentials_relative_uri",
40        "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
41    ),
42    (
43        "aws_container_credentials_full_uri",
44        "AWS_CONTAINER_CREDENTIALS_FULL_URI",
45    ),
46    (
47        "aws_container_authorization_token_file",
48        "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
49    ),
50    (
51        "aws_virtual_hosted_style_request",
52        "AWS_VIRTUAL_HOSTED_STYLE_REQUEST",
53    ),
54    ("aws_allow_http", "AWS_ALLOW_HTTP"),
55    ("aws_s3_allow_unsafe_rename", "AWS_S3_ALLOW_UNSAFE_RENAME"),
56];
57
58/// Azure ADLS environment variable fallbacks.
59const AZURE_ENV_MAPPING: &[(&str, &str)] = &[
60    ("azure_storage_account_name", "AZURE_STORAGE_ACCOUNT_NAME"),
61    ("azure_storage_account_key", "AZURE_STORAGE_ACCOUNT_KEY"),
62    ("azure_storage_sas_token", "AZURE_STORAGE_SAS_TOKEN"),
63    ("azure_storage_client_id", "AZURE_CLIENT_ID"),
64    ("azure_storage_tenant_id", "AZURE_TENANT_ID"),
65    ("azure_storage_client_secret", "AZURE_CLIENT_SECRET"),
66    ("azure_federated_token_file", "AZURE_FEDERATED_TOKEN_FILE"),
67    ("azure_storage_authority_host", "AZURE_AUTHORITY_HOST"),
68    ("azure_storage_endpoint", "AZURE_STORAGE_ENDPOINT"),
69    ("azure_allow_http", "AZURE_ALLOW_HTTP"),
70    ("azure_msi_endpoint", "IDENTITY_ENDPOINT"),
71    ("azure_use_azure_cli", "AZURE_USE_AZURE_CLI"),
72];
73
74/// GCS environment variable fallbacks.
75const GCS_ENV_MAPPING: &[(&str, &str)] = &[
76    ("google_service_account_path", "SERVICE_ACCOUNT"),
77    ("google_service_account_path", "GOOGLE_SERVICE_ACCOUNT"),
78    ("google_service_account_path", "GOOGLE_SERVICE_ACCOUNT_PATH"),
79    ("google_service_account_key", "GOOGLE_SERVICE_ACCOUNT_KEY"),
80    (
81        "google_application_credentials",
82        "GOOGLE_APPLICATION_CREDENTIALS",
83    ),
84    ("google_base_url", "GOOGLE_BASE_URL"),
85    ("google_base_url", "GOOGLE_ENDPOINT_URL"),
86    ("google_allow_http", "GOOGLE_ALLOW_HTTP"),
87];
88
89/// Resolved storage credentials ready for `object_store` / `deltalake`.
90#[derive(Clone)]
91pub struct ResolvedStorageOptions {
92    /// Detected cloud provider.
93    pub provider: StorageProvider,
94    /// Explicit and URL-derived options. Keys match what
95    /// `deltalake`/`object_store` expect.
96    pub options: HashMap<String, String>,
97    /// Canonical keys whose non-empty environment sources were observed.
98    ///
99    /// Credential values are deliberately not retained. The effective endpoint
100    /// URL is retained only so the same validation applies to explicit and
101    /// environment-selected compatibility endpoints.
102    pub env_resolved_keys: Vec<String>,
103    /// Non-secret credential mechanism selected by resolution.
104    pub auth_source: AuthSource,
105    /// Effective native, compatibility, emulator, or local endpoint class.
106    pub endpoint_class: StorageEndpointClass,
107    /// Whether connector options or provider environment selected an endpoint override.
108    pub endpoint_override_configured: bool,
109}
110
111impl ResolvedStorageOptions {
112    /// Whether concrete credential material or an explicit credential source was selected.
113    ///
114    /// A `false` result does not mean credentials are missing: ambient metadata
115    /// and downstream default chains intentionally return `false`.
116    #[must_use]
117    pub fn has_credentials(&self) -> bool {
118        matches!(
119            self.auth_source,
120            AuthSource::ExplicitStatic
121                | AuthSource::ExplicitToken
122                | AuthSource::EnvironmentStatic
123                | AuthSource::EnvironmentToken
124                | AuthSource::Profile
125                | AuthSource::WebIdentity
126                | AuthSource::WorkloadIdentity
127                | AuthSource::AzureCli
128        )
129    }
130
131    /// Effective endpoint class without exposing the endpoint or location.
132    #[must_use]
133    pub fn endpoint_class(&self) -> StorageEndpointClass {
134        self.endpoint_class
135    }
136}
137
138impl fmt::Debug for ResolvedStorageOptions {
139    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140        let mut option_keys = self.options.keys().collect::<Vec<_>>();
141        option_keys.sort_unstable();
142        let mut environment_keys = self.env_resolved_keys.iter().collect::<Vec<_>>();
143        environment_keys.sort_unstable();
144        formatter
145            .debug_struct("ResolvedStorageOptions")
146            .field("provider", &self.provider)
147            .field("auth_source", &self.auth_source)
148            .field("endpoint_class", &self.endpoint_class)
149            .field(
150                "endpoint_override_configured",
151                &self.endpoint_override_configured,
152            )
153            .field("option_keys", &option_keys)
154            .field("env_resolved_keys", &environment_keys)
155            .finish()
156    }
157}
158
159/// Storage credential resolver.
160///
161/// Resolves credentials by priority chain:
162/// 1. Explicit `storage.*` connector options
163/// 2. Explicitly selected profiles or credential sources
164/// 3. Provider environment and default credential chains (handled downstream)
165pub struct StorageCredentialResolver;
166
167impl StorageCredentialResolver {
168    /// Resolves storage credentials for the given table path.
169    ///
170    /// Retains explicit options and classifies environment/default-chain
171    /// sources appropriate for the detected cloud provider.
172    ///
173    /// # Arguments
174    ///
175    /// * `table_path` - URI of the table (`s3://`, `az://`, `gs://`, or local path)
176    /// * `explicit_options` - Connector options (`storage.` prefix already stripped)
177    ///
178    /// # Returns
179    ///
180    /// [`ResolvedStorageOptions`] with explicit options and ambient-source metadata.
181    #[must_use]
182    pub fn resolve(
183        table_path: &str,
184        explicit_options: &HashMap<String, String>,
185    ) -> ResolvedStorageOptions {
186        Self::resolve_from(table_path, explicit_options, &|name| {
187            std::env::var(name).ok()
188        })
189    }
190
191    /// Resolves credentials using a custom environment lookup function.
192    ///
193    /// Allows injecting env var values without mutating the actual
194    /// process environment.
195    #[cfg(test)]
196    #[must_use]
197    pub fn resolve_with_env<F>(
198        table_path: &str,
199        explicit_options: &HashMap<String, String>,
200        env_lookup: F,
201    ) -> ResolvedStorageOptions
202    where
203        F: Fn(&str) -> Option<String>,
204    {
205        Self::resolve_from(table_path, explicit_options, &env_lookup)
206    }
207
208    fn resolve_from<F>(
209        table_path: &str,
210        explicit_options: &HashMap<String, String>,
211        env_lookup: &F,
212    ) -> ResolvedStorageOptions
213    where
214        F: Fn(&str) -> Option<String>,
215    {
216        let parsed_location =
217            laminar_core::storage_location::StorageLocation::parse(table_path).ok();
218        let provider = parsed_location.as_ref().map_or_else(
219            || StorageProvider::detect(table_path),
220            |location| location.provider,
221        );
222
223        if provider == StorageProvider::Local {
224            return ResolvedStorageOptions {
225                provider,
226                options: explicit_options.clone(),
227                env_resolved_keys: Vec::new(),
228                auth_source: AuthSource::Anonymous,
229                endpoint_class: StorageEndpointClass::Local,
230                endpoint_override_configured: false,
231            };
232        }
233
234        let env_mapping = match provider {
235            StorageProvider::AwsS3 => AWS_ENV_MAPPING,
236            StorageProvider::AzureAdls => AZURE_ENV_MAPPING,
237            StorageProvider::Gcs => GCS_ENV_MAPPING,
238            StorageProvider::Local => &[],
239        };
240
241        let mut resolved = explicit_options.clone();
242        let mut env_resolved = Vec::new();
243        let explicit_endpoint = explicit_options
244            .iter()
245            .any(|(key, value)| is_endpoint_key(key) && !value.trim().is_empty());
246
247        for (option_key, env_var) in env_mapping {
248            if !resolved.contains_key(*option_key) {
249                if let Some(val) = env_lookup(env_var) {
250                    if effective_environment_value(option_key, &val) {
251                        if explicit_endpoint && is_endpoint_key(option_key) {
252                            continue;
253                        }
254                        let canonical_key = (*option_key).to_string();
255                        if !env_resolved.contains(&canonical_key) {
256                            env_resolved.push(canonical_key.clone());
257                        }
258                        if is_endpoint_key(option_key)
259                            && !explicit_endpoint
260                            && !resolved.keys().any(|key| is_endpoint_key(key))
261                        {
262                            resolved.insert(canonical_key, val);
263                        }
264                    }
265                }
266            }
267        }
268
269        let endpoint_override_configured =
270            explicit_endpoint || env_resolved.iter().any(|key| is_endpoint_key(key));
271        let endpoint_class = resolved_endpoint_class(
272            provider,
273            parsed_location.as_ref(),
274            endpoint_override_configured,
275        );
276
277        if let Some(Ok(adapted)) = parsed_location.as_ref().map(|location| {
278            location.adapt(laminar_core::storage_location::StorageConsumer::ObjectStore)
279        }) {
280            for (key, value) in adapted.derived_options {
281                resolved.entry(key).or_insert(value);
282            }
283        }
284
285        let auth_source = classify_storage_auth_source(provider, explicit_options, env_lookup);
286
287        ResolvedStorageOptions {
288            provider,
289            options: resolved,
290            env_resolved_keys: env_resolved,
291            auth_source,
292            endpoint_class,
293            endpoint_override_configured,
294        }
295    }
296}
297
298fn resolved_endpoint_class(
299    provider: StorageProvider,
300    location: Option<&laminar_core::storage_location::StorageLocation>,
301    has_override: bool,
302) -> StorageEndpointClass {
303    if has_override {
304        return match provider {
305            StorageProvider::AwsS3 => StorageEndpointClass::S3Compatible,
306            StorageProvider::AzureAdls | StorageProvider::Gcs => {
307                StorageEndpointClass::CustomOrEmulator
308            }
309            StorageProvider::Local => StorageEndpointClass::Local,
310        };
311    }
312    location.map_or(StorageEndpointClass::Native, |location| {
313        location.endpoint_class()
314    })
315}
316
317fn effective_environment_value(option_key: &str, value: &str) -> bool {
318    if value.trim().is_empty() {
319        return false;
320    }
321    if option_key.ends_with("allow_http") {
322        return value.trim().eq_ignore_ascii_case("true");
323    }
324    true
325}
326
327fn is_endpoint_key(key: &str) -> bool {
328    matches!(
329        key.to_ascii_lowercase().as_str(),
330        "endpoint"
331            | "endpoint_url"
332            | "aws_endpoint"
333            | "aws_endpoint_url"
334            | "aws_endpoint_url_s3"
335            | "azure_endpoint"
336            | "azure_storage_endpoint"
337            | "base_url"
338            | "google_base_url"
339            | "google_service_path"
340            | "gcs.service.path"
341    )
342}
343
344#[cfg(test)]
345mod tests;