Skip to main content

laminar_core/
storage_auth.rs

1//! Non-secret classification of object-store credential discovery.
2//!
3//! Provider clients retain ownership of credential loading and refresh. This
4//! module inspects only whether configured sources are present so callers can
5//! report useful diagnostics without retaining or displaying secret values.
6
7#![allow(clippy::disallowed_types)] // cold path: storage configuration
8
9use std::collections::HashMap;
10use std::fmt;
11use std::hash::BuildHasher;
12
13use crate::storage_location::StorageProvider;
14
15/// Non-secret description of the selected credential mechanism.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum AuthSource {
18    /// Long-lived credentials were supplied in connector configuration.
19    ExplicitStatic,
20    /// A bearer or session token was supplied in connector configuration.
21    ExplicitToken,
22    /// Long-lived credentials were discovered in provider environment variables.
23    EnvironmentStatic,
24    /// A bearer, session, or shared-access token was discovered in the environment.
25    EnvironmentToken,
26    /// An explicitly selected provider profile will be loaded downstream.
27    Profile,
28    /// AWS web identity will be exchanged by the downstream provider.
29    WebIdentity,
30    /// Federated workload identity will be loaded by the downstream provider.
31    WorkloadIdentity,
32    /// Instance, container, or managed-identity metadata will be used downstream.
33    ManagedIdentityOrMetadata,
34    /// Google Application Default Credentials will be loaded downstream.
35    ApplicationDefault,
36    /// An existing Azure CLI session will be loaded downstream.
37    AzureCli,
38    /// Requests are deliberately unsigned for a compatibility/test endpoint.
39    Anonymous,
40    /// The pinned provider library retains responsibility for credential discovery.
41    DownstreamDefault,
42    /// The credential mechanism could not be classified.
43    Unknown,
44}
45
46impl fmt::Display for AuthSource {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        formatter.write_str(match self {
49            Self::ExplicitStatic => "explicit-static",
50            Self::ExplicitToken => "explicit-token",
51            Self::EnvironmentStatic => "environment-static",
52            Self::EnvironmentToken => "environment-token",
53            Self::Profile => "profile",
54            Self::WebIdentity => "web-identity",
55            Self::WorkloadIdentity => "workload-identity",
56            Self::ManagedIdentityOrMetadata => "managed-identity-or-metadata",
57            Self::ApplicationDefault => "application-default",
58            Self::AzureCli => "azure-cli",
59            Self::Anonymous => "anonymous",
60            Self::DownstreamDefault => "downstream-default",
61            Self::Unknown => "unknown",
62        })
63    }
64}
65
66/// Classify a provider's effective credential source without loading credential values.
67///
68/// Explicit non-empty options take precedence over ambient sources. The environment lookup is
69/// intentionally borrowed by callback so short-lived values remain owned by the downstream
70/// provider's refreshable credential chain.
71#[must_use]
72pub fn classify_storage_auth_source<F, S>(
73    provider: StorageProvider,
74    explicit: &HashMap<String, String, S>,
75    env_lookup: &F,
76) -> AuthSource
77where
78    F: Fn(&str) -> Option<String>,
79    S: BuildHasher,
80{
81    let signals = AuthSignals {
82        explicit,
83        env_lookup,
84    };
85    match provider {
86        StorageProvider::AwsS3 => classify_aws(&signals),
87        StorageProvider::AzureAdls => classify_azure(&signals),
88        StorageProvider::Gcs => classify_gcs(&signals),
89        StorageProvider::Local => AuthSource::Anonymous,
90    }
91}
92
93struct AuthSignals<'a, F, S> {
94    explicit: &'a HashMap<String, String, S>,
95    env_lookup: &'a F,
96}
97
98impl<F, S> AuthSignals<'_, F, S>
99where
100    F: Fn(&str) -> Option<String>,
101    S: BuildHasher,
102{
103    fn explicit_has(&self, keys: &[&str]) -> bool {
104        self.explicit.iter().any(|(candidate, value)| {
105            keys.iter().any(|key| candidate.eq_ignore_ascii_case(key)) && !value.trim().is_empty()
106        })
107    }
108
109    fn explicit_true(&self, keys: &[&str]) -> bool {
110        self.explicit.iter().any(|(candidate, value)| {
111            keys.iter().any(|key| candidate.eq_ignore_ascii_case(key))
112                && value.trim().eq_ignore_ascii_case("true")
113        })
114    }
115
116    fn env_has(&self, keys: &[&str]) -> bool {
117        keys.iter()
118            .any(|key| (self.env_lookup)(key).is_some_and(|value| !value.trim().is_empty()))
119    }
120
121    fn env_true(&self, keys: &[&str]) -> bool {
122        keys.iter().any(|key| {
123            (self.env_lookup)(key).is_some_and(|value| value.trim().eq_ignore_ascii_case("true"))
124        })
125    }
126}
127
128fn classify_aws<F, S>(signals: &AuthSignals<'_, F, S>) -> AuthSource
129where
130    F: Fn(&str) -> Option<String>,
131    S: BuildHasher,
132{
133    if signals.explicit_true(&["aws_skip_signature", "skip_signature"]) {
134        AuthSource::Anonymous
135    } else if signals.explicit_has(&["aws_session_token", "aws_token", "session_token", "token"]) {
136        AuthSource::ExplicitToken
137    } else if signals.explicit_has(&["aws_access_key_id", "access_key_id"])
138        || signals.explicit_has(&["aws_secret_access_key", "secret_access_key"])
139    {
140        AuthSource::ExplicitStatic
141    } else if signals.explicit_has(&["aws_profile", "profile"]) {
142        AuthSource::Profile
143    } else if signals.explicit_has(&["aws_web_identity_token_file", "web_identity_token_file"]) {
144        AuthSource::WebIdentity
145    } else if signals.env_has(&["AWS_SESSION_TOKEN"]) {
146        AuthSource::EnvironmentToken
147    } else if signals.env_has(&["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]) {
148        AuthSource::EnvironmentStatic
149    } else if signals.env_has(&["AWS_WEB_IDENTITY_TOKEN_FILE"]) {
150        AuthSource::WebIdentity
151    } else if signals.env_has(&["AWS_PROFILE"]) {
152        AuthSource::Profile
153    } else if signals.env_has(&[
154        "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
155        "AWS_CONTAINER_CREDENTIALS_FULL_URI",
156    ]) {
157        AuthSource::ManagedIdentityOrMetadata
158    } else {
159        AuthSource::DownstreamDefault
160    }
161}
162
163fn classify_azure<F, S>(signals: &AuthSignals<'_, F, S>) -> AuthSource
164where
165    F: Fn(&str) -> Option<String>,
166    S: BuildHasher,
167{
168    if signals.explicit_true(&["azure_skip_signature", "skip_signature"])
169        || signals.explicit_true(&["azure_storage_use_emulator", "use_emulator"])
170    {
171        AuthSource::Anonymous
172    } else if signals.explicit_has(&[
173        "azure_storage_account_key",
174        "azure_storage_access_key",
175        "account_key",
176        "azure_storage_client_secret",
177        "azure_client_secret",
178        "client_secret",
179    ]) {
180        AuthSource::ExplicitStatic
181    } else if signals.explicit_has(&[
182        "azure_storage_sas_token",
183        "azure_storage_sas_key",
184        "sas_token",
185        "azure_storage_token",
186        "bearer_token",
187    ]) {
188        AuthSource::ExplicitToken
189    } else if signals.explicit_has(&["azure_federated_token_file", "federated_token_file"]) {
190        AuthSource::WorkloadIdentity
191    } else if signals.explicit_true(&["azure_use_azure_cli", "use_azure_cli"]) {
192        AuthSource::AzureCli
193    } else if signals.env_has(&["AZURE_STORAGE_SAS_TOKEN", "AZURE_STORAGE_TOKEN"]) {
194        AuthSource::EnvironmentToken
195    } else if signals.env_has(&["AZURE_STORAGE_ACCOUNT_KEY", "AZURE_CLIENT_SECRET"]) {
196        AuthSource::EnvironmentStatic
197    } else if signals.env_has(&["AZURE_FEDERATED_TOKEN_FILE"]) {
198        AuthSource::WorkloadIdentity
199    } else if signals.env_true(&["AZURE_USE_AZURE_CLI"]) {
200        AuthSource::AzureCli
201    } else {
202        AuthSource::ManagedIdentityOrMetadata
203    }
204}
205
206fn classify_gcs<F, S>(signals: &AuthSignals<'_, F, S>) -> AuthSource
207where
208    F: Fn(&str) -> Option<String>,
209    S: BuildHasher,
210{
211    if signals.explicit_true(&["google_skip_signature", "skip_signature"])
212        || signals.explicit_true(&["gcs.no-auth"])
213    {
214        AuthSource::Anonymous
215    } else if signals.explicit_has(&[
216        "google_service_account_key",
217        "service_account_key",
218        "google_service_account_path",
219        "service_account_path",
220    ]) {
221        AuthSource::ExplicitStatic
222    } else if signals.explicit_has(&["gcs.token", "google_token"]) {
223        AuthSource::ExplicitToken
224    } else if signals.explicit_has(&["google_application_credentials", "application_credentials"])
225        || signals.env_has(&["GOOGLE_APPLICATION_CREDENTIALS"])
226    {
227        AuthSource::ApplicationDefault
228    } else if signals.env_has(&[
229        "GOOGLE_SERVICE_ACCOUNT_KEY",
230        "SERVICE_ACCOUNT",
231        "GOOGLE_SERVICE_ACCOUNT",
232        "GOOGLE_SERVICE_ACCOUNT_PATH",
233    ]) {
234        AuthSource::EnvironmentStatic
235    } else {
236        AuthSource::ApplicationDefault
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn lookup(values: &[(&str, &str)], key: &str) -> Option<String> {
245        values
246            .iter()
247            .find_map(|(candidate, value)| (*candidate == key).then(|| (*value).to_string()))
248    }
249
250    #[test]
251    fn classifies_refreshable_ambient_sources_without_values() {
252        let options = HashMap::new();
253        assert_eq!(
254            classify_storage_auth_source(StorageProvider::AzureAdls, &options, &|key| lookup(
255                &[("AZURE_FEDERATED_TOKEN_FILE", "/token")],
256                key
257            )),
258            AuthSource::WorkloadIdentity
259        );
260        assert_eq!(
261            classify_storage_auth_source(StorageProvider::AwsS3, &options, &|key| lookup(
262                &[("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/task")],
263                key
264            )),
265            AuthSource::ManagedIdentityOrMetadata
266        );
267        assert_eq!(
268            classify_storage_auth_source(StorageProvider::Gcs, &options, &|_| None),
269            AuthSource::ApplicationDefault
270        );
271    }
272
273    #[test]
274    fn display_is_low_cardinality_and_non_secret() {
275        assert_eq!(AuthSource::WebIdentity.to_string(), "web-identity");
276        assert_eq!(AuthSource::AzureCli.to_string(), "azure-cli");
277    }
278
279    #[test]
280    fn explicit_azure_credentials_take_precedence_over_ambient_cli() {
281        let options = HashMap::from([(
282            "azure_storage_account_key".to_string(),
283            "not-logged".to_string(),
284        )]);
285        assert_eq!(
286            classify_storage_auth_source(StorageProvider::AzureAdls, &options, &|key| lookup(
287                &[("AZURE_USE_AZURE_CLI", "true")],
288                key
289            )),
290            AuthSource::ExplicitStatic
291        );
292    }
293
294    #[test]
295    fn explicit_gcs_token_has_a_non_secret_source_classification() {
296        let options = HashMap::from([("gcs.token".to_string(), "not-logged".to_string())]);
297        assert_eq!(
298            classify_storage_auth_source(StorageProvider::Gcs, &options, &|_| None),
299            AuthSource::ExplicitToken
300        );
301    }
302}