Skip to main content

faucet_common_azure/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! Shared Azure Blob Storage / ADLS Gen2 credential and client construction for
4//! the faucet source and sink connectors.
5//!
6//! Both `faucet-source-azure-blob` and `faucet-sink-azure-blob` build a single
7//! [`object_store`]-backed Azure store from an [`AzureConnection`] (account +
8//! container + credentials), so end users see one consistent config surface for
9//! both directions. ADLS Gen2 and classic Blob share the same
10//! `MicrosoftAzureBuilder`, so a single code path serves both.
11
12use std::str::FromStr;
13use std::sync::Arc;
14
15use faucet_core::FaucetError;
16use object_store::ObjectStore;
17use object_store::azure::{AzureConfigKey, MicrosoftAzureBuilder};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21/// Credential source for an Azure storage client.
22///
23/// Serializes as `{ type: <method>, config: { … } }` (adjacent tagging,
24/// snake_case discriminators) — the consistent auth wire shape shared by every
25/// faucet connector, e.g.
26/// `{ type: account_key, config: { account_key: "…" } }`.
27#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
28#[serde(tag = "type", content = "config", rename_all = "snake_case")]
29pub enum AzureCredentials {
30    /// Shared storage-account access key (the primary/secondary key).
31    AccountKey {
32        /// Base64 account key.
33        account_key: String,
34    },
35    /// Shared-access-signature token (with or without a leading `?`).
36    SasToken {
37        /// SAS token string.
38        sas_token: String,
39    },
40    /// Full storage connection string
41    /// (`DefaultEndpointsProtocol=…;AccountName=…;AccountKey=…;…`).
42    ConnectionString {
43        /// Connection string.
44        connection_string: String,
45    },
46    /// Azure Managed Identity (IMDS). For a user-assigned identity, set
47    /// `client_id`; leave it unset for the system-assigned identity.
48    ManagedIdentity {
49        /// Optional user-assigned managed-identity client id.
50        #[serde(default, skip_serializing_if = "Option::is_none")]
51        client_id: Option<String>,
52    },
53    /// Azure AD service principal (client credentials).
54    ServicePrincipal {
55        /// Application (client) id.
56        client_id: String,
57        /// Client secret.
58        client_secret: String,
59        /// Directory (tenant) id.
60        tenant_id: String,
61    },
62    /// `DefaultAzureCredential`-style resolution: the object-store builder's
63    /// default chain (environment variables, workload identity, managed
64    /// identity, Azure CLI), honouring `AZURE_*` env vars. This is the default.
65    #[default]
66    Default,
67}
68
69impl AzureCredentials {
70    /// Map this credential to the set of `object_store` Azure config
71    /// key/value pairs that select it.
72    ///
73    /// Keys are the canonical `azure_storage_*` alias strings that
74    /// [`AzureConfigKey::from_str`] accepts; returning them as plain strings
75    /// keeps this mapping unit-testable without constructing a live store.
76    /// [`Default`](AzureCredentials::Default) returns an empty set — the
77    /// builder's default credential chain is used unchanged.
78    pub fn config_entries(&self) -> Vec<(&'static str, String)> {
79        match self {
80            AzureCredentials::AccountKey { account_key } => {
81                vec![("azure_storage_access_key", account_key.clone())]
82            }
83            AzureCredentials::SasToken { sas_token } => {
84                vec![("azure_storage_sas_key", sas_token.clone())]
85            }
86            // `object_store` has no single connection-string config key, so we
87            // parse the string into the account/key/sas/endpoint config keys it
88            // does understand.
89            AzureCredentials::ConnectionString { connection_string } => {
90                parse_connection_string(connection_string)
91            }
92            // Setting the client id selects the user-assigned managed identity;
93            // a system-assigned identity (no client id) is picked up by the
94            // builder's default credential chain (IMDS), so no key is needed.
95            AzureCredentials::ManagedIdentity { client_id } => match client_id {
96                Some(id) => vec![("azure_storage_client_id", id.clone())],
97                None => Vec::new(),
98            },
99            AzureCredentials::ServicePrincipal {
100                client_id,
101                client_secret,
102                tenant_id,
103            } => vec![
104                ("azure_storage_client_id", client_id.clone()),
105                ("azure_storage_client_secret", client_secret.clone()),
106                ("azure_storage_tenant_id", tenant_id.clone()),
107            ],
108            AzureCredentials::Default => Vec::new(),
109        }
110    }
111}
112
113/// Parse an Azure storage connection string into the `object_store` config
114/// key/value pairs it understands.
115///
116/// A connection string is a `;`-separated list of `Key=Value` segments, e.g.
117/// `DefaultEndpointsProtocol=https;AccountName=x;AccountKey=y;EndpointSuffix=core.windows.net`.
118/// Only the segments that map to an authentication/endpoint config key are
119/// emitted (`AccountName`, `AccountKey`, `SharedAccessSignature`,
120/// `BlobEndpoint`); protocol/suffix hints are ignored, so the default public-cloud
121/// endpoint is used. Pure — unit-tested without a live store.
122fn parse_connection_string(cs: &str) -> Vec<(&'static str, String)> {
123    let mut entries: Vec<(&'static str, String)> = Vec::new();
124    for segment in cs.split(';') {
125        let segment = segment.trim();
126        if segment.is_empty() {
127            continue;
128        }
129        let Some((key, value)) = segment.split_once('=') else {
130            continue;
131        };
132        let value = value.trim().to_string();
133        if value.is_empty() {
134            continue;
135        }
136        // `AccountKey` values may themselves contain `=` (base64 padding); the
137        // `split_once` above keeps everything after the first `=` intact.
138        match key.trim() {
139            "AccountName" => entries.push(("azure_storage_account_name", value)),
140            "AccountKey" => entries.push(("azure_storage_access_key", value)),
141            "SharedAccessSignature" => entries.push(("azure_storage_sas_key", value)),
142            "BlobEndpoint" => entries.push(("azure_storage_endpoint", value)),
143            _ => {}
144        }
145    }
146    entries
147}
148
149/// Connection parameters shared by the Azure source and sink.
150///
151/// Flattened into each connector's config via `#[serde(flatten)]`, so
152/// `container`, `account`, `auth`, … appear at the top level of the source /
153/// sink config.
154#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
155pub struct AzureConnection {
156    /// Blob container / ADLS Gen2 filesystem name. Required.
157    pub container: String,
158    /// Storage-account name. Optional when a connection string or the
159    /// emulator supplies it, otherwise required.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub account: Option<String>,
162    /// Credential source. Defaults to the environment / managed-identity
163    /// credential chain.
164    #[serde(default)]
165    pub auth: AzureCredentials,
166    /// Custom blob endpoint (e.g. an Azurite emulator or a sovereign cloud).
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub endpoint: Option<String>,
169    /// Permit plaintext HTTP (required for a local Azurite emulator).
170    #[serde(default)]
171    pub allow_http: bool,
172    /// Target the Azurite storage emulator with its well-known
173    /// `devstoreaccount1` credentials.
174    #[serde(default)]
175    pub use_emulator: bool,
176}
177
178impl AzureConnection {
179    /// New connection targeting `container` with default credentials.
180    pub fn new(container: impl Into<String>) -> Self {
181        Self {
182            container: container.into(),
183            account: None,
184            auth: AzureCredentials::default(),
185            endpoint: None,
186            allow_http: false,
187            use_emulator: false,
188        }
189    }
190
191    /// Set the storage-account name.
192    pub fn account(mut self, account: impl Into<String>) -> Self {
193        self.account = Some(account.into());
194        self
195    }
196
197    /// Set the credential source.
198    pub fn auth(mut self, auth: AzureCredentials) -> Self {
199        self.auth = auth;
200        self
201    }
202
203    /// Set a custom blob endpoint (emulator / sovereign cloud).
204    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
205        self.endpoint = Some(endpoint.into());
206        self
207    }
208
209    /// Permit plaintext HTTP.
210    pub fn allow_http(mut self, allow: bool) -> Self {
211        self.allow_http = allow;
212        self
213    }
214
215    /// Target the Azurite emulator.
216    pub fn use_emulator(mut self, use_emulator: bool) -> Self {
217        self.use_emulator = use_emulator;
218        self
219    }
220}
221
222/// Build an [`object_store`] Azure store from an [`AzureConnection`].
223///
224/// The builder starts from [`MicrosoftAzureBuilder::from_env`] so `AZURE_*`
225/// environment variables act as a fallback; explicit config overrides them.
226/// All build failures map to [`FaucetError::Config`].
227pub fn build_store(conn: &AzureConnection) -> Result<Arc<dyn ObjectStore>, FaucetError> {
228    if conn.container.trim().is_empty() {
229        return Err(FaucetError::Config(
230            "azure: container name must not be empty".into(),
231        ));
232    }
233
234    let mut builder = MicrosoftAzureBuilder::from_env().with_container_name(&conn.container);
235
236    if let Some(account) = &conn.account {
237        builder = builder.with_account(account);
238    }
239    if conn.use_emulator {
240        builder = builder.with_use_emulator(true);
241    }
242    if let Some(endpoint) = &conn.endpoint {
243        builder = builder.with_endpoint(endpoint.clone());
244    }
245    if conn.allow_http {
246        builder = builder.with_allow_http(true);
247    }
248
249    for (key, value) in conn.auth.config_entries() {
250        let config_key = AzureConfigKey::from_str(key)
251            .map_err(|e| FaucetError::Config(format!("azure: unknown config key '{key}': {e}")))?;
252        builder = builder.with_config(config_key, value);
253    }
254
255    let store = builder
256        .build()
257        .map_err(|e| FaucetError::Config(format!("azure: failed to build client: {e}")))?;
258    Ok(Arc::new(store))
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use serde_json::json;
265
266    #[test]
267    fn credentials_default_is_default_variant() {
268        assert_eq!(AzureCredentials::default(), AzureCredentials::Default);
269    }
270
271    #[test]
272    fn default_credential_has_no_config_entries() {
273        assert!(AzureCredentials::Default.config_entries().is_empty());
274    }
275
276    #[test]
277    fn account_key_sets_access_key() {
278        let creds = AzureCredentials::AccountKey {
279            account_key: "abc123".into(),
280        };
281        assert_eq!(
282            creds.config_entries(),
283            vec![("azure_storage_access_key", "abc123".to_string())]
284        );
285    }
286
287    #[test]
288    fn sas_token_sets_sas_key() {
289        let creds = AzureCredentials::SasToken {
290            sas_token: "sv=2021".into(),
291        };
292        assert_eq!(
293            creds.config_entries(),
294            vec![("azure_storage_sas_key", "sv=2021".to_string())]
295        );
296    }
297
298    #[test]
299    fn connection_string_parses_into_account_and_key() {
300        let creds = AzureCredentials::ConnectionString {
301            connection_string:
302                "DefaultEndpointsProtocol=https;AccountName=acct;AccountKey=a2V5==;EndpointSuffix=core.windows.net"
303                    .into(),
304        };
305        let entries = creds.config_entries();
306        assert!(entries.contains(&("azure_storage_account_name", "acct".to_string())));
307        // AccountKey value retains its base64 padding (`=`).
308        assert!(entries.contains(&("azure_storage_access_key", "a2V5==".to_string())));
309        // Protocol / suffix hints are ignored.
310        assert_eq!(entries.len(), 2);
311    }
312
313    #[test]
314    fn connection_string_parses_sas_and_endpoint() {
315        let creds = AzureCredentials::ConnectionString {
316            connection_string:
317                "SharedAccessSignature=sv=2021&sig=abc;BlobEndpoint=http://127.0.0.1:10000/acct"
318                    .into(),
319        };
320        let entries = creds.config_entries();
321        assert!(entries.contains(&("azure_storage_sas_key", "sv=2021&sig=abc".to_string())));
322        assert!(entries.contains(&(
323            "azure_storage_endpoint",
324            "http://127.0.0.1:10000/acct".to_string()
325        )));
326    }
327
328    #[test]
329    fn connection_string_ignores_blank_and_unknown_segments() {
330        assert!(parse_connection_string("").is_empty());
331        assert!(parse_connection_string(";;Foo=bar;").is_empty());
332        assert!(parse_connection_string("AccountKey=").is_empty());
333    }
334
335    #[test]
336    fn managed_identity_user_assigned_sets_client_id() {
337        let creds = AzureCredentials::ManagedIdentity {
338            client_id: Some("mi-client".into()),
339        };
340        assert_eq!(
341            creds.config_entries(),
342            vec![("azure_storage_client_id", "mi-client".to_string())]
343        );
344    }
345
346    #[test]
347    fn managed_identity_system_assigned_sets_no_config() {
348        let creds = AzureCredentials::ManagedIdentity { client_id: None };
349        assert!(creds.config_entries().is_empty());
350    }
351
352    #[test]
353    fn service_principal_sets_all_three_fields() {
354        let creds = AzureCredentials::ServicePrincipal {
355            client_id: "cid".into(),
356            client_secret: "secret".into(),
357            tenant_id: "tid".into(),
358        };
359        let entries = creds.config_entries();
360        assert!(entries.contains(&("azure_storage_client_id", "cid".to_string())));
361        assert!(entries.contains(&("azure_storage_client_secret", "secret".to_string())));
362        assert!(entries.contains(&("azure_storage_tenant_id", "tid".to_string())));
363    }
364
365    #[test]
366    fn credentials_serde_account_key_round_trip() {
367        let creds = AzureCredentials::AccountKey {
368            account_key: "k".into(),
369        };
370        let v = serde_json::to_value(&creds).unwrap();
371        assert_eq!(
372            v,
373            json!({"type": "account_key", "config": {"account_key": "k"}})
374        );
375        let back: AzureCredentials = serde_json::from_value(v).unwrap();
376        assert_eq!(back, creds);
377    }
378
379    #[test]
380    fn credentials_serde_default_round_trip() {
381        let v = serde_json::to_value(AzureCredentials::Default).unwrap();
382        assert_eq!(v, json!({"type": "default"}));
383        let back: AzureCredentials = serde_json::from_value(v).unwrap();
384        assert_eq!(back, AzureCredentials::Default);
385    }
386
387    #[test]
388    fn credentials_serde_service_principal_round_trip() {
389        let creds = AzureCredentials::ServicePrincipal {
390            client_id: "cid".into(),
391            client_secret: "sec".into(),
392            tenant_id: "tid".into(),
393        };
394        let v = serde_json::to_value(&creds).unwrap();
395        assert_eq!(v["type"], "service_principal");
396        let back: AzureCredentials = serde_json::from_value(v).unwrap();
397        assert_eq!(back, creds);
398    }
399
400    #[test]
401    fn connection_builder_sets_fields() {
402        let conn = AzureConnection::new("data")
403            .account("acct")
404            .auth(AzureCredentials::AccountKey {
405                account_key: "k".into(),
406            })
407            .endpoint("http://127.0.0.1:10000/devstoreaccount1")
408            .allow_http(true)
409            .use_emulator(true);
410        assert_eq!(conn.container, "data");
411        assert_eq!(conn.account.as_deref(), Some("acct"));
412        assert!(conn.allow_http);
413        assert!(conn.use_emulator);
414        assert_eq!(
415            conn.endpoint.as_deref(),
416            Some("http://127.0.0.1:10000/devstoreaccount1")
417        );
418    }
419
420    #[test]
421    fn build_store_rejects_empty_container() {
422        let conn = AzureConnection::new("   ");
423        let err = build_store(&conn).unwrap_err();
424        assert!(matches!(err, FaucetError::Config(_)));
425    }
426
427    #[test]
428    fn build_store_succeeds_lazily_with_account_key() {
429        // The builder is lazy (no I/O at build time), so a well-formed config
430        // constructs a store even without a reachable backend. This exercises
431        // the full config-entry → with_config wiring for the account-key path.
432        let conn = AzureConnection::new("data")
433            .account("devstoreaccount1")
434            .auth(AzureCredentials::AccountKey {
435                account_key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==".into(),
436            })
437            .endpoint("http://127.0.0.1:10000/devstoreaccount1")
438            .allow_http(true);
439        assert!(build_store(&conn).is_ok());
440    }
441
442    #[test]
443    fn build_store_succeeds_lazily_with_emulator_and_default_creds() {
444        let conn = AzureConnection::new("data")
445            .use_emulator(true)
446            .allow_http(true);
447        assert!(build_store(&conn).is_ok());
448    }
449
450    #[test]
451    fn build_store_succeeds_lazily_with_service_principal() {
452        let conn =
453            AzureConnection::new("data")
454                .account("acct")
455                .auth(AzureCredentials::ServicePrincipal {
456                    client_id: "cid".into(),
457                    client_secret: "sec".into(),
458                    tenant_id: "tid".into(),
459                });
460        assert!(build_store(&conn).is_ok());
461    }
462}